업무
+
더블클릭하면 업무 편집 창이 열립니다.
+
+ 프로젝트
+ 세션
+ 업무
+ 마감일
+
@@ -472,7 +511,10 @@
-
+
-
+
{% endblock %}
diff --git a/app/static/project.css b/app/static/project.css
index ba4fd5e..c8f4157 100644
--- a/app/static/project.css
+++ b/app/static/project.css
@@ -227,6 +227,8 @@
.pj-tree-acts { display: none; gap: 0; flex: 0 0 auto; }
.pj-tree-row:hover .pj-tree-acts { display: inline-flex; }
.pj-tree-acts .pj-icon-btn { width: 22px; height: 22px; font-size: 14px; }
+.pj-tree-li[draggable="true"] { cursor: grab; }
+.pj-tree-li.is-dragging { opacity: .45; }
/* 구글 머티리얼 심볼 아이콘(담당자) */
.material-symbols-outlined { font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24; line-height: 1; vertical-align: middle; }
@@ -303,11 +305,16 @@
position/width/left/top 은 layoutMasonry 가 매번 인라인으로 계산해 준다. */
.pj-col { position: absolute; background: #eef0f4; border: 1px solid #e2e4ea; border-radius: 14px; padding: 12px; box-sizing: border-box; }
.pj-empty { position: static; }
+/* 완료로 지정된 세션 — 반투명 + 보드 끝으로(정렬은 JS 쪽 comparator 담당) */
+.pj-col.pj-col-done { opacity: .55; }
.pj-col-head {
- display: flex; align-items: center; gap: 6px; font-weight: 700; font-size: 13px;
+ display: flex; align-items: center; gap: 6px; font-size: 13px;
padding: 2px 6px 12px; margin-bottom: 4px; color: var(--pj-ink);
border-bottom: 1px solid #e2e4ea;
}
+.pj-col-dot { width: 10px; height: 10px; border-radius: 50%; flex: 0 0 auto; }
+/* 세션 제목 — 업무 카드 제목(pj-card-title 13.5px/600)보다 뚜렷하게 크고 굵게 */
+.pj-col-name { font-size: 15.5px; font-weight: 800; }
.pj-col-count {
margin-left: auto; color: var(--pj-ink-soft); font-weight: 700; font-size: 11px;
background: #fff; border: 1px solid #e2e4ea; border-radius: 999px; min-width: 20px; height: 20px; padding: 0 6px;
@@ -606,11 +613,22 @@
/* ── 전체 멤버 클릭 팝업 — 담당 업무 목록(프로젝트/세션/제목/마감일) ── */
.pj-mt-card { width: min(600px, 94vw); }
+.pj-mt-head {
+ display: grid; grid-template-columns: 100px 90px 1fr 80px; gap: 8px;
+ padding: 6px; border-bottom: 2px solid #eef0f2; margin-bottom: 2px;
+}
+.pj-mt-h {
+ text-align: center; font-weight: 700; font-size: 12px; color: #1e1f21;
+ cursor: pointer; user-select: none; display: flex; align-items: center; justify-content: center; gap: 3px;
+}
+.pj-mt-h:hover { color: var(--pj-accent); }
+.pj-mt-sort-icon { font-size: 10px; color: var(--pj-accent); }
.pj-mt-list { list-style: none; margin: 0 0 10px; padding: 0; max-height: 50vh; overflow-y: auto; }
.pj-mt-row {
display: grid; grid-template-columns: 100px 90px 1fr 80px; gap: 8px; align-items: center;
- padding: 8px 6px; border-bottom: 1px solid #f1f2f4; font-size: 12.5px;
+ padding: 8px 6px; border-bottom: 1px solid #f1f2f4; font-size: 12.5px; cursor: pointer;
}
+.pj-mt-row:hover { background: #f6f7f8; }
.pj-mt-row.is-done { color: #9aa1a9; text-decoration: line-through; }
.pj-mt-proj { font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pj-mt-stage { color: #6b7280; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -646,9 +664,9 @@
/* ── 보드 단계 컨트롤 ── */
.pj-col-head { position: relative; }
.pj-col-name { font-weight: 700; }
-.pj-col-ctrls { display: none; gap: 1px; margin-left: 6px; }
+.pj-col-ctrls { display: none; gap: 3px; margin-left: 6px; }
.pj-col:hover .pj-col-ctrls { display: inline-flex; }
-.pj-col-ctrls .pj-icon-btn { width: 20px; height: 20px; font-size: 13px; }
+.pj-col-ctrls .pj-icon-btn { width: 28px; height: 28px; font-size: 17px; }
.pj-icon-btn[disabled] { opacity: .3; cursor: default; }
.pj-add-stage { width: 100%; padding: 10px; border: 1px dashed #c9cdd3; border-radius: 12px; background: #fff; color: #6b7280; font-size: 13px; font-weight: 600; cursor: pointer; }
.pj-add-stage:hover { border-color: #1e1f21; color: #1e1f21; }
diff --git a/app/static/project.js b/app/static/project.js
index 370e373..41427b2 100644
--- a/app/static/project.js
+++ b/app/static/project.js
@@ -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 = "
불러오는 중…";
+ try {
+ const res = await api("GET", "/project/api/tasks/" + taskId + "/comments");
+ const comments = res.comments || [];
+ if (!comments.length) {
+ list.innerHTML = "
댓글 없음";
+ } 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 ? "" : '
' + escapeHtml(idOf(c.author_email)) + "") +
+ '
' +
+ '
' +
+ (mine ? '' : "") +
+ '' + (c.created_at || "").slice(0, 16).replace("T", " ") + "" +
+ "";
+ 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 = "
불러오기 실패";
+ 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"));
// 서버가 처음 그린
(macro)는 data-task-id 를 자체가 아니라
// 안쪽 .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 = "불러오는 중…";
- try {
- const res = await api("GET", "/project/api/tasks/" + taskId + "/comments");
- const comments = res.comments || [];
- if (!comments.length) {
- list.innerHTML = "
댓글 없음";
- } 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 ? "" : '
' + escapeHtml(idOf(c.author_email)) + "") +
- '
' +
- '
' +
- (mine ? '' : "") +
- '' + (c.created_at || "").slice(0, 16).replace("T", " ") + "" +
- "";
- 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 = "
불러오기 실패";
- 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 =
'
' +
+ (s.color ? '
' : "") +
'
' + escapeHtml(s.name) + "" +
(s.is_done_stage ? '
완료' : "") +
'
' +
(canManage
? '
' +
- '' +
+ '' +
'" +
'' +
@@ -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 @@
"";
}
const initial = escapeHtml((p.name || "?").trim().charAt(0).toUpperCase() || "?");
- return '
' +
'
' +
'' + initial + "" +
@@ -3004,6 +3054,9 @@
} catch (err) { showToast("삭제 실패: " + err.message, "error"); }
}
function wireTreeHandlers(rootUl) {
+ // 는 기본 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 '' +
+ '' + escapeHtml(t.project_name || "-") + "" +
+ '' + escapeHtml(t.stage_name || "미지정") + "" +
+ '' + escapeHtml(t.title || "") + "" +
+ '' + (t.due_date ? fmtDateKr(t.due_date) : "-") + "" +
+ "";
+ }
+ 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 = "배정된 업무가 없습니다."; 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 = "불러오는 중…";
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 '' +
- '' + escapeHtml(t.project_name || "-") + "" +
- '' + escapeHtml(t.stage_name || "미지정") + "" +
- '' + escapeHtml(t.title || "") + "" +
- '' + (t.due_date ? fmtDateKr(t.due_date) : "-") + "" +
- "";
- }).join("")
- : "배정된 업무가 없습니다.";
+ mtItems = res.tasks || [];
+ renderMtList();
} catch (e) {
list.innerHTML = "불러오기 실패: " + escapeHtml(e.message) + "";
}
diff --git a/docs/PROJECT_MODULE.md b/docs/PROJECT_MODULE.md
index 47ab7d1..8dce067 100644
--- a/docs/PROJECT_MODULE.md
+++ b/docs/PROJECT_MODULE.md
@@ -71,6 +71,8 @@
만들지 않는다**(아사나처럼 직접 "+ 세션 추가"로 만든다 — `create_project`
의 `seed_stages` 기본값은 `False`). `is_done_stage`=TRUE 단계로 옮기면 업무
완료 처리. `created_by`(003) — 삭제를 "만든 사람만"으로 제한하기 위함.
+ `color`(004, nullable) — 세션 생성/편집 팝업에서 아사나 10색 팔레트
+ (`store.STAGE_COLOR_PALETTE`) 중 선택, 보드 컬럼 헤더에 표시.
- `tasks` — 업무. stage_id, title, description(HTML — 이미지 삽입 지원), assignee_email/name, priority(low|normal|high), start/due_date, completed_at, `parent_task_id`(003, self-FK `ON DELETE CASCADE`, NULL=최상위 업무).
- `task_project_links`(003) — 멀티호밍. `(task_id, project_id)` UNIQUE, `stage_id` nullable(그 프로젝트 안에서의 세션).
- `task_comments` — 댓글.
@@ -122,11 +124,16 @@ docker exec -i postgres-db psql -U postgres -v app_password="$APP_PWD" \
# PROJECT_DB_URL=postgresql://project_app:@postgres-db:5432/project_db
# (메일 쓰려면 SMTP_* 추가)
-# 3) 마이그레이션 002·003 적용 (첨부/알림, 세션 생성자·하위업무·멀티호밍)
+# 3) 마이그레이션 002~004 적용 (첨부/알림, 세션 생성자·하위업무·멀티호밍,
+# 업무 시간, 세션 색상)
docker exec -i postgres-db psql -U postgres -d project_db \
< scripts/sql/project_db_002_attachments_notifications.sql
docker exec -i postgres-db psql -U postgres -d project_db \
< scripts/sql/project_db_003_sections_subtasks_links.sql
+docker exec -i postgres-db psql -U postgres -d project_db \
+ < scripts/sql/project_db_003_task_times.sql
+docker exec -i postgres-db psql -U postgres -d project_db \
+ < scripts/sql/project_db_004_stage_color.sql
# 4) 재배포 (git pull 후 반드시 --build)
cd /opt/www/main && docker compose up -d --build web
diff --git a/scripts/sql/project_db_004_stage_color.sql b/scripts/sql/project_db_004_stage_color.sql
new file mode 100644
index 0000000..bc05df4
--- /dev/null
+++ b/scripts/sql/project_db_004_stage_color.sql
@@ -0,0 +1,16 @@
+-- =====================================================================
+-- project_db 마이그레이션 004 — 세션(project_stages) 색상
+-- =====================================================================
+-- 멱등. NULL = 색상 미지정(기본 회색으로 표시). DROP 없음.
+--
+-- 실행:
+-- docker exec -i postgres-db psql -U postgres -d project_db \
+-- < scripts/sql/project_db_004_stage_color.sql
+-- =====================================================================
+
+\set ON_ERROR_STOP on
+\connect project_db
+
+ALTER TABLE project_stages ADD COLUMN IF NOT EXISTS color VARCHAR(16);
+
+SELECT 'project_db 004 ready' AS status;