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 '";
+ }
+ 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;