From 1d7fe71b752c0c82d8c25005cdf1e49587c2aaf5 Mon Sep 17 00:00:00 2001 From: king Date: Thu, 17 Sep 2026 12:27:44 +0900 Subject: [PATCH] =?UTF-8?q?feat(project):=20=EC=84=B8=EC=85=98=20=ED=97=A4?= =?UTF-8?q?=EB=8D=94=20=EC=83=89=EC=83=81=20=EC=B1=84=EC=9A=B0=EA=B8=B0,?= =?UTF-8?q?=20=EB=B3=B4=EB=93=9C=20=EB=8C=93=EA=B8=80=ED=8C=9D=EC=97=85,?= =?UTF-8?q?=20=EC=B1=84=ED=8C=85=20=EC=B2=A8=EB=B6=80=ED=8C=8C=EC=9D=BC/?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 세션 색상: 점 대신 세션 헤더 윗부분 배경을 통째로 칠함(글자/카운트 흰색으로) - 보드 업무 카드의 댓글 말풍선도 더블클릭하면 채팅 팝업 열리게(.pj-cmt-badge 공용) - 댓글 채팅 팝업에 파일/이미지 첨부 추가: 클립 버튼 업로드 + 클립보드 이미지 붙여넣기, 이미지는 썸네일, 파일은 확장자 아이콘+파일명으로 표시, 다운로드 가능, 업로드한 사람만 삭제(즉시 반영). task_attachments.comment_id(마이그 레이션 005)로 태깅해 업무 "첨부파일" 패널에도 그대로 함께 나온다. Co-Authored-By: Claude Sonnet 5 --- app/modules/project/db.py | 21 ++- app/modules/project/router.py | 12 +- .../project/templates/project/inbox.html | 4 +- .../project/templates/project/index.html | 11 +- .../project/templates/project/project.html | 11 +- app/static/project.css | 54 ++++++- app/static/project.js | 149 ++++++++++++++++-- .../project_db_005_comment_attachments.sql | 23 +++ 8 files changed, 258 insertions(+), 27 deletions(-) create mode 100644 scripts/sql/project_db_005_comment_attachments.sql diff --git a/app/modules/project/db.py b/app/modules/project/db.py index 89ceebf..d04e6b6 100644 --- a/app/modules/project/db.py +++ b/app/modules/project/db.py @@ -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) diff --git a/app/modules/project/router.py b/app/modules/project/router.py index c42e219..a1bfea7 100644 --- a/app/modules/project/router.py +++ b/app/modules/project/router.py @@ -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) diff --git a/app/modules/project/templates/project/inbox.html b/app/modules/project/templates/project/inbox.html index 1553a31..4840f8e 100644 --- a/app/modules/project/templates/project/inbox.html +++ b/app/modules/project/templates/project/inbox.html @@ -1,7 +1,7 @@ {% extends "erp_base.html" %} {% block head_extra %} - + {% endblock %} @@ -45,5 +45,5 @@ {% endif %} - + {% endblock %} diff --git a/app/modules/project/templates/project/index.html b/app/modules/project/templates/project/index.html index b12933f..926e2c9 100644 --- a/app/modules/project/templates/project/index.html +++ b/app/modules/project/templates/project/index.html @@ -1,7 +1,7 @@ {% extends "erp_base.html" %} {% block head_extra %} - + {% endblock %} @@ -380,8 +380,13 @@ +
- + +
@@ -393,5 +398,5 @@ - + {% endblock %} diff --git a/app/modules/project/templates/project/project.html b/app/modules/project/templates/project/project.html index 3c23399..6333475 100644 --- a/app/modules/project/templates/project/project.html +++ b/app/modules/project/templates/project/project.html @@ -1,7 +1,7 @@ {% extends "erp_base.html" %} {% block head_extra %} - + @@ -485,8 +485,13 @@ +
- + +
@@ -533,5 +538,5 @@ - + {% endblock %} diff --git a/app/static/project.css b/app/static/project.css index e74a13e..8e9f472 100644 --- a/app/static/project.css +++ b/app/static/project.css @@ -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; } diff --git a/app/static/project.js b/app/static/project.js index b7f1c93..0df911c 100644 --- a/app/static/project.js +++ b/app/static/project.js @@ -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 '' + + '' + icon + "" + + '' + escapeHtml(f.name) + "" + + ''; + }).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 + ? '' + : ""; + if (isImg) { + return '
' + + '' + + '' + escapeHtml(a.filename) + '' + + delBtn + "
"; + } + return '' + + '' + + '' + chatFileIcon(a.filename) + '' + + '' + escapeHtml(a.filename) + "" + + delBtn + ""; + } + 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 || []; + 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 = "
  • 댓글 없음
  • "; } 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 + ? '
    ' + atts.map(function (a) { return attachmentHtml(a, me); }).join("") + "
    " + : ""; li.innerHTML = (mine ? "" : '' + escapeHtml(idOf(c.author_email)) + "") + - '
    ' + + (c.body ? '
    ' : "") + + attsHtml + '' + (mine ? '' : "") + '' + (c.created_at || "").slice(0, 16).replace("T", " ") + "" + ""; - 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 = - '
    ' + - (s.color ? '' : "") + + '
    ' + '' + escapeHtml(s.name) + "" + (s.is_done_stage ? '완료' : "") + '' + @@ -2238,9 +2360,14 @@ ? '' + (t.subtask_done || 0) + "/" + t.subtask_total + "" : "") + (t.due_date ? '' + t.due_date + "" : "") + - (t.comment_count ? '' + cmtBadge(t) + "" : "") + + (t.comment_count + ? '' + cmtBadge(t) + "" + : "") + "
    "; 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)); diff --git a/scripts/sql/project_db_005_comment_attachments.sql b/scripts/sql/project_db_005_comment_attachments.sql new file mode 100644 index 0000000..0696c86 --- /dev/null +++ b/scripts/sql/project_db_005_comment_attachments.sql @@ -0,0 +1,23 @@ +-- ===================================================================== +-- project_db 마이그레이션 005 — 댓글(채팅) 첨부파일/이미지 +-- ===================================================================== +-- 멱등. task_attachments 를 그대로 재사용하되, 댓글에 딸린 파일은 +-- comment_id 를 채워 채팅 팝업에서 말풍선 안에 표시한다(comment_id NULL = +-- 기존처럼 업무 "첨부파일" 패널 전용, 설명란 이미지 삽입도 포함). +-- 댓글이 삭제되면(ON DELETE CASCADE) 거기 딸린 첨부도 함께 삭제된다 — +-- 파일 라우터가 디스크 파일까지 지우진 못하므로(DB 트리거 아님) 드문 +-- 경우지만 고아 파일이 남을 수 있다는 점은 감안한다. +-- +-- 실행: +-- docker exec -i postgres-db psql -U postgres -d project_db \ +-- < scripts/sql/project_db_005_comment_attachments.sql +-- ===================================================================== + +\set ON_ERROR_STOP on +\connect project_db + +ALTER TABLE task_attachments + ADD COLUMN IF NOT EXISTS comment_id BIGINT REFERENCES task_comments(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS idx_task_attachments_comment ON task_attachments (comment_id); + +SELECT 'project_db 005 ready' AS status;