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
+12 -5
View File
@@ -238,11 +238,13 @@ class ProjectStore:
return [self._serialize(r) for r in rows]
def add_stage(
self, *, project_id: int, name: str, is_done_stage: bool = False, created_by: str = ""
self, *, project_id: int, name: str, is_done_stage: bool = False,
color: str | None = None, created_by: str = "",
) -> dict[str, Any]:
nm = store.norm_str(name)
if not nm:
raise ValueError("세션 이름은 필수입니다.")
col = store.validate_stage_color(color)
with self._pool.connection() as conn:
nxt = conn.execute(
"SELECT COALESCE(MAX(sort_order), -1) + 1 AS n "
@@ -250,9 +252,9 @@ class ProjectStore:
(project_id,),
).fetchone()
row = conn.execute(
"INSERT INTO project_stages (project_id, name, sort_order, is_done_stage, created_by) "
"VALUES (%s,%s,%s,%s,%s) RETURNING *",
(project_id, nm, int(nxt["n"]), bool(is_done_stage),
"INSERT INTO project_stages (project_id, name, sort_order, is_done_stage, color, created_by) "
"VALUES (%s,%s,%s,%s,%s,%s) RETURNING *",
(project_id, nm, int(nxt["n"]), bool(is_done_stage), col,
store.norm_str(created_by, lower=True)),
).fetchone()
return self._serialize(row)
@@ -776,8 +778,10 @@ class ProjectStore:
# ════════════════════════════════════════════════════════════
def update_stage(
self, *, stage_id: int, name: str | None = None,
is_done_stage: bool | None = None,
is_done_stage: bool | None = None, color: Any = ...,
) -> dict[str, Any]:
"""color 는 지정 안 함(기본 `...`)과 명시적으로 지우기(`None`)를
구분해야 해서 다른 필드처럼 `is None` 이 아니라 `is not ...` 로 검사한다."""
sets: list[str] = []
params: list[Any] = []
if name is not None:
@@ -789,6 +793,9 @@ class ProjectStore:
if is_done_stage is not None:
sets.append("is_done_stage = %s")
params.append(bool(is_done_stage))
if color is not ...:
sets.append("color = %s")
params.append(store.validate_stage_color(color))
if not sets:
raise ValueError("변경할 내용이 없습니다.")
params.append(stage_id)
+3
View File
@@ -618,6 +618,7 @@ async def project_page(request: Request, project_id: int) -> HTMLResponse:
"avatars": avatars,
"priority_labels": store.PRIORITY_LABELS,
"color_palette": list(store.COLOR_PALETTE),
"stage_color_palette": list(store.STAGE_COLOR_PALETTE),
"today": today.isoformat(),
"year": year,
"month": month,
@@ -865,6 +866,7 @@ async def api_add_stage(
project_id=project_id,
name=payload.get("name", ""),
is_done_stage=bool(payload.get("is_done_stage", False)),
color=payload.get("color"),
created_by=user["email"],
)
except ValueError as exc:
@@ -1362,6 +1364,7 @@ async def api_update_stage(
stage_id=stage_id,
name=payload.get("name"),
is_done_stage=payload.get("is_done_stage"),
color=payload.get("color", ...),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
+21
View File
@@ -49,6 +49,21 @@ COLOR_PALETTE: tuple[str, ...] = (
"#5a6772", # slate
)
# 세션(칸반 컬럼) 색상 팔레트 — 아사나 태그/커스텀필드에서 쓰는 10색.
# 세션 생성/편집 팝업의 색상표가 이 순서 그대로 노출된다.
STAGE_COLOR_PALETTE: tuple[str, ...] = (
"#4573d2", # blue
"#37a3a3", # teal
"#62a420", # green
"#e8a33d", # amber
"#e8384f", # red
"#aa62e3", # purple
"#f06a6a", # coral
"#5a6772", # slate
"#e8398a", # pink
"#a15c43", # brown
)
# ════════════════════════════════════════════════════════════
# 대한민국 국경일·공휴일(2026~2030) — 프로젝트 달력 표시 전용.
#
@@ -181,6 +196,12 @@ def validate_color(color: Any) -> str:
return COLOR_PALETTE[0]
def validate_stage_color(color: Any) -> str | None:
"""세션 색상 — 팔레트 밖 값/빈값은 저장 안 함(NULL, 기본 회색 표시)."""
v = norm_str(color).lower()
return v if v in STAGE_COLOR_PALETTE else None
def validate_date(d: Any) -> str | None:
"""'YYYY-MM-DD' 형식만 통과. 빈값은 None."""
from datetime import date as _date # noqa: WPS433
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/project.css?v=20260917j" />
<link rel="stylesheet" href="/static/project.css?v=20260917k" />
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
{% endblock %}
@@ -45,5 +45,5 @@
{% endif %}
</div>
<script src="/static/project.js?v=20260917o" defer></script>
<script src="/static/project.js?v=20260917p" defer></script>
{% endblock %}
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/project.css?v=20260917j" />
<link rel="stylesheet" href="/static/project.css?v=20260917k" />
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
{% endblock %}
@@ -393,5 +393,5 @@
<script>
window.PJ_COLORS = {{ ["#4573d2","#37a3a3","#62a420","#e8a33d","#e8384f","#aa62e3","#f06a6a","#5a6772"] | tojson }};
</script>
<script src="/static/project.js?v=20260917o" defer></script>
<script src="/static/project.js?v=20260917p" defer></script>
{% endblock %}
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/project.css?v=20260917j" />
<link rel="stylesheet" href="/static/project.css?v=20260917k" />
<!-- 구글 머티리얼 심볼(담당자 아이콘 등) — self-host -->
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
<!-- 타임라인 vis-timeline — self-host -->
@@ -290,6 +290,23 @@
</div>
</div>
<!-- 세션 추가/편집 모달 — 보드의 "+ 세션 추가" 버튼과 세션 헤더의 ✎(세션 편집) 공용 -->
<div class="pj-modal" id="pj-modal-stage" hidden>
<div class="pj-modal-card">
<h3 id="pj-stage-modal-title">새 세션</h3>
<input type="hidden" id="pj-stg-id" />
<label>이름<input type="text" id="pj-stg-name" placeholder="세션 이름" /></label>
<label>색상
<span class="pj-color-palette" id="pj-stg-colors"></span>
</label>
<div class="pj-modal-actions">
<span style="flex:1"></span>
<button type="button" class="pj-btn" data-close>취소</button>
<button type="button" class="pj-btn pj-btn-primary" id="pj-save-stage">저장</button>
</div>
</div>
</div>
<!-- 업무 모달 -->
<div class="pj-modal" id="pj-modal-task" hidden>
<div class="pj-modal-card pj-task-modal-card">
@@ -460,10 +477,32 @@
</div>
{% endif %}
<!-- 댓글만 보는 채팅형 팝업 — 업무 카드/내 업무의 말풍선 아이콘 더블클릭(홈 화면과 동일 기능) -->
<div class="pj-modal" id="pj-modal-comments" hidden>
<div class="pj-modal-card pj-modal-chat">
<div class="pj-chat-head">
<h3 id="pj-cm-title">댓글</h3>
</div>
<input type="hidden" id="pj-cm-task-id" />
<ul class="pj-chat-list" id="pj-chat-list"></ul>
<div class="pj-comment-form">
<input type="text" id="pj-cm-input" placeholder="댓글 입력…" />
<button type="button" class="pj-btn pj-btn-primary" id="pj-cm-send">전송</button>
</div>
</div>
</div>
<!-- 전체 멤버 아이콘 클릭 팝업 — 그 사람이 담당자인 업무를 프로젝트/세션/마감일과 함께 나열 -->
<div class="pj-modal" id="pj-modal-member-tasks" hidden>
<div class="pj-modal-card pj-mt-card">
<h3 id="pj-mt-title">업무</h3>
<p class="pj-modal-hint">더블클릭하면 업무 편집 창이 열립니다.</p>
<div class="pj-mt-head" id="pj-mt-head">
<span class="pj-mt-h" data-sort="project">프로젝트<span class="pj-mt-sort-icon"></span></span>
<span class="pj-mt-h" data-sort="stage">세션<span class="pj-mt-sort-icon"></span></span>
<span class="pj-mt-h" data-sort="title">업무<span class="pj-mt-sort-icon"></span></span>
<span class="pj-mt-h" data-sort="due">마감일<span class="pj-mt-sort-icon"></span></span>
</div>
<ul class="pj-mt-list" id="pj-mt-list"></ul>
<div class="pj-modal-actions">
<span style="flex:1"></span>
@@ -472,7 +511,10 @@
</div>
</div>
<script>window.PJ_COLORS = {{ color_palette | tojson }};</script>
<script>
window.PJ_COLORS = {{ color_palette | tojson }};
window.PJ_STAGE_COLORS = {{ stage_color_palette | tojson }};
</script>
<!-- 데이터 -->
<script id="pj-data" type="application/json">
{
@@ -491,5 +533,5 @@
</script>
<script src="/static/vendor/vis-timeline/vis-timeline-graph2d.min.js"></script>
<script src="/static/project.js?v=20260917o" defer></script>
<script src="/static/project.js?v=20260917p" defer></script>
{% endblock %}
+22 -4
View File
@@ -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; }
+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>";
}
+8 -1
View File
@@ -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:<APP_PWD>@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
@@ -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;