feat(project): 세션 헤더 색상 채우기, 보드 댓글팝업, 채팅 첨부파일/이미지

- 세션 색상: 점 대신 세션 헤더 윗부분 배경을 통째로 칠함(글자/카운트 흰색으로)
- 보드 업무 카드의 댓글 말풍선도 더블클릭하면 채팅 팝업 열리게(.pj-cmt-badge 공용)
- 댓글 채팅 팝업에 파일/이미지 첨부 추가: 클립 버튼 업로드 + 클립보드 이미지
  붙여넣기, 이미지는 썸네일, 파일은 확장자 아이콘+파일명으로 표시, 다운로드
  가능, 업로드한 사람만 삭제(즉시 반영). task_attachments.comment_id(마이그
  레이션 005)로 태깅해 업무 "첨부파일" 패널에도 그대로 함께 나온다.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-17 12:27:44 +09:00
parent 6922679f51
commit 1d7fe71b75
8 changed files with 258 additions and 27 deletions
+16 -5
View File
@@ -683,11 +683,21 @@ class ProjectStore:
).fetchall()
return [self._serialize(r) for r in rows]
def get_comment(self, *, comment_id: int) -> dict[str, Any] | None:
with self._pool.connection() as conn:
row = conn.execute(
"SELECT * FROM task_comments WHERE id = %s", (comment_id,)
).fetchone()
return self._serialize(row) if row else None
def add_comment(
self, *, task_id: int, author_email: str, author_name: str, body: str
self, *, task_id: int, author_email: str, author_name: str, body: str,
allow_empty: bool = False,
) -> dict[str, Any]:
"""allow_empty — 채팅 팝업에서 파일/이미지만 보내는 메시지(본문 없음)를
허용할 때 True. 일반 댓글 입력은 여전히 빈 값을 막는다."""
text = store.norm_str(body)
if not text:
if not text and not allow_empty:
raise ValueError("댓글 내용이 비었습니다.")
with self._pool.connection() as conn:
row = conn.execute(
@@ -744,15 +754,16 @@ class ProjectStore:
def add_attachment(
self, *, task_id: int, filename: str, stored_name: str,
content_type: str, size_bytes: int, uploaded_by: str,
comment_id: int | None = None,
) -> dict[str, Any]:
with self._pool.connection() as conn:
row = conn.execute(
"INSERT INTO task_attachments "
"(task_id, filename, stored_name, content_type, size_bytes, uploaded_by) "
"VALUES (%s,%s,%s,%s,%s,%s) RETURNING *",
"(task_id, filename, stored_name, content_type, size_bytes, uploaded_by, comment_id) "
"VALUES (%s,%s,%s,%s,%s,%s,%s) RETURNING *",
(task_id, store.norm_str(filename), stored_name,
store.norm_str(content_type), int(size_bytes),
store.norm_str(uploaded_by, lower=True)),
store.norm_str(uploaded_by, lower=True), comment_id),
).fetchone()
return self._serialize(row)
+10 -2
View File
@@ -1164,6 +1164,7 @@ async def api_add_comment(
author_email=user["email"],
author_name=user.get("name") or user["email"].split("@")[0],
body=payload.get("body", ""),
allow_empty=bool(payload.get("has_attachment")),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
@@ -1237,14 +1238,21 @@ async def api_list_attachments(request: Request, task_id: int) -> JSONResponse:
@router.post("/api/tasks/{task_id}/attachments")
async def api_upload_attachment(
request: Request, task_id: int, file: UploadFile = File(...)
request: Request, task_id: int, file: UploadFile = File(...),
comment_id: int | None = Form(None),
) -> JSONResponse:
"""comment_id 를 주면 댓글 채팅 팝업의 첨부(이미지/파일)로도 등록 —
같은 표에 저장되므로 업무 "첨부파일" 패널에도 그대로 함께 나온다."""
user = _require_user(request)
st = _db_or_503(request)
task = st.get_task(task_id=task_id)
if task is None:
raise HTTPException(status_code=404, detail="업무를 찾을 수 없습니다.")
_require_manage(request, st, user, task["project_id"])
if comment_id is not None:
comment = st.get_comment(comment_id=comment_id)
if comment is None or comment["task_id"] != task_id:
raise HTTPException(status_code=400, detail="잘못된 댓글입니다.")
data = await file.read()
if len(data) > _MAX_ATTACH_BYTES:
@@ -1261,7 +1269,7 @@ async def api_upload_attachment(
att = st.add_attachment(
task_id=task_id, filename=orig, stored_name=stored,
content_type=file.content_type or "application/octet-stream",
size_bytes=len(data), uploaded_by=user["email"],
size_bytes=len(data), uploaded_by=user["email"], comment_id=comment_id,
)
return JSONResponse({"attachment": att}, status_code=201)
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/project.css?v=20260917l" />
<link rel="stylesheet" href="/static/project.css?v=20260917m" />
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
{% endblock %}
@@ -45,5 +45,5 @@
{% endif %}
</div>
<script src="/static/project.js?v=20260917q" defer></script>
<script src="/static/project.js?v=20260917r" defer></script>
{% endblock %}
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/project.css?v=20260917l" />
<link rel="stylesheet" href="/static/project.css?v=20260917m" />
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
{% endblock %}
@@ -380,8 +380,13 @@
</div>
<input type="hidden" id="pj-cm-task-id" />
<ul class="pj-chat-list" id="pj-chat-list"></ul>
<div class="pj-chat-pending" id="pj-cm-pending" hidden></div>
<div class="pj-comment-form">
<input type="text" id="pj-cm-input" placeholder="댓글 입력…" />
<label class="pj-chat-attach-btn" title="파일 추가">
<span class="material-symbols-outlined">attach_file</span>
<input type="file" id="pj-cm-file-input" multiple hidden />
</label>
<input type="text" id="pj-cm-input" placeholder="댓글 입력… (이미지는 붙여넣기 가능)" />
<button type="button" class="pj-btn pj-btn-primary" id="pj-cm-send">전송</button>
</div>
</div>
@@ -393,5 +398,5 @@
<script>
window.PJ_COLORS = {{ ["#4573d2","#37a3a3","#62a420","#e8a33d","#e8384f","#aa62e3","#f06a6a","#5a6772"] | tojson }};
</script>
<script src="/static/project.js?v=20260917q" defer></script>
<script src="/static/project.js?v=20260917r" defer></script>
{% endblock %}
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/project.css?v=20260917l" />
<link rel="stylesheet" href="/static/project.css?v=20260917m" />
<!-- 구글 머티리얼 심볼(담당자 아이콘 등) — self-host -->
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
<!-- 타임라인 vis-timeline — self-host -->
@@ -485,8 +485,13 @@
</div>
<input type="hidden" id="pj-cm-task-id" />
<ul class="pj-chat-list" id="pj-chat-list"></ul>
<div class="pj-chat-pending" id="pj-cm-pending" hidden></div>
<div class="pj-comment-form">
<input type="text" id="pj-cm-input" placeholder="댓글 입력…" />
<label class="pj-chat-attach-btn" title="파일 추가">
<span class="material-symbols-outlined">attach_file</span>
<input type="file" id="pj-cm-file-input" multiple hidden />
</label>
<input type="text" id="pj-cm-input" placeholder="댓글 입력… (이미지는 붙여넣기 가능)" />
<button type="button" class="pj-btn pj-btn-primary" id="pj-cm-send">전송</button>
</div>
</div>
@@ -533,5 +538,5 @@
</script>
<script src="/static/vendor/vis-timeline/vis-timeline-graph2d.min.js"></script>
<script src="/static/project.js?v=20260917q" defer></script>
<script src="/static/project.js?v=20260917r" defer></script>
{% endblock %}
+53 -1
View File
@@ -315,9 +315,18 @@
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 의 padding(12px)만큼 밀어내 컬럼 테두리에 딱 맞춘다. */
.pj-col-head.pj-col-head-colored {
margin: -12px -12px 4px; padding: 10px 14px; border-bottom: none;
border-radius: 13px 13px 0 0;
}
.pj-col-head.pj-col-head-colored .pj-col-name { color: #fff; }
.pj-col-head.pj-col-head-colored .pj-col-count { background: rgba(255,255,255,.25); border-color: rgba(255,255,255,.4); color: #fff; }
.pj-col-head.pj-col-head-colored .pj-col-ctrls .pj-icon-btn { color: #fff; }
.pj-col-head.pj-col-head-colored .pj-chip { background: rgba(255,255,255,.28); color: #fff; }
.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;
@@ -664,6 +673,49 @@
.pj-chat-del .material-symbols-outlined { font-size: 13px; }
.pj-chat-del:hover { color: #c22b10; background: #fbecea; }
/* ── 채팅 첨부(이미지/파일) — 말풍선 아래 붙는다 ── */
.pj-chat-atts { display: flex; flex-direction: column; gap: 6px; margin-top: 4px; max-width: 100%; }
.pj-chat-msg.mine .pj-chat-atts { align-items: flex-end; }
.pj-chat-att { position: relative; display: inline-flex; }
.pj-chat-att-img img {
max-width: 220px; max-height: 220px; border-radius: 12px; display: block;
object-fit: cover; box-shadow: 0 1px 2px rgba(0,0,0,.08);
}
.pj-chat-att-file {
display: inline-flex; align-items: center; gap: 4px; background: #fff;
border: 1px solid var(--pj-border); border-radius: 10px; padding: 2px 4px 2px 2px;
}
.pj-chat-att-file a { display: inline-flex; align-items: center; gap: 6px; padding: 4px 6px; color: var(--pj-ink); text-decoration: none; font-size: 12.5px; }
.pj-chat-att-file a:hover { text-decoration: underline; }
.pj-chat-att-icon { font-size: 16px; line-height: 1; }
.pj-chat-att-name { max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pj-chat-att-del {
width: 18px; height: 18px; flex: 0 0 auto; display: inline-flex; align-items: center;
justify-content: center; border-radius: 50%; color: #9aa1a9;
}
.pj-chat-att-del .material-symbols-outlined { font-size: 13px; }
.pj-chat-att-del:hover { color: #c22b10; background: #fbecea; }
.pj-chat-att-img .pj-chat-att-del {
position: absolute; top: 4px; right: 4px; background: rgba(20,22,26,.55); color: #fff;
}
.pj-chat-att-img .pj-chat-att-del:hover { background: rgba(194,43,16,.85); }
/* ── 보내기 전 대기 파일 칩 ── */
.pj-chat-pending { display: flex; flex-wrap: wrap; gap: 6px; padding: 0 2px 8px; flex: 0 0 auto; }
.pj-chat-pending-chip {
display: inline-flex; align-items: center; gap: 5px; background: #eef0f2;
border-radius: 8px; padding: 4px 8px; font-size: 11.5px; color: #333;
}
.pj-chat-pending-chip button { border: none; background: none; cursor: pointer; color: #6b7280; font-size: 13px; line-height: 1; padding: 0; }
.pj-chat-pending-chip button:hover { color: #c22b10; }
/* ── 첨부 버튼(클립) ── */
.pj-chat-attach-btn {
display: inline-flex; align-items: center; justify-content: center; width: 34px; height: 34px;
border-radius: 8px; cursor: pointer; color: #5f6368; flex: 0 0 auto;
}
.pj-chat-attach-btn:hover { background: #f1f2f4; }
/* ── 보드 단계 컨트롤 ── */
.pj-col-head { position: relative; }
.pj-col-name { font-weight: 700; }
+138 -11
View File
@@ -195,6 +195,20 @@
// getTaskTitle(taskId) — 채팅창 제목에 쓸 업무 제목을 페이지별 데이터에서
// 찾아 돌려주는 콜백(홈은 homeTasks, 프로젝트 화면은 myTasksById/tasks).
let cmtChat = null;
// 확장자 → 이모지 아이콘(파일 종류 표시용). 이미지는 썸네일로 따로 보여주므로
// 여기 없음.
function chatFileIcon(filename) {
const ext = String(filename || "").split(".").pop().toLowerCase();
const map = {
pdf: "📕", doc: "📄", docx: "📄", hwp: "📄", txt: "📃",
xls: "📊", xlsx: "📊", csv: "📊",
ppt: "📙", pptx: "📙",
zip: "🗜️", rar: "🗜️", "7z": "🗜️",
mp4: "🎬", mov: "🎬", avi: "🎬",
mp3: "🎵", wav: "🎵",
};
return map[ext] || "📎";
}
function initCommentsChat(getTaskTitle) {
const modal = el("pj-modal-comments");
if (!modal) return null;
@@ -215,13 +229,84 @@
return ((wrap && wrap.dataset.me) || "").toLowerCase();
}
// ── 보내기 전 대기 중인 파일(파일 선택/붙여넣기로 담은 것들) ──
let pendingFiles = [];
function renderPending() {
const box = el("pj-cm-pending");
if (!box) return;
if (!pendingFiles.length) { box.hidden = true; box.innerHTML = ""; return; }
box.hidden = false;
box.innerHTML = pendingFiles.map(function (f, i) {
const icon = f.type && f.type.indexOf("image/") === 0 ? "🖼️" : chatFileIcon(f.name);
return '<span class="pj-chat-pending-chip">' +
'<span class="pj-chat-att-icon">' + icon + "</span>" +
'<span>' + escapeHtml(f.name) + "</span>" +
'<button type="button" data-idx="' + i + '" title="빼기">×</button></span>';
}).join("");
box.querySelectorAll("button[data-idx]").forEach(function (btn) {
btn.addEventListener("click", function () {
pendingFiles.splice(parseInt(btn.dataset.idx, 10), 1);
renderPending();
});
});
}
const fileInput = el("pj-cm-file-input");
if (fileInput) fileInput.addEventListener("change", function () {
Array.prototype.slice.call(fileInput.files).forEach(function (f) { pendingFiles.push(f); });
fileInput.value = "";
renderPending();
});
const cmInput = el("pj-cm-input");
if (cmInput) cmInput.addEventListener("paste", function (e) {
const items = (e.clipboardData || window.clipboardData || {}).items || [];
let handled = false;
for (let i = 0; i < items.length; i++) {
if (items[i].type && items[i].type.indexOf("image/") === 0) {
const file = items[i].getAsFile();
if (file) { pendingFiles.push(file); handled = true; }
}
}
if (handled) { e.preventDefault(); renderPending(); }
});
// ── 댓글에 딸린 첨부(이미지/파일) 렌더 — 이미지는 썸네일, 그 외는 아이콘+파일명 ──
function attachmentHtml(a, me) {
const mine = (a.uploaded_by || "").toLowerCase() === me;
const isImg = (a.content_type || "").indexOf("image/") === 0;
const delBtn = mine
? '<button type="button" class="pj-chat-att-del" data-id="' + a.id + '" title="삭제">' +
'<span class="material-symbols-outlined">close</span></button>'
: "";
if (isImg) {
return '<div class="pj-chat-att pj-chat-att-img" data-id="' + a.id + '">' +
'<a href="/project/api/attachments/' + a.id + '/download" target="_blank" rel="noopener">' +
'<img src="/project/api/attachments/' + a.id + '/inline" alt="' + escapeHtml(a.filename) + '" /></a>' +
delBtn + "</div>";
}
return '<span class="pj-chat-att pj-chat-att-file" data-id="' + a.id + '">' +
'<a href="/project/api/attachments/' + a.id + '/download">' +
'<span class="pj-chat-att-icon">' + chatFileIcon(a.filename) + '</span>' +
'<span class="pj-chat-att-name">' + escapeHtml(a.filename) + "</span></a>" +
delBtn + "</span>";
}
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 || [];
const [cRes, aRes] = await Promise.all([
api("GET", "/project/api/tasks/" + taskId + "/comments"),
api("GET", "/project/api/tasks/" + taskId + "/attachments"),
]);
const comments = cRes.comments || [];
// 댓글에 딸린 첨부만 채팅 말풍선에 표시(comment_id NULL = 설명란
// 이미지/"첨부파일" 패널 전용이라 여기선 무시).
const attByComment = {};
(aRes.attachments || []).forEach(function (a) {
if (a.comment_id == null) return;
(attByComment[a.comment_id] = attByComment[a.comment_id] || []).push(a);
});
if (!comments.length) {
list.innerHTML = "<li class='pj-empty-sm'>댓글 없음</li>";
} else {
@@ -230,17 +315,23 @@
const mine = (c.author_email || "").toLowerCase() === me;
const li = document.createElement("li");
li.className = "pj-chat-msg" + (mine ? " mine" : "");
const atts = attByComment[c.id] || [];
const attsHtml = atts.length
? '<div class="pj-chat-atts">' + atts.map(function (a) { return attachmentHtml(a, me); }).join("") + "</div>"
: "";
li.innerHTML =
(mine ? "" : '<span class="pj-chat-name">' + escapeHtml(idOf(c.author_email)) + "</span>") +
'<div class="pj-chat-bubble"></div>' +
(c.body ? '<div class="pj-chat-bubble"></div>' : "") +
attsHtml +
'<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;
const bubble = li.querySelector(".pj-chat-bubble");
if (bubble) bubble.textContent = c.body;
if (mine) {
li.querySelector(".pj-chat-del").addEventListener("click", async function () {
if (!confirm("이 댓글을 삭제할까요?")) return;
if (!confirm("이 댓글을 삭제할까요? 첨부된 파일도 함께 삭제됩니다.")) return;
try {
await api("DELETE", "/project/api/comments/" + c.id);
const n = await renderChat(taskId);
@@ -248,6 +339,16 @@
} catch (e) { alert("삭제 실패: " + e.message); }
});
}
li.querySelectorAll(".pj-chat-att-del").forEach(function (btn) {
btn.addEventListener("click", async function (e) {
e.preventDefault(); e.stopPropagation();
if (!confirm("이 파일을 삭제할까요?")) return;
try {
await api("DELETE", "/project/api/attachments/" + btn.dataset.id);
await renderChat(taskId);
} catch (err) { alert("삭제 실패: " + err.message); }
});
});
list.appendChild(li);
});
list.scrollTop = list.scrollHeight;
@@ -272,6 +373,8 @@
async function open(taskId) {
el("pj-cm-task-id").value = taskId;
el("pj-cm-title").textContent = (getTaskTitle && getTaskTitle(taskId)) || "댓글";
pendingFiles = [];
renderPending();
openModal(modal);
const n = await renderChat(taskId);
if (n !== null) { markSeen(taskId, n); syncBadge(taskId, n); }
@@ -294,10 +397,28 @@
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;
const bodyText = inp.value.trim();
if (!taskId || (!bodyText && !pendingFiles.length)) return;
const filesToSend = pendingFiles.slice();
try {
await api("POST", "/project/api/tasks/" + taskId + "/comments", { body: body });
const res = await api("POST", "/project/api/tasks/" + taskId + "/comments",
{ body: bodyText, has_attachment: filesToSend.length > 0 });
const commentId = res.comment.id;
for (let i = 0; i < filesToSend.length; i++) {
const fd = new FormData();
fd.append("file", filesToSend[i]);
fd.append("comment_id", String(commentId));
try {
const r = await fetch("/project/api/tasks/" + taskId + "/attachments", { method: "POST", body: fd });
if (!r.ok) {
let m = r.statusText;
try { m = (await r.json()).detail || m; } catch (_) {}
showToast("파일 업로드 실패(" + filesToSend[i].name + "): " + m, "error");
}
} catch (_) { showToast("파일 업로드 실패: " + filesToSend[i].name, "error"); }
}
pendingFiles = [];
renderPending();
inp.value = "";
const n = await renderChat(taskId);
if (n !== null) { markSeen(taskId, n); syncBadge(taskId, n); }
@@ -2013,8 +2134,9 @@
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>' : "") +
'<div class="pj-col-head' + (s.color ? " pj-col-head-colored" : "") + '"' +
(canManage ? ' draggable="true"' : "") +
(s.color ? ' style="background:' + s.color + '"' : "") + '>' +
'<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>' +
@@ -2238,9 +2360,14 @@
? '<span class="pj-card-subtasks" title="하위 업무">' + (t.subtask_done || 0) + "/" + t.subtask_total + "</span>"
: "") +
(t.due_date ? '<span class="pj-card-due">' + t.due_date + "</span>" : "") +
(t.comment_count ? '<span class="pj-card-cmt">' + cmtBadge(t) + "</span>" : "") +
(t.comment_count
? '<span class="pj-card-cmt pj-cmt-badge" data-task-id="' + t.id + '" data-count="' + t.comment_count +
'" title="댓글 ' + t.comment_count + '개">' + cmtBadge(t) + "</span>"
: "") +
"</div>";
card.addEventListener("click", function () { if (canManage) openTaskModal(t); });
// 댓글 말풍선 더블클릭 → 채팅형 댓글 팝업(내 업무 패널과 동일 구현 공용).
if (cmt) cmt.wireBadge(card.querySelector(".pj-cmt-badge"));
if (canManage) {
card.addEventListener("dragstart", function (e) {
e.dataTransfer.setData("text/plain", String(t.id));