feat(project): 세션 헤더 색상 채우기, 보드 댓글팝업, 채팅 첨부파일/이미지
- 세션 색상: 점 대신 세션 헤더 윗부분 배경을 통째로 칠함(글자/카운트 흰색으로) - 보드 업무 카드의 댓글 말풍선도 더블클릭하면 채팅 팝업 열리게(.pj-cmt-badge 공용) - 댓글 채팅 팝업에 파일/이미지 첨부 추가: 클립 버튼 업로드 + 클립보드 이미지 붙여넣기, 이미지는 썸네일, 파일은 확장자 아이콘+파일명으로 표시, 다운로드 가능, 업로드한 사람만 삭제(즉시 반영). task_attachments.comment_id(마이그 레이션 005)로 태깅해 업무 "첨부파일" 패널에도 그대로 함께 나온다. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -683,11 +683,21 @@ class ProjectStore:
|
|||||||
).fetchall()
|
).fetchall()
|
||||||
return [self._serialize(r) for r in rows]
|
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(
|
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]:
|
) -> dict[str, Any]:
|
||||||
|
"""allow_empty — 채팅 팝업에서 파일/이미지만 보내는 메시지(본문 없음)를
|
||||||
|
허용할 때 True. 일반 댓글 입력은 여전히 빈 값을 막는다."""
|
||||||
text = store.norm_str(body)
|
text = store.norm_str(body)
|
||||||
if not text:
|
if not text and not allow_empty:
|
||||||
raise ValueError("댓글 내용이 비었습니다.")
|
raise ValueError("댓글 내용이 비었습니다.")
|
||||||
with self._pool.connection() as conn:
|
with self._pool.connection() as conn:
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
@@ -744,15 +754,16 @@ class ProjectStore:
|
|||||||
def add_attachment(
|
def add_attachment(
|
||||||
self, *, task_id: int, filename: str, stored_name: str,
|
self, *, task_id: int, filename: str, stored_name: str,
|
||||||
content_type: str, size_bytes: int, uploaded_by: str,
|
content_type: str, size_bytes: int, uploaded_by: str,
|
||||||
|
comment_id: int | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
with self._pool.connection() as conn:
|
with self._pool.connection() as conn:
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"INSERT INTO task_attachments "
|
"INSERT INTO task_attachments "
|
||||||
"(task_id, filename, stored_name, content_type, size_bytes, uploaded_by) "
|
"(task_id, filename, stored_name, content_type, size_bytes, uploaded_by, comment_id) "
|
||||||
"VALUES (%s,%s,%s,%s,%s,%s) RETURNING *",
|
"VALUES (%s,%s,%s,%s,%s,%s,%s) RETURNING *",
|
||||||
(task_id, store.norm_str(filename), stored_name,
|
(task_id, store.norm_str(filename), stored_name,
|
||||||
store.norm_str(content_type), int(size_bytes),
|
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()
|
).fetchone()
|
||||||
return self._serialize(row)
|
return self._serialize(row)
|
||||||
|
|
||||||
|
|||||||
@@ -1164,6 +1164,7 @@ async def api_add_comment(
|
|||||||
author_email=user["email"],
|
author_email=user["email"],
|
||||||
author_name=user.get("name") or user["email"].split("@")[0],
|
author_name=user.get("name") or user["email"].split("@")[0],
|
||||||
body=payload.get("body", ""),
|
body=payload.get("body", ""),
|
||||||
|
allow_empty=bool(payload.get("has_attachment")),
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(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")
|
@router.post("/api/tasks/{task_id}/attachments")
|
||||||
async def api_upload_attachment(
|
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:
|
) -> JSONResponse:
|
||||||
|
"""comment_id 를 주면 댓글 채팅 팝업의 첨부(이미지/파일)로도 등록 —
|
||||||
|
같은 표에 저장되므로 업무 "첨부파일" 패널에도 그대로 함께 나온다."""
|
||||||
user = _require_user(request)
|
user = _require_user(request)
|
||||||
st = _db_or_503(request)
|
st = _db_or_503(request)
|
||||||
task = st.get_task(task_id=task_id)
|
task = st.get_task(task_id=task_id)
|
||||||
if task is None:
|
if task is None:
|
||||||
raise HTTPException(status_code=404, detail="업무를 찾을 수 없습니다.")
|
raise HTTPException(status_code=404, detail="업무를 찾을 수 없습니다.")
|
||||||
_require_manage(request, st, user, task["project_id"])
|
_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()
|
data = await file.read()
|
||||||
if len(data) > _MAX_ATTACH_BYTES:
|
if len(data) > _MAX_ATTACH_BYTES:
|
||||||
@@ -1261,7 +1269,7 @@ async def api_upload_attachment(
|
|||||||
att = st.add_attachment(
|
att = st.add_attachment(
|
||||||
task_id=task_id, filename=orig, stored_name=stored,
|
task_id=task_id, filename=orig, stored_name=stored,
|
||||||
content_type=file.content_type or "application/octet-stream",
|
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)
|
return JSONResponse({"attachment": att}, status_code=201)
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{% extends "erp_base.html" %}
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
{% block head_extra %}
|
{% 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" />
|
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@@ -45,5 +45,5 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/project.js?v=20260917q" defer></script>
|
<script src="/static/project.js?v=20260917r" defer></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{% extends "erp_base.html" %}
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
{% block head_extra %}
|
{% 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" />
|
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@@ -380,8 +380,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<input type="hidden" id="pj-cm-task-id" />
|
<input type="hidden" id="pj-cm-task-id" />
|
||||||
<ul class="pj-chat-list" id="pj-chat-list"></ul>
|
<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">
|
<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>
|
<button type="button" class="pj-btn pj-btn-primary" id="pj-cm-send">전송</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -393,5 +398,5 @@
|
|||||||
<script>
|
<script>
|
||||||
window.PJ_COLORS = {{ ["#4573d2","#37a3a3","#62a420","#e8a33d","#e8384f","#aa62e3","#f06a6a","#5a6772"] | tojson }};
|
window.PJ_COLORS = {{ ["#4573d2","#37a3a3","#62a420","#e8a33d","#e8384f","#aa62e3","#f06a6a","#5a6772"] | tojson }};
|
||||||
</script>
|
</script>
|
||||||
<script src="/static/project.js?v=20260917q" defer></script>
|
<script src="/static/project.js?v=20260917r" defer></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{% extends "erp_base.html" %}
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
{% block head_extra %}
|
{% block head_extra %}
|
||||||
<link rel="stylesheet" href="/static/project.css?v=20260917l" />
|
<link rel="stylesheet" href="/static/project.css?v=20260917m" />
|
||||||
<!-- 구글 머티리얼 심볼(담당자 아이콘 등) — self-host -->
|
<!-- 구글 머티리얼 심볼(담당자 아이콘 등) — self-host -->
|
||||||
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
|
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
|
||||||
<!-- 타임라인 vis-timeline — self-host -->
|
<!-- 타임라인 vis-timeline — self-host -->
|
||||||
@@ -485,8 +485,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<input type="hidden" id="pj-cm-task-id" />
|
<input type="hidden" id="pj-cm-task-id" />
|
||||||
<ul class="pj-chat-list" id="pj-chat-list"></ul>
|
<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">
|
<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>
|
<button type="button" class="pj-btn pj-btn-primary" id="pj-cm-send">전송</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -533,5 +538,5 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script src="/static/vendor/vis-timeline/vis-timeline-graph2d.min.js"></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 %}
|
{% endblock %}
|
||||||
|
|||||||
+53
-1
@@ -315,9 +315,18 @@
|
|||||||
padding: 2px 6px 12px; margin-bottom: 4px; color: var(--pj-ink);
|
padding: 2px 6px 12px; margin-bottom: 4px; color: var(--pj-ink);
|
||||||
border-bottom: 1px solid #e2e4ea;
|
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-card-title 13.5px/600)보다 뚜렷하게 크고 굵게 */
|
||||||
.pj-col-name { font-size: 15.5px; font-weight: 800; }
|
.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 {
|
.pj-col-count {
|
||||||
margin-left: auto; color: var(--pj-ink-soft); font-weight: 700; font-size: 11px;
|
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;
|
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 .material-symbols-outlined { font-size: 13px; }
|
||||||
.pj-chat-del:hover { color: #c22b10; background: #fbecea; }
|
.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-head { position: relative; }
|
||||||
.pj-col-name { font-weight: 700; }
|
.pj-col-name { font-weight: 700; }
|
||||||
|
|||||||
+138
-11
@@ -195,6 +195,20 @@
|
|||||||
// getTaskTitle(taskId) — 채팅창 제목에 쓸 업무 제목을 페이지별 데이터에서
|
// getTaskTitle(taskId) — 채팅창 제목에 쓸 업무 제목을 페이지별 데이터에서
|
||||||
// 찾아 돌려주는 콜백(홈은 homeTasks, 프로젝트 화면은 myTasksById/tasks).
|
// 찾아 돌려주는 콜백(홈은 homeTasks, 프로젝트 화면은 myTasksById/tasks).
|
||||||
let cmtChat = null;
|
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) {
|
function initCommentsChat(getTaskTitle) {
|
||||||
const modal = el("pj-modal-comments");
|
const modal = el("pj-modal-comments");
|
||||||
if (!modal) return null;
|
if (!modal) return null;
|
||||||
@@ -215,13 +229,84 @@
|
|||||||
return ((wrap && wrap.dataset.me) || "").toLowerCase();
|
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) {
|
async function renderChat(taskId) {
|
||||||
const me = currentUserEmail();
|
const me = currentUserEmail();
|
||||||
const list = el("pj-chat-list");
|
const list = el("pj-chat-list");
|
||||||
list.innerHTML = "<li class='pj-empty-sm'>불러오는 중…</li>";
|
list.innerHTML = "<li class='pj-empty-sm'>불러오는 중…</li>";
|
||||||
try {
|
try {
|
||||||
const res = await api("GET", "/project/api/tasks/" + taskId + "/comments");
|
const [cRes, aRes] = await Promise.all([
|
||||||
const comments = res.comments || [];
|
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) {
|
if (!comments.length) {
|
||||||
list.innerHTML = "<li class='pj-empty-sm'>댓글 없음</li>";
|
list.innerHTML = "<li class='pj-empty-sm'>댓글 없음</li>";
|
||||||
} else {
|
} else {
|
||||||
@@ -230,17 +315,23 @@
|
|||||||
const mine = (c.author_email || "").toLowerCase() === me;
|
const mine = (c.author_email || "").toLowerCase() === me;
|
||||||
const li = document.createElement("li");
|
const li = document.createElement("li");
|
||||||
li.className = "pj-chat-msg" + (mine ? " mine" : "");
|
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 =
|
li.innerHTML =
|
||||||
(mine ? "" : '<span class="pj-chat-name">' + escapeHtml(idOf(c.author_email)) + "</span>") +
|
(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">' +
|
'<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>' : "") +
|
(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 class="pj-chat-time">' + (c.created_at || "").slice(0, 16).replace("T", " ") + "</span>" +
|
||||||
"</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) {
|
if (mine) {
|
||||||
li.querySelector(".pj-chat-del").addEventListener("click", async function () {
|
li.querySelector(".pj-chat-del").addEventListener("click", async function () {
|
||||||
if (!confirm("이 댓글을 삭제할까요?")) return;
|
if (!confirm("이 댓글을 삭제할까요? 첨부된 파일도 함께 삭제됩니다.")) return;
|
||||||
try {
|
try {
|
||||||
await api("DELETE", "/project/api/comments/" + c.id);
|
await api("DELETE", "/project/api/comments/" + c.id);
|
||||||
const n = await renderChat(taskId);
|
const n = await renderChat(taskId);
|
||||||
@@ -248,6 +339,16 @@
|
|||||||
} catch (e) { alert("삭제 실패: " + e.message); }
|
} 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.appendChild(li);
|
||||||
});
|
});
|
||||||
list.scrollTop = list.scrollHeight;
|
list.scrollTop = list.scrollHeight;
|
||||||
@@ -272,6 +373,8 @@
|
|||||||
async function open(taskId) {
|
async function open(taskId) {
|
||||||
el("pj-cm-task-id").value = taskId;
|
el("pj-cm-task-id").value = taskId;
|
||||||
el("pj-cm-title").textContent = (getTaskTitle && getTaskTitle(taskId)) || "댓글";
|
el("pj-cm-title").textContent = (getTaskTitle && getTaskTitle(taskId)) || "댓글";
|
||||||
|
pendingFiles = [];
|
||||||
|
renderPending();
|
||||||
openModal(modal);
|
openModal(modal);
|
||||||
const n = await renderChat(taskId);
|
const n = await renderChat(taskId);
|
||||||
if (n !== null) { markSeen(taskId, n); syncBadge(taskId, n); }
|
if (n !== null) { markSeen(taskId, n); syncBadge(taskId, n); }
|
||||||
@@ -294,10 +397,28 @@
|
|||||||
el("pj-cm-send").addEventListener("click", async function () {
|
el("pj-cm-send").addEventListener("click", async function () {
|
||||||
const taskId = el("pj-cm-task-id").value;
|
const taskId = el("pj-cm-task-id").value;
|
||||||
const inp = el("pj-cm-input");
|
const inp = el("pj-cm-input");
|
||||||
const body = inp.value.trim();
|
const bodyText = inp.value.trim();
|
||||||
if (!taskId || !body) return;
|
if (!taskId || (!bodyText && !pendingFiles.length)) return;
|
||||||
|
const filesToSend = pendingFiles.slice();
|
||||||
try {
|
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 = "";
|
inp.value = "";
|
||||||
const n = await renderChat(taskId);
|
const n = await renderChat(taskId);
|
||||||
if (n !== null) { markSeen(taskId, n); syncBadge(taskId, n); }
|
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.className = "pj-col" + (s.is_done_stage ? " pj-col-done" : "");
|
||||||
col.dataset.stageId = s.id;
|
col.dataset.stageId = s.id;
|
||||||
col.innerHTML =
|
col.innerHTML =
|
||||||
'<div class="pj-col-head"' + (canManage ? ' draggable="true"' : "") + '>' +
|
'<div class="pj-col-head' + (s.color ? " pj-col-head-colored" : "") + '"' +
|
||||||
(s.color ? '<span class="pj-col-dot" style="background:' + s.color + '"></span>' : "") +
|
(canManage ? ' draggable="true"' : "") +
|
||||||
|
(s.color ? ' style="background:' + s.color + '"' : "") + '>' +
|
||||||
'<span class="pj-col-name">' + escapeHtml(s.name) + "</span>" +
|
'<span class="pj-col-name">' + escapeHtml(s.name) + "</span>" +
|
||||||
(s.is_done_stage ? '<span class="pj-chip pj-chip-sm">완료</span>' : "") +
|
(s.is_done_stage ? '<span class="pj-chip pj-chip-sm">완료</span>' : "") +
|
||||||
'<span class="pj-col-count"></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>"
|
? '<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.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>";
|
"</div>";
|
||||||
card.addEventListener("click", function () { if (canManage) openTaskModal(t); });
|
card.addEventListener("click", function () { if (canManage) openTaskModal(t); });
|
||||||
|
// 댓글 말풍선 더블클릭 → 채팅형 댓글 팝업(내 업무 패널과 동일 구현 공용).
|
||||||
|
if (cmt) cmt.wireBadge(card.querySelector(".pj-cmt-badge"));
|
||||||
if (canManage) {
|
if (canManage) {
|
||||||
card.addEventListener("dragstart", function (e) {
|
card.addEventListener("dragstart", function (e) {
|
||||||
e.dataTransfer.setData("text/plain", String(t.id));
|
e.dataTransfer.setData("text/plain", String(t.id));
|
||||||
|
|||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user