feat(project): 세션 색상/편집, 프로젝트 트리 드래그, 멤버업무 정렬, 댓글팝업 버그수정

- 좌측 프로젝트 리스트 드래그 재정렬(관리자, 홈 카드와 동일 API)
- 세션 추가/편집 팝업: 이름 + 아사나 10색 색상표(project_stages.color, 마이그레이션 004)
- 보드 세션 hover 아이콘 확대, 세션 제목 강조(업무 카드보다 크고 굵게)
- 완료 세션 반투명 + 보드 끝으로(정렬 기준만 변경 — 해제 시 원래 순서 자동 복귀)
- 전체 멤버 업무 팝업에 표 헤더(가운데정렬/굵게) + 정렬 아이콘 + 더블클릭 편집
- 댓글 채팅 팝업을 initCommentsChat() 공용화 — project.html에 모달이 없어 "내 업무"
  패널 댓글 더블클릭이 전혀 동작 안 했던 버그 수정(동적 배지 재바인딩 누락도 같이 수정)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-17 11:55:49 +09:00
parent 2f611767ca
commit 003d29d349
10 changed files with 438 additions and 174 deletions
+307 -157
View File
@@ -161,8 +161,10 @@
}
// ── 색상 팔레트 렌더 ───────────────────────────────────────
function renderColorPalette(container, onPick, initial) {
const colors = window.PJ_COLORS || ["#4573d2"];
// palette 를 생략하면 프로젝트 색(window.PJ_COLORS) — 세션 색상 선택은
// window.PJ_STAGE_COLORS(아사나 10색)를 넘겨 같은 UI를 재사용한다.
function renderColorPalette(container, onPick, initial, palette) {
const colors = palette || window.PJ_COLORS || ["#4573d2"];
let selected = (initial && colors.indexOf(initial) >= 0) ? initial : colors[0];
container.innerHTML = "";
colors.forEach(function (c) {
@@ -184,6 +186,131 @@
return function () { return selected; };
}
// ════════════════════════════════════════════════════════════
// 댓글만 보는 채팅형 팝업(#pj-modal-comments) — 홈 카드/내 업무, 프로젝트
// 화면 우측 "내 업무" 패널 전부 공용(마크업 id 는 두 페이지가 같은 걸 씀).
// 페이지당 한 번 initCommentsChat() 으로 활성화하고, 이후 동적으로 새로
// 만들어지는 말풍선 배지는 반환된 wireBadge() 로 개별 등록한다(안 하면
// patch 로 새로 그려진 배지는 더블클릭이 죽는다 — 실제 있었던 버그).
// getTaskTitle(taskId) — 채팅창 제목에 쓸 업무 제목을 페이지별 데이터에서
// 찾아 돌려주는 콜백(홈은 homeTasks, 프로젝트 화면은 myTasksById/tasks).
let cmtChat = null;
function initCommentsChat(getTaskTitle) {
const modal = el("pj-modal-comments");
if (!modal) return null;
if (cmtChat) return cmtChat;
wireModalClose(modal);
function loadSeenComments() {
try { return JSON.parse(localStorage.getItem("pj_seen_comments") || "{}"); }
catch (_) { return {}; }
}
function markSeen(taskId, count) {
const m = loadSeenComments();
m[taskId] = count;
try { localStorage.setItem("pj_seen_comments", JSON.stringify(m)); } catch (_) {}
}
function currentUserEmail() {
const wrap = document.querySelector(".pj-wrap");
return ((wrap && wrap.dataset.me) || "").toLowerCase();
}
async function renderChat(taskId) {
const me = currentUserEmail();
const list = el("pj-chat-list");
list.innerHTML = "<li class='pj-empty-sm'>불러오는 중…</li>";
try {
const res = await api("GET", "/project/api/tasks/" + taskId + "/comments");
const comments = res.comments || [];
if (!comments.length) {
list.innerHTML = "<li class='pj-empty-sm'>댓글 없음</li>";
} else {
list.innerHTML = "";
comments.forEach(function (c) {
const mine = (c.author_email || "").toLowerCase() === me;
const li = document.createElement("li");
li.className = "pj-chat-msg" + (mine ? " mine" : "");
li.innerHTML =
(mine ? "" : '<span class="pj-chat-name">' + escapeHtml(idOf(c.author_email)) + "</span>") +
'<div class="pj-chat-bubble"></div>' +
'<span class="pj-chat-meta">' +
(mine ? '<button type="button" class="pj-icon-btn pj-chat-del" title="삭제"><span class="material-symbols-outlined">close</span></button>' : "") +
'<span class="pj-chat-time">' + (c.created_at || "").slice(0, 16).replace("T", " ") + "</span>" +
"</span>";
li.querySelector(".pj-chat-bubble").textContent = c.body;
if (mine) {
li.querySelector(".pj-chat-del").addEventListener("click", async function () {
if (!confirm("이 댓글을 삭제할까요?")) return;
try {
await api("DELETE", "/project/api/comments/" + c.id);
const n = await renderChat(taskId);
if (n !== null) { markSeen(taskId, n); syncBadge(taskId, n); }
} catch (e) { alert("삭제 실패: " + e.message); }
});
}
list.appendChild(li);
});
list.scrollTop = list.scrollHeight;
}
return comments.length;
} catch (e) {
list.innerHTML = "<li class='pj-empty-sm'>불러오기 실패</li>";
return null;
}
}
function syncBadge(taskId, n) {
const badge = document.querySelector('.pj-cmt-badge[data-task-id="' + taskId + '"]');
if (!badge) return;
badge.dataset.count = n;
badge.title = "댓글 " + n + "개";
const numEl = badge.querySelector(".pj-cmt-n");
if (numEl) numEl.textContent = n;
badge.classList.remove("is-unread");
}
async function open(taskId) {
el("pj-cm-task-id").value = taskId;
el("pj-cm-title").textContent = (getTaskTitle && getTaskTitle(taskId)) || "댓글";
openModal(modal);
const n = await renderChat(taskId);
if (n !== null) { markSeen(taskId, n); syncBadge(taskId, n); }
}
function wireBadge(b) {
if (!b || b.dataset.pjCmtWired) return;
b.dataset.pjCmtWired = "1";
const n = parseInt(b.dataset.count, 10) || 0;
const seenN = loadSeenComments()[b.dataset.taskId] || 0;
if (n > seenN) b.classList.add("is-unread");
b.addEventListener("click", function (e) { e.preventDefault(); e.stopPropagation(); });
b.addEventListener("dblclick", function (e) {
e.preventDefault(); e.stopPropagation();
open(b.dataset.taskId);
});
}
document.querySelectorAll(".pj-cmt-badge").forEach(wireBadge);
el("pj-cm-send").addEventListener("click", async function () {
const taskId = el("pj-cm-task-id").value;
const inp = el("pj-cm-input");
const body = inp.value.trim();
if (!taskId || !body) return;
try {
await api("POST", "/project/api/tasks/" + taskId + "/comments", { body: body });
inp.value = "";
const n = await renderChat(taskId);
if (n !== null) { markSeen(taskId, n); syncBadge(taskId, n); }
} catch (e) { alert("등록 실패: " + e.message); }
});
el("pj-cm-input").addEventListener("keydown", function (e) {
if (e.key === "Enter") { e.preventDefault(); el("pj-cm-send").click(); }
});
cmtChat = { open: open, wireBadge: wireBadge };
return cmtChat;
}
// ════════════════════════════════════════════════════════════
// 홈 페이지 — 새 프로젝트
// ════════════════════════════════════════════════════════════
@@ -204,7 +331,6 @@
// 부를 수 있도록 참조만 미리 선언해둔다(정의되는 즉시 아래서 채워짐).
let openNewTaskModalRef = null;
let openHomeTaskModalRef = null;
let openCommentsModalRef = null;
// ── 프로젝트 카드 수정/삭제 (카드 ✎ 버튼·우클릭 메뉴 공용) ──
const editModal = el("pj-modal-edit");
@@ -466,14 +592,7 @@
wrap.innerHTML = homeTaskRowHtml(t);
const newLi = wrap.firstElementChild;
wireHomeTaskPopupLink(newLi.querySelector(".pj-task-popup-link"), t);
const badge = newLi.querySelector(".pj-cmt-badge");
if (badge) {
badge.addEventListener("click", function (e) { e.preventDefault(); e.stopPropagation(); });
badge.addEventListener("dblclick", function (e) {
e.preventDefault(); e.stopPropagation();
if (openCommentsModalRef) openCommentsModalRef(t.id);
});
}
if (cmt) cmt.wireBadge(newLi.querySelector(".pj-cmt-badge"));
// 서버가 처음 그린 <li>(macro)는 data-task-id 를 <li> 자체가 아니라
// 안쪽 .pj-task-popup-link 에만 붙인다 — 그 기준으로 찾아야 최초
// 페이지 로드 때부터 있던 행(아직 한 번도 patch 안 된 행)도 잡힌다.
@@ -530,6 +649,7 @@
wrap.innerHTML = html;
const newLi = wrap.firstElementChild;
wireHomeTaskPopupLink(newLi.querySelector(".pj-task-popup-link"), t);
if (cmt) cmt.wireBadge(newLi.querySelector(".pj-cmt-badge"));
if (existingLi) existingLi.replaceWith(newLi); else group.appendChild(newLi);
}
@@ -965,115 +1085,13 @@
document.querySelectorAll(".pj-task-popup-link").forEach(function (a) { wireHomeTaskPopupLink(a); });
}
// ── 댓글 말풍선(카드/내 업무) — 더블클릭 시 댓글만 보는 채팅형 팝업 ──
const commentsModal = el("pj-modal-comments");
if (commentsModal) {
wireModalClose(commentsModal);
function loadSeenComments() {
try { return JSON.parse(localStorage.getItem("pj_seen_comments") || "{}"); }
catch (_) { return {}; }
}
function markSeen(taskId, count) {
const m = loadSeenComments();
m[taskId] = count;
try { localStorage.setItem("pj_seen_comments", JSON.stringify(m)); } catch (_) {}
}
// 페이지 로드 시 — 마지막으로 확인한 개수보다 늘었으면 반짝임(is-unread) 표시.
const seen = loadSeenComments();
document.querySelectorAll(".pj-cmt-badge").forEach(function (b) {
const n = parseInt(b.dataset.count, 10) || 0;
const seenN = seen[b.dataset.taskId] || 0;
if (n > seenN) b.classList.add("is-unread");
});
async function renderChat(taskId) {
const list = el("pj-chat-list");
list.innerHTML = "<li class='pj-empty-sm'>불러오는 중…</li>";
try {
const res = await api("GET", "/project/api/tasks/" + taskId + "/comments");
const comments = res.comments || [];
if (!comments.length) {
list.innerHTML = "<li class='pj-empty-sm'>댓글 없음</li>";
} else {
list.innerHTML = "";
comments.forEach(function (c) {
const mine = (c.author_email || "").toLowerCase() === me;
const li = document.createElement("li");
li.className = "pj-chat-msg" + (mine ? " mine" : "");
li.innerHTML =
(mine ? "" : '<span class="pj-chat-name">' + escapeHtml(idOf(c.author_email)) + "</span>") +
'<div class="pj-chat-bubble"></div>' +
'<span class="pj-chat-meta">' +
(mine ? '<button type="button" class="pj-icon-btn pj-chat-del" title="삭제"><span class="material-symbols-outlined">close</span></button>' : "") +
'<span class="pj-chat-time">' + (c.created_at || "").slice(0, 16).replace("T", " ") + "</span>" +
"</span>";
li.querySelector(".pj-chat-bubble").textContent = c.body;
if (mine) {
li.querySelector(".pj-chat-del").addEventListener("click", async function () {
if (!confirm("이 댓글을 삭제할까요?")) return;
try {
await api("DELETE", "/project/api/comments/" + c.id);
const n = await renderChat(taskId);
if (n !== null) { markSeen(taskId, n); syncBadge(taskId, n); }
} catch (e) { alert("삭제 실패: " + e.message); }
});
}
list.appendChild(li);
});
list.scrollTop = list.scrollHeight;
}
return comments.length;
} catch (e) {
list.innerHTML = "<li class='pj-empty-sm'>불러오기 실패</li>";
return null;
}
}
function syncBadge(taskId, n) {
const badge = document.querySelector('.pj-cmt-badge[data-task-id="' + taskId + '"]');
if (!badge) return;
badge.dataset.count = n;
badge.title = "댓글 " + n + "개";
const numEl = badge.querySelector(".pj-cmt-n");
if (numEl) numEl.textContent = n;
badge.classList.remove("is-unread");
}
async function openCommentsModal(taskId) {
el("pj-cm-task-id").value = taskId;
const task = homeTasks[taskId];
el("pj-cm-title").textContent = (task && task.title) || "댓글";
openModal(commentsModal);
const n = await renderChat(taskId);
if (n !== null) { markSeen(taskId, n); syncBadge(taskId, n); }
}
openCommentsModalRef = openCommentsModal;
document.querySelectorAll(".pj-cmt-badge").forEach(function (b) {
b.addEventListener("click", function (e) { e.preventDefault(); e.stopPropagation(); });
b.addEventListener("dblclick", function (e) {
e.preventDefault(); e.stopPropagation();
openCommentsModal(b.dataset.taskId);
});
});
el("pj-cm-send").addEventListener("click", async function () {
const taskId = el("pj-cm-task-id").value;
const inp = el("pj-cm-input");
const body = inp.value.trim();
if (!taskId || !body) return;
try {
await api("POST", "/project/api/tasks/" + taskId + "/comments", { body: body });
inp.value = "";
const n = await renderChat(taskId);
if (n !== null) { markSeen(taskId, n); syncBadge(taskId, n); }
} catch (e) { alert("등록 실패: " + e.message); }
});
el("pj-cm-input").addEventListener("keydown", function (e) {
if (e.key === "Enter") { e.preventDefault(); el("pj-cm-send").click(); }
});
}
// ── 댓글 말풍선(카드/내 업무) — 더블클릭 시 댓글만 보는 채팅형 팝업.
// 홈/프로젝트 화면 공용 구현은 initCommentsChat() 하나(위쪽 정의) — 여기서는
// 제목 조회 콜백만 홈 데이터(homeTasks)로 넘긴다.
const cmt = initCommentsChat(function (taskId) {
const t = homeTasks[taskId];
return t && t.title;
});
// 최초 로드된 카드 + 이후 새로 만든 카드 모두 여기서 한 번에 배선.
document.querySelectorAll(".pj-project-card").forEach(wireProjectCard);
@@ -1187,6 +1205,13 @@
const avatars = data.avatars || {};
const myTasksById = {};
(data.myTasks || []).forEach(function (t) { myTasksById[t.id] = t; });
// 댓글 말풍선 더블클릭 팝업 — 홈 화면과 동일 구현(initCommentsChat, 위쪽
// 정의)을 공용으로 쓴다. project.html 에 모달 마크업이 없었던 게 원인이라,
// 이 화면 "내 업무" 패널의 말풍선이 그동안 안 눌렸다(수정된 버그).
const cmt = initCommentsChat(function (taskId) {
const t = myTasksById[taskId];
return t && t.title;
});
// 우측 "내 업무" 패널 — openTaskModal/mergeTask/refreshActiveView 는 이
// 함수 뒤쪽에서 선언되지만 function 선언은 호이스팅되어 여기서 참조해도
// 문제없다. 이 패널의 업무는 전부 "내가 담당자"인 게 보장돼 있어(서버가
@@ -1220,6 +1245,7 @@
wrap.innerHTML = myTaskRowHtml(t);
const newLi = wrap.firstElementChild;
wireMyTaskPopupLink(newLi.querySelector(".pj-task-popup-link"));
if (cmt) cmt.wireBadge(newLi.querySelector(".pj-cmt-badge"));
if (existingLi) existingLi.replaceWith(newLi); else group.appendChild(newLi);
}
// 내 업무 항목 우클릭 메뉴 — 열기(프로젝트 페이지로 이동)/업무 추가/수정
@@ -1832,17 +1858,42 @@
// "+ 세션 추가" 버튼은 보드(스크롤 영역) 밖 고정 위치에 있어 세션이
// 늘어나 스크롤이 생겨도 항상 보인다 — 한 번만 wiring, 표시 여부만 매
// 렌더마다 토글.
// ── 세션 추가/편집 팝업 — 이름 + 색상(아사나 10색). "+ 세션 추가" 버튼과
// 세션 헤더 ✎(세션 편집) 아이콘·컨텍스트 메뉴가 전부 이 모달 하나를 쓴다.
const stageModal = el("pj-modal-stage");
let getStageColor = null;
if (stageModal) wireModalClose(stageModal);
function openStageModal(stage) {
if (!stageModal) return;
el("pj-stage-modal-title").textContent = stage ? "세션 편집" : "새 세션";
el("pj-stg-id").value = stage ? stage.id : "";
el("pj-stg-name").value = stage ? stage.name : "";
getStageColor = renderColorPalette(
el("pj-stg-colors"), null, stage ? stage.color : null, window.PJ_STAGE_COLORS
);
openModal(stageModal);
}
const saveStageBtn = el("pj-save-stage");
if (saveStageBtn) saveStageBtn.addEventListener("click", async function () {
const id = el("pj-stg-id").value;
const name = el("pj-stg-name").value.trim();
if (!name) { alert("세션 이름을 입력하세요."); return; }
const color = getStageColor ? getStageColor() : null;
try {
if (id) {
await api("PUT", "/project/api/projects/" + projectId + "/stages/" + id, { name: name, color: color });
} else {
await api("POST", "/project/api/projects/" + projectId + "/stages", { name: name, color: color });
}
await reloadStagesForCurrentProject();
closeModal(stageModal);
renderBoard();
} catch (e) { showToast("저장 실패: " + e.message, "error"); }
});
const boardAddStageBtn = el("pj-board-addstage");
if (boardAddStageBtn) {
boardAddStageBtn.addEventListener("click", async function () {
const name = prompt("새 세션 이름");
if (!name || !name.trim()) return;
try {
await api("POST", "/project/api/projects/" + projectId + "/stages", { name: name.trim() });
await reloadStagesForCurrentProject();
renderBoard();
} catch (e) { alert("추가 실패: " + e.message); }
});
boardAddStageBtn.addEventListener("click", function () { openStageModal(null); });
}
function renderBoard() {
@@ -1940,8 +1991,14 @@
// ── 보드(프로젝트 스코프) — 세션은 이 프로젝트의 실제 행(id)이라 관리 UI를
// 붙일 수 있다: 이름변경·완료토글·삭제(만든 사람만)·추가·드래그 재정렬.
function renderBoardByStage(board, vis) {
// 완료 세션은 끝으로 보낸다 — sort_order 자체는 안 건드리고 정렬
// 기준에서만 뒤로 미루므로, 완료 체크를 해제하면 sort_order 가 그대로라
// 원래 있던 자리로 자동 복귀한다(별도 "이전 위치" 저장 불필요).
const list = (stagesByProject[projectId] || []).slice()
.sort(function (a, b) { return a.sort_order - b.sort_order; });
.sort(function (a, b) {
if (!!a.is_done_stage !== !!b.is_done_stage) return a.is_done_stage ? 1 : -1;
return a.sort_order - b.sort_order;
});
// 세션은 프로젝트를 만들 때 자동으로 생기지 않는다(아사나처럼 직접 추가) —
// 아직 하나도 없으면 안내만 보여준다.
if (!list.length && !vis.length) {
@@ -1953,16 +2010,17 @@
}
list.forEach(function (s) {
const col = document.createElement("div");
col.className = "pj-col";
col.className = "pj-col" + (s.is_done_stage ? " pj-col-done" : "");
col.dataset.stageId = s.id;
col.innerHTML =
'<div class="pj-col-head"' + (canManage ? ' draggable="true"' : "") + '>' +
(s.color ? '<span class="pj-col-dot" style="background:' + s.color + '"></span>' : "") +
'<span class="pj-col-name">' + escapeHtml(s.name) + "</span>" +
(s.is_done_stage ? '<span class="pj-chip pj-chip-sm">완료</span>' : "") +
'<span class="pj-col-count"></span>' +
(canManage
? '<span class="pj-col-ctrls">' +
'<button type="button" class="pj-icon-btn pj-col-rename" title="이름 변경">✎</button>' +
'<button type="button" class="pj-icon-btn pj-col-rename" title="세션 편집">✎</button>' +
'<button type="button" class="pj-icon-btn pj-col-toggledone" title="완료 세션 지정/해제">' +
(s.is_done_stage ? "☑" : "☐") + "</button>" +
'<button type="button" class="pj-icon-btn pj-col-del" title="세션 삭제">×</button>' +
@@ -2006,15 +2064,7 @@
}
function wireColumnControls(col, s) {
async function renameStage() {
const name = prompt("세션 이름", s.name);
if (!name || !name.trim() || name.trim() === s.name) return;
try {
await api("PUT", "/project/api/projects/" + projectId + "/stages/" + s.id, { name: name.trim() });
await reloadStagesForCurrentProject();
renderBoard();
} catch (err) { showToast("이름 변경 실패: " + err.message, "error"); }
}
function editStage() { openStageModal(s); }
async function toggleDoneStage() {
try {
await api("PUT", "/project/api/projects/" + projectId + "/stages/" + s.id,
@@ -2033,7 +2083,7 @@
} catch (err) { showToast("삭제 실패: " + err.message, "error"); }
}
const renameBtn = col.querySelector(".pj-col-rename");
if (renameBtn) renameBtn.addEventListener("click", function (e) { e.stopPropagation(); renameStage(); });
if (renameBtn) renameBtn.addEventListener("click", function (e) { e.stopPropagation(); editStage(); });
const doneBtn = col.querySelector(".pj-col-toggledone");
if (doneBtn) doneBtn.addEventListener("click", function (e) { e.stopPropagation(); toggleDoneStage(); });
const delBtn = col.querySelector(".pj-col-del");
@@ -2043,7 +2093,7 @@
e.preventDefault();
showContextMenu(e.clientX, e.clientY, [
{ label: "업무 추가", onClick: function () { openTaskModal(null, s.id); } },
{ label: "이름 변경", onClick: renameStage },
{ label: "세션 편집", onClick: editStage },
{ label: s.is_done_stage ? "완료 세션 해제" : "완료 세션으로 지정", onClick: toggleDoneStage },
null,
{ label: "삭제", danger: true, onClick: deleteStage },
@@ -2987,7 +3037,7 @@
"</span>";
}
const initial = escapeHtml((p.name || "?").trim().charAt(0).toUpperCase() || "?");
return '<li class="pj-tree-li" data-id="' + p.id + '"><div class="pj-tree-row ' +
return '<li class="pj-tree-li"' + (isAdmin ? ' draggable="true"' : "") + ' data-id="' + p.id + '"><div class="pj-tree-row ' +
(p.id === projectId ? "is-active" : "") + '">' +
'<a class="pj-tree-link" href="/project/p/' + p.id + '">' +
'<span class="pj-tree-avatar" style="background:' + (p.color || "#4573d2") + '">' + initial + "</span>" +
@@ -3004,6 +3054,9 @@
} catch (err) { showToast("삭제 실패: " + err.message, "error"); }
}
function wireTreeHandlers(rootUl) {
// <a> 는 기본 draggable="true" — 그대로 두면 li 대신 링크(URL) 드래그가
// 시작돼 아래 wireTreeDrag 의 dragstart 핸들러가 무력화된다.
if (isAdmin) rootUl.querySelectorAll(".pj-tree-link").forEach(function (a) { a.draggable = false; });
rootUl.querySelectorAll(".pj-tree-del").forEach(function (b) {
b.addEventListener("click", function (e) {
e.preventDefault(); e.stopPropagation();
@@ -3041,6 +3094,47 @@
rootUl.innerHTML = projectsMeta.map(treeProjLi).join("");
wireTreeHandlers(rootUl);
}
// 좌측 프로젝트 목록 드래그 순서 변경 — 홈 화면 카드 정렬과 같은 API/원칙
// (관리자 전용, 최상위 프로젝트만). rootUl 자체는 renderTree() 가 innerHTML
// 만 바꾸고 element 를 갈아치우지 않으므로 리스너는 한 번만 붙이면 된다.
function wireTreeDrag(rootUl) {
if (!isAdmin || !rootUl) return;
let dragLi = null;
rootUl.addEventListener("dragstart", function (e) {
const li = e.target.closest(".pj-tree-li");
if (!li) return;
dragLi = li;
e.dataTransfer.setData("application/x-pj-tree", String(li.dataset.id));
e.dataTransfer.effectAllowed = "move";
li.classList.add("is-dragging");
});
rootUl.addEventListener("dragend", function () {
if (dragLi) dragLi.classList.remove("is-dragging");
dragLi = null;
});
rootUl.addEventListener("dragover", function (e) {
if (!dragLi) return;
const li = e.target.closest(".pj-tree-li");
if (!li || li === dragLi) { e.preventDefault(); return; }
e.preventDefault();
const r = li.getBoundingClientRect();
const before = (e.clientY - r.top) < r.height / 2;
rootUl.insertBefore(dragLi, before ? li : li.nextSibling);
});
rootUl.addEventListener("drop", async function (e) {
if (!dragLi) return;
e.preventDefault();
const ids = Array.prototype.slice.call(rootUl.querySelectorAll(".pj-tree-li"))
.map(function (li) { return parseInt(li.dataset.id, 10); });
try {
await api("PUT", "/project/api/projects/order", { ordered_ids: ids });
projectsMeta.sort(function (a, b) { return ids.indexOf(a.id) - ids.indexOf(b.id); });
} catch (err) {
showToast("순서 변경 실패: " + err.message, "error");
renderTree();
}
});
}
function mergeProject(p) {
const i = projectsMeta.findIndex(function (x) { return String(x.id) === String(p.id); });
if (i >= 0) projectsMeta[i] = Object.assign({}, projectsMeta[i], p);
@@ -3048,11 +3142,15 @@
renderTree();
if (String(p.id) === String(projectId)) {
Object.assign(currentProject, p);
const titleEl = document.querySelector(".pj-project-title");
if (titleEl) titleEl.textContent = currentProject.name || "";
const pillEl = document.querySelector(".pj-project-title-pill");
if (pillEl) {
pillEl.textContent = currentProject.name || "";
pillEl.style.setProperty("--pj-color", currentProject.color || "#4573d2");
}
}
}
renderTree();
wireTreeDrag(el("pj-tree-root"));
// 새 최상위 프로젝트 (관리자)
const newProjSide = el("pj-new-project-side");
@@ -3203,25 +3301,77 @@
}
const memberTasksModal = el("pj-modal-member-tasks");
if (memberTasksModal) wireModalClose(memberTasksModal);
let mtItems = [];
let mtSort = { key: null, dir: 1 };
function mtSortValue(t, key) {
if (key === "project") return (t.project_name || "").toLowerCase();
if (key === "stage") return (t.stage_name || "").toLowerCase();
if (key === "title") return (t.title || "").toLowerCase();
if (key === "due") return t.due_date || "9999-99-99";
return "";
}
function mtRowHtml(t) {
return '<li class="pj-mt-row' + (t.completed_at ? " is-done" : "") + '" data-task-id="' + t.id + '">' +
'<span class="pj-mt-proj" style="color:' + (t.project_color || "#4573d2") + '">' + escapeHtml(t.project_name || "-") + "</span>" +
'<span class="pj-mt-stage">' + escapeHtml(t.stage_name || "미지정") + "</span>" +
'<span class="pj-mt-title2">' + escapeHtml(t.title || "") + "</span>" +
'<span class="pj-mt-due">' + (t.due_date ? fmtDateKr(t.due_date) : "-") + "</span>" +
"</li>";
}
const mtHead = el("pj-mt-head");
function updateMtSortIcons() {
if (!mtHead) return;
mtHead.querySelectorAll(".pj-mt-h").forEach(function (h) {
const icon = h.querySelector(".pj-mt-sort-icon");
if (!icon) return;
icon.textContent = h.dataset.sort === mtSort.key ? (mtSort.dir === 1 ? "▲" : "▼") : "";
});
}
function renderMtList() {
const list = el("pj-mt-list");
if (!mtItems.length) { list.innerHTML = "<li class='pj-empty-sm'>배정된 업무가 없습니다.</li>"; return; }
const sorted = mtItems.slice();
if (mtSort.key) {
sorted.sort(function (a, b) {
const va = mtSortValue(a, mtSort.key), vb = mtSortValue(b, mtSort.key);
if (va < vb) return -1 * mtSort.dir;
if (va > vb) return 1 * mtSort.dir;
return 0;
});
}
list.innerHTML = sorted.map(mtRowHtml).join("");
// 더블클릭 = 업무 편집 팝업(현재 프로젝트가 아니어도 openTaskModal 은
// task.project_id 기준으로 담당자/세션 옵션을 다시 채우므로 그대로 동작).
list.querySelectorAll("li[data-task-id]").forEach(function (li) {
li.addEventListener("dblclick", function () {
const t = mtItems.find(function (x) { return String(x.id) === li.dataset.taskId; });
if (!t) return;
closeModal(memberTasksModal);
openTaskModal(t);
});
});
}
if (mtHead) mtHead.addEventListener("click", function (e) {
const h = e.target.closest(".pj-mt-h");
if (!h) return;
const key = h.dataset.sort;
if (mtSort.key === key) mtSort.dir *= -1; else { mtSort.key = key; mtSort.dir = 1; }
updateMtSortIcons();
renderMtList();
});
async function openMemberTasksModal(email, name) {
if (!memberTasksModal) return;
el("pj-mt-title").textContent = (name || email) + "님의 업무";
mtItems = [];
mtSort = { key: null, dir: 1 };
updateMtSortIcons();
const list = el("pj-mt-list");
list.innerHTML = "<li class='pj-empty-sm'>불러오는 중…</li>";
openModal(memberTasksModal);
try {
const res = await api("GET", "/project/api/users/tasks?email=" + encodeURIComponent(email));
const items = res.tasks || [];
list.innerHTML = items.length
? items.map(function (t) {
return '<li class="pj-mt-row' + (t.completed_at ? " is-done" : "") + '">' +
'<span class="pj-mt-proj" style="color:' + (t.project_color || "#4573d2") + '">' + escapeHtml(t.project_name || "-") + "</span>" +
'<span class="pj-mt-stage">' + escapeHtml(t.stage_name || "미지정") + "</span>" +
'<span class="pj-mt-title2">' + escapeHtml(t.title || "") + "</span>" +
'<span class="pj-mt-due">' + (t.due_date ? fmtDateKr(t.due_date) : "-") + "</span>" +
"</li>";
}).join("")
: "<li class='pj-empty-sm'>배정된 업무가 없습니다.</li>";
mtItems = res.tasks || [];
renderMtList();
} catch (e) {
list.innerHTML = "<li class='pj-empty-sm'>불러오기 실패: " + escapeHtml(e.message) + "</li>";
}