From 278151a3fc12dbd058832d00ef1cf94307699bb6 Mon Sep 17 00:00:00 2001 From: king Date: Wed, 16 Sep 2026 00:38:43 +0900 Subject: [PATCH] =?UTF-8?q?fix(project):=20=EC=97=85=EB=AC=B4=20=EB=AA=A8?= =?UTF-8?q?=EB=8B=AC=20Esc=20=EB=8B=AB=EA=B8=B0+=EB=AF=B8=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=ED=99=95=EC=9D=B8,=20=EC=A0=9C=EB=AA=A9=EC=97=90?= =?UTF-8?q?=20=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8=EB=AA=85,=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20=EC=82=BD=EC=9E=85=20=EB=B2=84=EA=B7=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Esc/배경 클릭/취소 버튼으로 업무 모달을 닫을 때 폼이 열었을 때와 달라져 있으면 erpConfirm 으로 "저장하시겠습니까?" 확인 후 저장 또는 그냥 닫기. erpConfirm 자체도 Esc 로 닫히며 stopPropagation 을 안 해 재진입할 수 있어 플래그로 막음. - 모달 제목을 "업무 편집"/"새 업무" 단독 표기에서 "[프로젝트명]의 업무 편집"/"[프로젝트명]의 새 업무"로 통일(편집 모드도 프로젝트명 표시). - 이미지 삽입 버그 2건 수정: 1) 커서 위치 오삽입 — 업로드 버튼 클릭 후 파일선택창이 닫히고 포커스가 에디터로 돌아오면 브라우저가 선택범위를 엉뚱한 위치로 되살리는 경우가 있었는데, 기존 코드는 "선택범위가 에디터 밖에 있을 때만" 저장해둔 위치로 복원해서 이 경우를 못 잡았다 — 업로드 시작 직전 위치를 무조건 복원하도록 수정. 2) 저장 후 이미지가 사라짐 — 업로드가 끝나기 전에 저장 버튼을 누르면 설명에 이미지가 아직 안 들어간 채로 저장됐다. 업로드 중엔 저장을 막고 안내 문구를 보여줌. Co-Authored-By: Claude Sonnet 5 --- .../project/templates/project/inbox.html | 2 +- .../project/templates/project/index.html | 2 +- .../project/templates/project/project.html | 2 +- app/static/project.js | 131 +++++++++++++++++- 4 files changed, 128 insertions(+), 9 deletions(-) diff --git a/app/modules/project/templates/project/inbox.html b/app/modules/project/templates/project/inbox.html index 5843b19..432bb47 100644 --- a/app/modules/project/templates/project/inbox.html +++ b/app/modules/project/templates/project/inbox.html @@ -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 969619f..fbf9fe9 100644 --- a/app/modules/project/templates/project/index.html +++ b/app/modules/project/templates/project/index.html @@ -392,5 +392,5 @@ - + {% endblock %} diff --git a/app/modules/project/templates/project/project.html b/app/modules/project/templates/project/project.html index 57c1220..f89ff45 100644 --- a/app/modules/project/templates/project/project.html +++ b/app/modules/project/templates/project/project.html @@ -405,5 +405,5 @@ - + {% endblock %} diff --git a/app/static/project.js b/app/static/project.js index e6b8a28..31ea712 100644 --- a/app/static/project.js +++ b/app/static/project.js @@ -208,11 +208,54 @@ // ── 업무 편집 팝업 (카드/내 업무 클릭 시 페이지 이동 없이) ── const homeTaskModal = el("pj-modal-task"); if (homeTaskModal) { - wireModalClose(homeTaskModal); const descEditor = initDescEditor(); const subtaskBlock = initSubtaskBlock(); const linksBlock = initLinksBlock(); + // 닫을 때(배경 클릭·Esc·취소 버튼) 수정한 내용이 있으면 저장할지 물어본다. + let homeTaskSnapshot = null; + function homeTaskFormSnapshot() { + return JSON.stringify({ + title: el("pj-t-title").value, + assignee: el("pj-t-assignee").value, + stage: el("pj-t-stage").value, + priority: el("pj-t-priority").value, + start: el("pj-t-start").value, + due: el("pj-t-due").value, + usetime: el("pj-t-usetime").checked, + stime: el("pj-t-stime").value, + dtime: el("pj-t-dtime").value, + desc: descEditor ? descEditor.getHtml() : "", + }); + } + function isHomeTaskDirty() { + return homeTaskSnapshot !== null && homeTaskFormSnapshot() !== homeTaskSnapshot; + } + let homeTaskClosing = false; + async function attemptCloseHomeTaskModal() { + // erpConfirm 도 Esc 로 닫히는데 stopPropagation 을 안 해서, 확인창이 + // 뜬 상태에서 Esc 를 누르면 이 함수가 다시 불릴 수 있다 — 재진입 방지. + if (homeTaskModal.hidden || homeTaskClosing) return; + homeTaskClosing = true; + try { + if (isHomeTaskDirty()) { + const save = await window.erpConfirm( + "수정한 내용이 있습니다. 저장하시겠습니까?", + { okText: "저장", cancelText: "저장 안 함" } + ); + if (save) { el("pj-save-task").click(); return; } + } + homeTaskSnapshot = null; + closeModal(homeTaskModal); + } finally { homeTaskClosing = false; } + } + homeTaskModal.addEventListener("click", function (e) { + if (e.target === homeTaskModal || e.target.hasAttribute("data-close")) attemptCloseHomeTaskModal(); + }); + document.addEventListener("keydown", function (e) { + if (e.key === "Escape" && !homeTaskModal.hidden) { e.preventDefault(); attemptCloseHomeTaskModal(); } + }); + async function loadTaskExtras(task) { if (!subtaskBlock && !linksBlock) return; try { @@ -332,7 +375,7 @@ }); async function openHomeTaskModal(task) { - el("pj-task-modal-title").textContent = "업무 편집"; + el("pj-task-modal-title").textContent = "[" + (task.project_name || "") + "]의 업무 편집"; el("pj-t-id").value = task.id; el("pj-t-project-id").value = task.project_id; el("pj-t-title").value = task.title || ""; @@ -363,6 +406,7 @@ loadHomeAttachments(task.id); loadHomeComments(task.id); loadTaskExtras(task); + homeTaskSnapshot = homeTaskFormSnapshot(); } // 카드 이름 옆 "업무 추가" 버튼 — 같은 모달을 새 업무 작성용으로 초기화해 연다. @@ -387,6 +431,7 @@ if (linksBlock) el("pj-links-block").hidden = true; openModal(homeTaskModal); await Promise.all([fillAssignee(projectId, ""), fillStage(projectId, null)]); + homeTaskSnapshot = homeTaskFormSnapshot(); } function setHomeCompleteBtn(done) { const btn = el("pj-toggle-complete"); @@ -421,6 +466,7 @@ }); el("pj-save-task").addEventListener("click", async function () { + if (descEditor && descEditor.isUploading()) { alert("이미지 업로드가 끝나면 저장해주세요."); return; } const id = el("pj-t-id").value; const pid = el("pj-t-project-id").value; const assigneeSel = el("pj-t-assignee"); @@ -1581,12 +1627,57 @@ // ── 업무 모달 ── const taskModal = el("pj-modal-task"); - wireModalClose(taskModal); let currentTaskId = null; const descEditor = initDescEditor(); const subtaskBlock = initSubtaskBlock(); const linksBlock = initLinksBlock(); + // 닫을 때(배경 클릭·Esc·취소 버튼) 수정한 내용이 있으면 저장할지 물어본다. + // 하위업무/첨부/댓글/연결은 각자 즉시 저장돼서 여기서 추적할 필요 없다 — + // "저장" 버튼이 실제로 보내는 필드만 스냅샷으로 비교. + let taskSnapshot = null; + function taskFormSnapshot() { + return JSON.stringify({ + title: el("pj-t-title").value, + assignee: el("pj-t-assignee").value, + stage: el("pj-t-stage").value, + priority: el("pj-t-priority").value, + start: el("pj-t-start").value, + due: el("pj-t-due").value, + usetime: el("pj-t-usetime").checked, + stime: el("pj-t-stime").value, + dtime: el("pj-t-dtime").value, + desc: descEditor ? descEditor.getHtml() : "", + }); + } + function isTaskDirty() { + return taskSnapshot !== null && taskFormSnapshot() !== taskSnapshot; + } + let taskClosing = false; + async function attemptCloseTaskModal() { + // erpConfirm 도 Esc 로 닫히는데 stopPropagation 을 안 해서, 확인창이 + // 뜬 상태에서 Esc 를 누르면 이 함수가 다시 불릴 수 있다 — 재진입 방지. + if (taskModal.hidden || taskClosing) return; + taskClosing = true; + try { + if (isTaskDirty()) { + const save = await window.erpConfirm( + "수정한 내용이 있습니다. 저장하시겠습니까?", + { okText: "저장", cancelText: "저장 안 함" } + ); + if (save) { el("pj-save-task").click(); return; } + } + taskSnapshot = null; + closeModal(taskModal); + } finally { taskClosing = false; } + } + taskModal.addEventListener("click", function (e) { + if (e.target === taskModal || e.target.hasAttribute("data-close")) attemptCloseTaskModal(); + }); + document.addEventListener("keydown", function (e) { + if (e.key === "Escape" && !taskModal.hidden) { e.preventDefault(); attemptCloseTaskModal(); } + }); + async function loadTaskExtras(task) { if (!task || (!subtaskBlock && !linksBlock)) return; try { @@ -1632,9 +1723,8 @@ } function openTaskModal(task) { - el("pj-task-modal-title").textContent = task - ? "업무 편집" - : "[" + (currentProject.name || "") + "]의 새 업무"; + const projName = task ? (task.project_name || currentProject.name || "") : (currentProject.name || ""); + el("pj-task-modal-title").textContent = "[" + projName + "]의 " + (task ? "업무 편집" : "새 업무"); el("pj-t-id").value = task ? task.id : ""; el("pj-t-title").value = task ? task.title : ""; if (descEditor) { descEditor.setTaskId(task ? task.id : null); descEditor.setHtml(task ? (task.description || "") : ""); } @@ -1662,6 +1752,7 @@ if (linksBlock) el("pj-links-block").hidden = !task; if (task) { loadAttachments(task.id); loadComments(task.id); loadTaskExtras(task); } openModal(taskModal); + taskSnapshot = taskFormSnapshot(); } function setCompleteBtn(done) { @@ -1829,6 +1920,7 @@ }); el("pj-save-task").addEventListener("click", async function () { + if (descEditor && descEditor.isUploading()) { alert("이미지 업로드가 끝나면 저장해주세요."); return; } const id = el("pj-t-id").value; const assigneeSel = el("pj-t-assignee"); const assigneeName = assigneeSel.selectedOptions[0] ? (assigneeSel.selectedOptions[0].dataset.name || "") : ""; @@ -1852,6 +1944,7 @@ if (id) res = await api("PUT", "/project/api/tasks/" + id, payload); else res = await api("POST", "/project/api/projects/" + projectId + "/tasks", payload); mergeTask(res.task); + taskSnapshot = null; closeModal(taskModal); refreshActiveView(); } catch (e) { alert("저장 실패: " + e.message); } @@ -2159,8 +2252,28 @@ }); } + // 업로드 중엔 저장을 막는다(안 그러면 업로드가 끝나기 전에 저장을 눌러 + // 방금 넣은 이미지가 설명에서 통째로 빠진 채 저장되는 경우가 있었다). + let pendingUploads = 0; + function beginUpload() { + pendingUploads++; + if (hint) hint.textContent = "이미지 업로드 중…"; + } + function endUpload() { + pendingUploads = Math.max(0, pendingUploads - 1); + if (pendingUploads === 0 && hint) hint.textContent = taskId ? "" : "저장 후 이미지를 추가할 수 있습니다."; + } + async function insertImageFile(file) { if (!taskId) { alert("먼저 저장한 뒤 이미지를 추가할 수 있습니다."); return; } + // 업로드는 비동기라 그 사이 포커스가 파일선택창 등으로 빠져나가면서 + // contenteditable 의 선택범위가 사라지거나(브라우저가 엉뚱한 위치로 + // 되살리기도 한다) 커서 위치가 틀어진다 — 업로드 시작 "직전"의 커서를 + // 무조건 기억해뒀다가 삽입 시 그대로 복원한다(현재 라이브 셀렉션은 + // 신뢰하지 않는다). + saveSelection(); + const insertAt = lastRange ? lastRange.cloneRange() : null; + beginUpload(); const fd = new FormData(); fd.append("file", file); try { @@ -2174,8 +2287,13 @@ const boxW = box.clientWidth || 0; const widthAttr = (dims && dims.width && boxW && dims.width > boxW) ? ' width="' + Math.round(boxW * 0.9) + '"' : ""; + box.focus(); + if (insertAt) { + try { const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(insertAt); } catch (_) {} + } insertHtmlAtCursor(''); } catch (e) { alert("이미지 업로드 실패: " + e.message); } + finally { endUpload(); } } box.addEventListener("paste", function (e) { @@ -2203,6 +2321,7 @@ setTaskId: setTaskId, setHtml: function (html) { selectImg(null); lastRange = null; box.innerHTML = html || ""; }, getHtml: function () { return box.innerHTML; }, + isUploading: function () { return pendingUploads > 0; }, }; }