From b3d6a35651c245e7f8e3ae80b5091bc28372ad59 Mon Sep 17 00:00:00 2001 From: king Date: Tue, 15 Sep 2026 21:02:37 +0900 Subject: [PATCH] =?UTF-8?q?feat(project):=20=EC=99=84=EB=A3=8C=20=ED=86=A0?= =?UTF-8?q?=EA=B8=80,=20=EC=84=A4=EB=AA=85=20=EC=9D=B4=EB=AF=B8=EC=A7=80?= =?UTF-8?q?=20=ED=81=AC=EA=B8=B0=EC=A1=B0=EC=A0=88/=EC=82=AD=EC=A0=9C,=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=ED=8F=BC=20=EA=B0=80=EB=8F=85=EC=84=B1,=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC=20=EA=B3=A0=EC=A0=95=ED=81=AC=EA=B8=B0=20800?= =?UTF-8?q?x1100?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 업무 편집 모달에 "완료로 표시" 버튼 추가(완료 시 리스트뷰는 기존 취소선 스타일 그대로 적용됨). 완료 상태에 따라 버튼 색/문구 토글. - 설명란 이미지 클릭 시 선택되어 우하단 드래그로 크기 조절, × 버튼으로 삭제 가능. 크기는 style이 아닌 width 속성으로 저장해 서버 sanitize 후에도 유지. - 연결된 프로젝트 select 를 세로 배치+작은 폰트로 바꿔 글자 잘림 해결. - 업무 편집 모달을 800x1100 고정 크기로 바꾸고 좌(메타/설명)·우(하위업무· 첨부·연결·댓글) 칼럼이 각자 내부 스크롤하도록 재정비. fix(shell): 사이드바 접힘 상태를 localStorage에서 첫 페인트 전에 반영해 페이지 이동 시 "펼쳐졌다 다시 접히는" 깜빡임 제거. Co-Authored-By: Claude Sonnet 5 --- .../project/templates/project/inbox.html | 4 +- .../project/templates/project/index.html | 9 +- .../project/templates/project/project.html | 9 +- app/static/project.css | 59 ++++++++--- app/static/project.js | 100 +++++++++++++++++- app/templates/erp_base.html | 14 ++- 6 files changed, 169 insertions(+), 26 deletions(-) diff --git a/app/modules/project/templates/project/inbox.html b/app/modules/project/templates/project/inbox.html index 5b76100..aace27d 100644 --- a/app/modules/project/templates/project/inbox.html +++ b/app/modules/project/templates/project/inbox.html @@ -1,7 +1,7 @@ {% extends "erp_base.html" %} {% block head_extra %} - + {% endblock %} @@ -45,5 +45,5 @@ {% endif %} - + {% endblock %} diff --git a/app/modules/project/templates/project/index.html b/app/modules/project/templates/project/index.html index dad5632..b02cf60 100644 --- a/app/modules/project/templates/project/index.html +++ b/app/modules/project/templates/project/index.html @@ -1,7 +1,7 @@ {% extends "erp_base.html" %} {% block head_extra %} - + {% endblock %} @@ -292,7 +292,9 @@ -
+
+
+
@@ -343,6 +345,7 @@
+ 프로젝트에서 열기 @@ -373,5 +376,5 @@ - + {% endblock %} diff --git a/app/modules/project/templates/project/project.html b/app/modules/project/templates/project/project.html index de8f7b5..7eb8dad 100644 --- a/app/modules/project/templates/project/project.html +++ b/app/modules/project/templates/project/project.html @@ -1,7 +1,7 @@ {% extends "erp_base.html" %} {% block head_extra %} - + @@ -220,7 +220,9 @@
-
+
+
+
@@ -277,6 +279,7 @@
+ @@ -326,5 +329,5 @@ - + {% endblock %} diff --git a/app/static/project.css b/app/static/project.css index 6a0fe10..318bb66 100644 --- a/app/static/project.css +++ b/app/static/project.css @@ -310,16 +310,23 @@ .pj-modal { position: fixed; inset: 0; background: rgba(20,22,26,.46); display: flex; align-items: center; justify-content: center; z-index: 1000; } .pj-modal[hidden] { display: none; } .pj-modal-card { background: #fff; border-radius: 16px; padding: 22px; width: min(480px, 92vw); box-shadow: 0 20px 60px rgba(0,0,0,.25); max-height: 90vh; overflow-y: auto; } -/* 업무 편집 팝업만 넓게 — 메타/설명(좌) + 하위업무·첨부·연결·댓글(우) 2단. - 우측 칼럼만 자체 스크롤 시켜 팝업 전체 스크롤은 웬만하면 안 생기게 한다. */ -.pj-task-modal-card { width: min(780px, 96vw); } -.pj-modal-body { display: grid; grid-template-columns: 1.15fr .85fr; gap: 0 26px; align-items: start; } -.pj-modal-col-main, .pj-modal-col-side { min-width: 0; } -.pj-modal-col-side { max-height: 66vh; overflow-y: auto; padding-right: 4px; } +/* 업무 편집 팝업만 고정 크기(800x1100) — 메타/설명(좌) + 하위업무·첨부·연결· + 댓글(우) 2단. 카드 자체는 스크롤 없이 고정하고, 좌우 칼럼이 각자 내부 + 스크롤한다(우측은 늘 자체 스크롤, 좌측은 내용이 넘칠 때만). */ +.pj-task-modal-card { + width: 800px; max-width: 96vw; height: 1100px; max-height: 94vh; + display: flex; flex-direction: column; overflow: hidden; +} +.pj-task-modal-card > .pj-t-title-input { flex: none; } +.pj-task-modal-card .pj-modal-body { flex: 1 1 auto; min-height: 0; } +.pj-task-modal-card .pj-modal-actions { flex: none; } +.pj-modal-body { display: grid; grid-template-columns: 1.15fr .85fr; gap: 0 26px; align-items: stretch; height: 100%; } +.pj-modal-col-main, .pj-modal-col-side { min-width: 0; min-height: 0; overflow-y: auto; padding-right: 4px; } .pj-modal-col-side .pj-task-extra:first-child { margin-top: 0; padding-top: 0; border-top: none; } @media (max-width: 720px) { - .pj-modal-body { grid-template-columns: 1fr; } - .pj-modal-col-side { max-height: none; overflow-y: visible; padding-right: 0; } + .pj-task-modal-card { width: min(480px, 94vw); height: auto; max-height: 90vh; overflow-y: auto; display: block; } + .pj-modal-body { display: block; height: auto; } + .pj-modal-col-main, .pj-modal-col-side { overflow: visible; height: auto; padding-right: 0; } .pj-modal-col-side .pj-task-extra:first-child { border-top: 1px solid #eef0f2; margin-top: 14px; padding-top: 12px; } } .pj-modal-card h3 { margin: 0 0 16px; font-size: 17px; } @@ -359,8 +366,10 @@ .pj-form-row input[type="time"] { display: block; width: 100%; margin-top: 5px; padding: 8px 10px; border: 1px solid #d8dce2; border-radius: 8px; font-size: 13.5px; font-family: inherit; box-sizing: border-box; } .pj-form-row input[type="time"]:disabled { background: #f2f2f2; color: #aaa; cursor: not-allowed; } #pj-time-row:has(input:disabled) label { color: #aaa; } -.pj-modal-actions { display: flex; align-items: center; gap: 8px; margin-top: 8px; } -.pj-modal-actions .pj-btn:last-child { } +.pj-modal-actions { display: flex; align-items: center; gap: 8px; margin-top: 8px; flex-wrap: wrap; } +.pj-btn-complete { background: #e9f7ef; color: #1e8e5a; border: 1px solid #bfe8d2; font-weight: 700; } +.pj-btn-complete:hover { background: #d9f2e4; } +.pj-btn-complete.is-done { background: #f2f2f4; color: #6b6f76; border-color: var(--pj-border); } .pj-color-palette, #pj-f-colors { display: flex; gap: 8px; margin-top: 6px; } .pj-color-swatch { width: 24px; height: 24px; border-radius: 50%; border: 2px solid transparent; cursor: pointer; } @@ -476,9 +485,27 @@ line-height: 1.5; margin-bottom: 12px; background: #fff; } .pj-desc-editor:empty::before { content: attr(data-placeholder); color: #b7bdc5; } -.pj-desc-editor img { max-width: 100%; border-radius: 6px; margin: 4px 0; display: block; } +.pj-desc-editor img { max-width: 100%; border-radius: 6px; margin: 4px 0; display: block; cursor: pointer; } +.pj-desc-editor img.pj-img-selected { outline: 2px solid var(--pj-accent); outline-offset: 1px; } .pj-desc-editor[contenteditable="false"] { background: #f6f7f8; color: #9aa1a9; } +/* 설명란 이미지 선택 시 오버레이(삭제 × / 우하단 크기조절 핸들) — wrap 은 + position:relative, tools 는 선택된 img 의 offsetLeft/Top/Width/Height 로 + 자리를 맞춘다(box 자체는 static 이라 offsetParent 가 wrap 이 된다). */ +.pj-desc-wrap { position: relative; } +.pj-img-tools { position: absolute; pointer-events: none; } +.pj-img-del { + position: absolute; top: -9px; right: -9px; pointer-events: auto; + width: 20px; height: 20px; border-radius: 50%; border: none; background: #1e1f21; color: #fff; + font-size: 13px; line-height: 1; cursor: pointer; display: flex; align-items: center; justify-content: center; padding: 0; +} +.pj-img-del:hover { background: #c22b10; } +.pj-img-handle { + position: absolute; bottom: -6px; right: -6px; pointer-events: auto; + width: 13px; height: 13px; border-radius: 3px; background: var(--pj-accent); cursor: nwse-resize; + border: 2px solid #fff; box-shadow: 0 0 0 1px rgba(0,0,0,.25); +} + /* ── 하위 업무(인라인) ── */ .pj-subtask-list { list-style: none; margin: 0 0 8px; padding: 0; } .pj-subtask-row { display: flex; align-items: center; gap: 6px; padding: 4px 2px; } @@ -518,8 +545,14 @@ } .pj-link-chip .pj-link-del { border: none; background: transparent; cursor: pointer; color: #9aa1a9; font-size: 13px; line-height: 1; padding: 2px; } .pj-link-chip .pj-link-del:hover { color: #c22b10; } -.pj-link-form { display: flex; gap: 6px; align-items: center; } -.pj-link-form select { flex: 1; padding: 7px 8px; border: 1px solid #d8dce2; border-radius: 8px; font-size: 13px; font-family: inherit; } +/* 세로로 쌓기 — 옆 칼럼이 좁아 가로 배치면 select 글자가 잘린다. */ +.pj-link-form { display: flex; flex-direction: column; gap: 6px; align-items: stretch; } +.pj-link-form select { + width: 100% !important; flex: none; margin: 0 !important; + padding: 6px 8px !important; border: 1px solid #d8dce2 !important; border-radius: 8px; + font-size: 11.5px !important; font-family: inherit; box-sizing: border-box; +} +.pj-link-form .pj-btn { align-self: flex-end; } /* ── 알림센터 ── */ .pj-inbox { list-style: none; margin: 0; padding: 0; } diff --git a/app/static/project.js b/app/static/project.js index 336a8fa..4f649c1 100644 --- a/app/static/project.js +++ b/app/static/project.js @@ -348,6 +348,8 @@ el("pj-t-dtime").disabled = !useTime; el("pj-t-open-project").href = "/project/p/" + task.project_id + "?task=" + task.id; el("pj-delete-task").hidden = false; + el("pj-toggle-complete").hidden = false; + setHomeCompleteBtn(!!task.completed_at); el("pj-t-open-project").hidden = false; el("pj-attach-block").hidden = false; el("pj-comment-block").hidden = false; @@ -377,6 +379,7 @@ el("pj-t-stime").value = ""; el("pj-t-stime").disabled = true; el("pj-t-dtime").value = ""; el("pj-t-dtime").disabled = true; el("pj-delete-task").hidden = true; + el("pj-toggle-complete").hidden = true; el("pj-t-open-project").hidden = true; el("pj-attach-block").hidden = true; el("pj-comment-block").hidden = true; @@ -385,6 +388,21 @@ openModal(homeTaskModal); await Promise.all([fillAssignee(projectId, ""), fillStage(projectId, null)]); } + function setHomeCompleteBtn(done) { + const btn = el("pj-toggle-complete"); + btn.textContent = done ? "↩ 완료 취소" : "✓ 완료로 표시"; + btn.classList.toggle("is-done", done); + btn.dataset.completed = done ? "1" : ""; + } + el("pj-toggle-complete").addEventListener("click", async function () { + const id = el("pj-t-id").value; + if (!id) return; + const done = el("pj-toggle-complete").dataset.completed === "1"; + try { + await api("PUT", "/project/api/tasks/" + id, { completed: !done }); + location.reload(); + } catch (e) { alert("처리 실패: " + e.message); } + }); document.querySelectorAll(".pj-card-addtask").forEach(function (btn) { btn.addEventListener("click", function (e) { e.preventDefault(); e.stopPropagation(); @@ -1584,6 +1602,8 @@ el("pj-t-stime").disabled = !useTime; el("pj-t-dtime").disabled = !useTime; el("pj-delete-task").hidden = !task; + el("pj-toggle-complete").hidden = !task; + if (task) setCompleteBtn(!!task.completed_at); // 댓글·첨부·하위업무·연결은 기존 업무 편집 시에만 currentTaskId = task ? task.id : null; el("pj-attach-block").hidden = !task; @@ -1594,6 +1614,24 @@ openModal(taskModal); } + function setCompleteBtn(done) { + const btn = el("pj-toggle-complete"); + btn.textContent = done ? "↩ 완료 취소" : "✓ 완료로 표시"; + btn.classList.toggle("is-done", done); + btn.dataset.completed = done ? "1" : ""; + } + el("pj-toggle-complete").addEventListener("click", async function () { + const id = el("pj-t-id").value; + if (!id) return; + const done = el("pj-toggle-complete").dataset.completed === "1"; + try { + const res = await api("PUT", "/project/api/tasks/" + id, { completed: !done }); + mergeTask(res.task); + setCompleteBtn(!!res.task.completed_at); + refreshActiveView(); + } catch (e) { alert("처리 실패: " + e.message); } + }); + // ── 첨부파일 ── function fmtSize(n) { if (n < 1024) return n + " B"; @@ -1906,10 +1944,70 @@ function initDescEditor() { const box = el("pj-t-desc"); if (!box) return null; + const wrap = box.parentElement; const imgInput = el("pj-desc-img-input"); const hint = el("pj-desc-hint"); let taskId = null; + // ── 설명란 이미지 선택 → 크기 조절 핸들 + 삭제 버튼 오버레이 ── + // 크기는 style이 아니라 width 속성으로 저장한다(서버 sanitize가 style은 + // 버리지만 img width/height 속성은 허용 목록에 있어 저장 후에도 유지된다). + const imgTools = document.createElement("div"); + imgTools.className = "pj-img-tools"; + imgTools.hidden = true; + imgTools.innerHTML = + '' + + ''; + wrap.appendChild(imgTools); + const imgDelBtn = imgTools.querySelector(".pj-img-del"); + const imgHandle = imgTools.querySelector(".pj-img-handle"); + let selectedImg = null; + + function positionImgTools() { + if (!selectedImg || !selectedImg.isConnected) { selectedImg = null; imgTools.hidden = true; return; } + imgTools.style.left = selectedImg.offsetLeft + "px"; + imgTools.style.top = selectedImg.offsetTop + "px"; + imgTools.style.width = selectedImg.offsetWidth + "px"; + imgTools.style.height = selectedImg.offsetHeight + "px"; + imgTools.hidden = false; + } + function selectImg(img) { + if (selectedImg) selectedImg.classList.remove("pj-img-selected"); + selectedImg = img || null; + if (selectedImg) { selectedImg.classList.add("pj-img-selected"); positionImgTools(); } + else { imgTools.hidden = true; } + } + box.addEventListener("click", function (e) { + if (e.target.tagName === "IMG") selectImg(e.target); + else selectImg(null); + }); + document.addEventListener("click", function (e) { + if (selectedImg && !wrap.contains(e.target)) selectImg(null); + }); + imgDelBtn.addEventListener("click", function () { + if (selectedImg) selectedImg.remove(); + selectImg(null); + }); + let dragging = false, dragStartX = 0, dragStartW = 0; + imgHandle.addEventListener("mousedown", function (e) { + if (!selectedImg) return; + e.preventDefault(); + dragging = true; + dragStartX = e.clientX; + dragStartW = selectedImg.offsetWidth; + }); + document.addEventListener("mousemove", function (e) { + if (!dragging || !selectedImg) return; + let newW = Math.round(dragStartW + (e.clientX - dragStartX)); + newW = Math.max(40, Math.min(newW, wrap.clientWidth || 9999)); + selectedImg.setAttribute("width", String(newW)); + selectedImg.removeAttribute("height"); + selectedImg.style.width = ""; + selectedImg.style.height = ""; + positionImgTools(); + }); + document.addEventListener("mouseup", function () { dragging = false; }); + function setTaskId(id) { taskId = id; const enabled = !!id; @@ -1973,7 +2071,7 @@ return { setTaskId: setTaskId, - setHtml: function (html) { box.innerHTML = html || ""; }, + setHtml: function (html) { selectImg(null); box.innerHTML = html || ""; }, getHtml: function () { return box.innerHTML; }, }; } diff --git a/app/templates/erp_base.html b/app/templates/erp_base.html index 45b0a06..8929d05 100644 --- a/app/templates/erp_base.html +++ b/app/templates/erp_base.html @@ -16,6 +16,15 @@