fix(project): 업무 모달 Esc 닫기+미저장 확인, 제목에 프로젝트명, 이미지 삽입 버그 수정
- Esc/배경 클릭/취소 버튼으로 업무 모달을 닫을 때 폼이 열었을 때와 달라져
있으면 erpConfirm 으로 "저장하시겠습니까?" 확인 후 저장 또는 그냥 닫기.
erpConfirm 자체도 Esc 로 닫히며 stopPropagation 을 안 해 재진입할 수
있어 플래그로 막음.
- 모달 제목을 "업무 편집"/"새 업무" 단독 표기에서 "[프로젝트명]의 업무
편집"/"[프로젝트명]의 새 업무"로 통일(편집 모드도 프로젝트명 표시).
- 이미지 삽입 버그 2건 수정:
1) 커서 위치 오삽입 — 업로드 버튼 클릭 후 파일선택창이 닫히고 포커스가
에디터로 돌아오면 브라우저가 선택범위를 엉뚱한 위치로 되살리는 경우가
있었는데, 기존 코드는 "선택범위가 에디터 밖에 있을 때만" 저장해둔
위치로 복원해서 이 경우를 못 잡았다 — 업로드 시작 직전 위치를 무조건
복원하도록 수정.
2) 저장 후 이미지가 사라짐 — 업로드가 끝나기 전에 저장 버튼을 누르면
설명에 이미지가 아직 안 들어간 채로 저장됐다. 업로드 중엔 저장을
막고 안내 문구를 보여줌.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -45,5 +45,5 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/project.js?v=20260917a" defer></script>
|
<script src="/static/project.js?v=20260917b" defer></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -392,5 +392,5 @@
|
|||||||
<script>
|
<script>
|
||||||
window.PJ_COLORS = {{ ["#4573d2","#37a3a3","#62a420","#e8a33d","#e8384f","#aa62e3","#f06a6a","#5a6772"] | tojson }};
|
window.PJ_COLORS = {{ ["#4573d2","#37a3a3","#62a420","#e8a33d","#e8384f","#aa62e3","#f06a6a","#5a6772"] | tojson }};
|
||||||
</script>
|
</script>
|
||||||
<script src="/static/project.js?v=20260917a" defer></script>
|
<script src="/static/project.js?v=20260917b" defer></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -405,5 +405,5 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script src="/static/vendor/vis-timeline/vis-timeline-graph2d.min.js"></script>
|
<script src="/static/vendor/vis-timeline/vis-timeline-graph2d.min.js"></script>
|
||||||
<script src="/static/project.js?v=20260917a" defer></script>
|
<script src="/static/project.js?v=20260917b" defer></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
+125
-6
@@ -208,11 +208,54 @@
|
|||||||
// ── 업무 편집 팝업 (카드/내 업무 클릭 시 페이지 이동 없이) ──
|
// ── 업무 편집 팝업 (카드/내 업무 클릭 시 페이지 이동 없이) ──
|
||||||
const homeTaskModal = el("pj-modal-task");
|
const homeTaskModal = el("pj-modal-task");
|
||||||
if (homeTaskModal) {
|
if (homeTaskModal) {
|
||||||
wireModalClose(homeTaskModal);
|
|
||||||
const descEditor = initDescEditor();
|
const descEditor = initDescEditor();
|
||||||
const subtaskBlock = initSubtaskBlock();
|
const subtaskBlock = initSubtaskBlock();
|
||||||
const linksBlock = initLinksBlock();
|
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) {
|
async function loadTaskExtras(task) {
|
||||||
if (!subtaskBlock && !linksBlock) return;
|
if (!subtaskBlock && !linksBlock) return;
|
||||||
try {
|
try {
|
||||||
@@ -332,7 +375,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function openHomeTaskModal(task) {
|
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-id").value = task.id;
|
||||||
el("pj-t-project-id").value = task.project_id;
|
el("pj-t-project-id").value = task.project_id;
|
||||||
el("pj-t-title").value = task.title || "";
|
el("pj-t-title").value = task.title || "";
|
||||||
@@ -363,6 +406,7 @@
|
|||||||
loadHomeAttachments(task.id);
|
loadHomeAttachments(task.id);
|
||||||
loadHomeComments(task.id);
|
loadHomeComments(task.id);
|
||||||
loadTaskExtras(task);
|
loadTaskExtras(task);
|
||||||
|
homeTaskSnapshot = homeTaskFormSnapshot();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 카드 이름 옆 "업무 추가" 버튼 — 같은 모달을 새 업무 작성용으로 초기화해 연다.
|
// 카드 이름 옆 "업무 추가" 버튼 — 같은 모달을 새 업무 작성용으로 초기화해 연다.
|
||||||
@@ -387,6 +431,7 @@
|
|||||||
if (linksBlock) el("pj-links-block").hidden = true;
|
if (linksBlock) el("pj-links-block").hidden = true;
|
||||||
openModal(homeTaskModal);
|
openModal(homeTaskModal);
|
||||||
await Promise.all([fillAssignee(projectId, ""), fillStage(projectId, null)]);
|
await Promise.all([fillAssignee(projectId, ""), fillStage(projectId, null)]);
|
||||||
|
homeTaskSnapshot = homeTaskFormSnapshot();
|
||||||
}
|
}
|
||||||
function setHomeCompleteBtn(done) {
|
function setHomeCompleteBtn(done) {
|
||||||
const btn = el("pj-toggle-complete");
|
const btn = el("pj-toggle-complete");
|
||||||
@@ -421,6 +466,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
el("pj-save-task").addEventListener("click", async function () {
|
el("pj-save-task").addEventListener("click", async function () {
|
||||||
|
if (descEditor && descEditor.isUploading()) { alert("이미지 업로드가 끝나면 저장해주세요."); return; }
|
||||||
const id = el("pj-t-id").value;
|
const id = el("pj-t-id").value;
|
||||||
const pid = el("pj-t-project-id").value;
|
const pid = el("pj-t-project-id").value;
|
||||||
const assigneeSel = el("pj-t-assignee");
|
const assigneeSel = el("pj-t-assignee");
|
||||||
@@ -1581,12 +1627,57 @@
|
|||||||
|
|
||||||
// ── 업무 모달 ──
|
// ── 업무 모달 ──
|
||||||
const taskModal = el("pj-modal-task");
|
const taskModal = el("pj-modal-task");
|
||||||
wireModalClose(taskModal);
|
|
||||||
let currentTaskId = null;
|
let currentTaskId = null;
|
||||||
const descEditor = initDescEditor();
|
const descEditor = initDescEditor();
|
||||||
const subtaskBlock = initSubtaskBlock();
|
const subtaskBlock = initSubtaskBlock();
|
||||||
const linksBlock = initLinksBlock();
|
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) {
|
async function loadTaskExtras(task) {
|
||||||
if (!task || (!subtaskBlock && !linksBlock)) return;
|
if (!task || (!subtaskBlock && !linksBlock)) return;
|
||||||
try {
|
try {
|
||||||
@@ -1632,9 +1723,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openTaskModal(task) {
|
function openTaskModal(task) {
|
||||||
el("pj-task-modal-title").textContent = task
|
const projName = task ? (task.project_name || currentProject.name || "") : (currentProject.name || "");
|
||||||
? "업무 편집"
|
el("pj-task-modal-title").textContent = "[" + projName + "]의 " + (task ? "업무 편집" : "새 업무");
|
||||||
: "[" + (currentProject.name || "") + "]의 새 업무";
|
|
||||||
el("pj-t-id").value = task ? task.id : "";
|
el("pj-t-id").value = task ? task.id : "";
|
||||||
el("pj-t-title").value = task ? task.title : "";
|
el("pj-t-title").value = task ? task.title : "";
|
||||||
if (descEditor) { descEditor.setTaskId(task ? task.id : null); descEditor.setHtml(task ? (task.description || "") : ""); }
|
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 (linksBlock) el("pj-links-block").hidden = !task;
|
||||||
if (task) { loadAttachments(task.id); loadComments(task.id); loadTaskExtras(task); }
|
if (task) { loadAttachments(task.id); loadComments(task.id); loadTaskExtras(task); }
|
||||||
openModal(taskModal);
|
openModal(taskModal);
|
||||||
|
taskSnapshot = taskFormSnapshot();
|
||||||
}
|
}
|
||||||
|
|
||||||
function setCompleteBtn(done) {
|
function setCompleteBtn(done) {
|
||||||
@@ -1829,6 +1920,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
el("pj-save-task").addEventListener("click", async function () {
|
el("pj-save-task").addEventListener("click", async function () {
|
||||||
|
if (descEditor && descEditor.isUploading()) { alert("이미지 업로드가 끝나면 저장해주세요."); return; }
|
||||||
const id = el("pj-t-id").value;
|
const id = el("pj-t-id").value;
|
||||||
const assigneeSel = el("pj-t-assignee");
|
const assigneeSel = el("pj-t-assignee");
|
||||||
const assigneeName = assigneeSel.selectedOptions[0] ? (assigneeSel.selectedOptions[0].dataset.name || "") : "";
|
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);
|
if (id) res = await api("PUT", "/project/api/tasks/" + id, payload);
|
||||||
else res = await api("POST", "/project/api/projects/" + projectId + "/tasks", payload);
|
else res = await api("POST", "/project/api/projects/" + projectId + "/tasks", payload);
|
||||||
mergeTask(res.task);
|
mergeTask(res.task);
|
||||||
|
taskSnapshot = null;
|
||||||
closeModal(taskModal);
|
closeModal(taskModal);
|
||||||
refreshActiveView();
|
refreshActiveView();
|
||||||
} catch (e) { alert("저장 실패: " + e.message); }
|
} 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) {
|
async function insertImageFile(file) {
|
||||||
if (!taskId) { alert("먼저 저장한 뒤 이미지를 추가할 수 있습니다."); return; }
|
if (!taskId) { alert("먼저 저장한 뒤 이미지를 추가할 수 있습니다."); return; }
|
||||||
|
// 업로드는 비동기라 그 사이 포커스가 파일선택창 등으로 빠져나가면서
|
||||||
|
// contenteditable 의 선택범위가 사라지거나(브라우저가 엉뚱한 위치로
|
||||||
|
// 되살리기도 한다) 커서 위치가 틀어진다 — 업로드 시작 "직전"의 커서를
|
||||||
|
// 무조건 기억해뒀다가 삽입 시 그대로 복원한다(현재 라이브 셀렉션은
|
||||||
|
// 신뢰하지 않는다).
|
||||||
|
saveSelection();
|
||||||
|
const insertAt = lastRange ? lastRange.cloneRange() : null;
|
||||||
|
beginUpload();
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append("file", file);
|
fd.append("file", file);
|
||||||
try {
|
try {
|
||||||
@@ -2174,8 +2287,13 @@
|
|||||||
const boxW = box.clientWidth || 0;
|
const boxW = box.clientWidth || 0;
|
||||||
const widthAttr = (dims && dims.width && boxW && dims.width > boxW)
|
const widthAttr = (dims && dims.width && boxW && dims.width > boxW)
|
||||||
? ' width="' + Math.round(boxW * 0.9) + '"' : "";
|
? ' width="' + Math.round(boxW * 0.9) + '"' : "";
|
||||||
|
box.focus();
|
||||||
|
if (insertAt) {
|
||||||
|
try { const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(insertAt); } catch (_) {}
|
||||||
|
}
|
||||||
insertHtmlAtCursor('<img src="/project/api/attachments/' + data.attachment.id + '/inline" alt=""' + widthAttr + '>');
|
insertHtmlAtCursor('<img src="/project/api/attachments/' + data.attachment.id + '/inline" alt=""' + widthAttr + '>');
|
||||||
} catch (e) { alert("이미지 업로드 실패: " + e.message); }
|
} catch (e) { alert("이미지 업로드 실패: " + e.message); }
|
||||||
|
finally { endUpload(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
box.addEventListener("paste", function (e) {
|
box.addEventListener("paste", function (e) {
|
||||||
@@ -2203,6 +2321,7 @@
|
|||||||
setTaskId: setTaskId,
|
setTaskId: setTaskId,
|
||||||
setHtml: function (html) { selectImg(null); lastRange = null; box.innerHTML = html || ""; },
|
setHtml: function (html) { selectImg(null); lastRange = null; box.innerHTML = html || ""; },
|
||||||
getHtml: function () { return box.innerHTML; },
|
getHtml: function () { return box.innerHTML; },
|
||||||
|
isUploading: function () { return pendingUploads > 0; },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user