feat(project): 댓글 있는 업무에 말풍선 아이콘 표시(달력·타임라인·보드·리스트)

- list_tasks/get_task 에 comment_count 서브쿼리 추가
- 달력 막대/타임라인 항목/보드 카드/리스트 업무열에 댓글 수 말풍선
- 댓글 로드 시 메모리 카운트 동기화

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-25 17:17:48 +09:00
parent e817dd75d5
commit e4fb945474
5 changed files with 30 additions and 9 deletions
+4 -2
View File
@@ -297,7 +297,8 @@ class ProjectStore:
with self._pool.connection() as conn:
rows = conn.execute(
f"SELECT t.*, p.name AS project_name, p.color AS project_color, "
f" s.name AS stage_name, s.is_done_stage "
f" s.name AS stage_name, s.is_done_stage, "
f" (SELECT COUNT(*) FROM task_comments c WHERE c.task_id = t.id) AS comment_count "
f"FROM tasks t "
f"JOIN projects p ON p.id = t.project_id "
f"LEFT JOIN project_stages s ON s.id = t.stage_id "
@@ -311,7 +312,8 @@ class ProjectStore:
with self._pool.connection() as conn:
row = conn.execute(
"SELECT t.*, p.name AS project_name, p.color AS project_color, "
" s.name AS stage_name, s.is_done_stage "
" s.name AS stage_name, s.is_done_stage, "
" (SELECT COUNT(*) FROM task_comments c WHERE c.task_id = t.id) AS comment_count "
"FROM tasks t JOIN projects p ON p.id = t.project_id "
"LEFT JOIN project_stages s ON s.id = t.stage_id "
"WHERE t.id = %s",
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/project.css?v=20260625w" />
<link rel="stylesheet" href="/static/project.css?v=20260625x" />
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
{% endblock %}
@@ -167,5 +167,5 @@
<script>
window.PJ_COLORS = {{ ["#4573d2","#37a3a3","#62a420","#e8a33d","#e8384f","#aa62e3","#f06a6a","#5a6772"] | tojson }};
</script>
<script src="/static/project.js?v=20260625w" defer></script>
<script src="/static/project.js?v=20260625x" defer></script>
{% endblock %}
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/project.css?v=20260625w" />
<link rel="stylesheet" href="/static/project.css?v=20260625x" />
<!-- 구글 머티리얼 심볼(담당자 아이콘 등) — self-host -->
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
<!-- 타임라인 vis-timeline — self-host -->
@@ -290,5 +290,5 @@
</script>
<script src="/static/vendor/vis-timeline/vis-timeline-graph2d.min.js"></script>
<script src="/static/project.js?v=20260625w" defer></script>
<script src="/static/project.js?v=20260625x" defer></script>
{% endblock %}
+6
View File
@@ -286,6 +286,12 @@
/* 보드 카드 — 전체 뷰에서 어느 프로젝트인지 배지 표시 */
.pj-card-proj { font-size: 11px; font-weight: 700; margin-bottom: 3px; letter-spacing: -.2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* 댓글 말풍선 표시 — 색은 currentColor 상속 */
.material-symbols-outlined.pj-cmt-ic { font-size: 14px; vertical-align: middle; margin-left: 4px; line-height: 1; }
.material-symbols-outlined.pj-cmt-ic.pj-cmt-ic-tl { font-size: 12px; margin-left: 3px; }
.pj-cmt-n { font-size: 11px; font-weight: 700; vertical-align: middle; margin-left: 1px; }
.pj-card-cmt { display: inline-flex; align-items: center; color: #6b7280; }
/* ── 보드 단계 컨트롤 ── */
.pj-col-head { position: relative; }
.pj-col-name { font-weight: 700; }
+16 -3
View File
@@ -175,6 +175,13 @@
return !!cb && cb === me;
}
const DELETE_DENIED = "삭제는 만든 사람만 할 수 있습니다.";
// 댓글 있으면 말풍선 아이콘(+개수). 색은 currentColor 상속(막대=흰, 카드/리스트=진회색).
function cmtBadge(t, cls) {
const n = t.comment_count || 0;
if (!n) return "";
return '<span class="material-symbols-outlined pj-cmt-ic ' + (cls || "") +
'" title="댓글 ' + n + '개">chat_bubble</span><span class="pj-cmt-n">' + n + "</span>";
}
const dataEl = el("pj-data");
const data = JSON.parse(dataEl.textContent);
@@ -326,7 +333,7 @@
div.style.background = b.t.completed_at ? "#9aa5b1" : (b.t.project_color || "#4573d2");
div.title = "[" + (b.t.project_name || "") + "] " + (b.t.title || "") +
(b.t.assignee_email ? " · " + idOf(b.t.assignee_email) : "");
div.innerHTML = '<span class="pj-bar-label">' + escapeHtml(b.t.title || "") + "</span>";
div.innerHTML = '<span class="pj-bar-label">' + escapeHtml(b.t.title || "") + "</span>" + cmtBadge(b.t);
cont.appendChild(div);
});
for (var col = 0; col < 7; col++) {
@@ -484,7 +491,7 @@
const endISO = e + (t.due_time ? "T" + t.due_time : "");
const isRange = (e !== s) || (!!t.start_time && !!t.due_time && t.start_time !== t.due_time);
items.push({
id: t.id, group: grp, content: t.title,
id: t.id, group: grp, content: escapeHtml(t.title || "") + cmtBadge(t, "pj-cmt-ic-tl"),
start: startISO, end: isRange ? endISO : undefined,
type: isRange ? "range" : "point",
style: "background-color:" + (t.completed_at ? "#9aa5b1" : projColor) + ";color:#fff;border:none;",
@@ -613,6 +620,7 @@
escapeHtml(idOf(t.assignee_email)) + "</span>"
: "") +
(t.due_date ? '<span class="pj-card-due">' + t.due_date + "</span>" : "") +
(t.comment_count ? '<span class="pj-card-cmt">' + cmtBadge(t) + "</span>" : "") +
"</div>";
card.addEventListener("click", function () { if (canManage) openTaskModal(t); });
if (canManage) {
@@ -724,7 +732,9 @@
const pc = t.project_color || "#4573d2";
tr.style.background = pc + "14";
tr.innerHTML = LIST_COLS.map(function (c) {
return "<td>" + escapeHtml(String(c.text(t))) + "</td>";
let inner = escapeHtml(String(c.text(t)));
if (c.key === "title") inner += cmtBadge(t);
return "<td>" + inner + "</td>";
}).join("");
if (canManage) { tr.style.cursor = "pointer"; tr.addEventListener("click", function () { openTaskModal(t); }); }
tbody.appendChild(tr);
@@ -833,6 +843,9 @@
ul.innerHTML = "<li class='pj-empty-sm'>불러오는 중…</li>";
try {
const res = await api("GET", "/project/api/tasks/" + taskId + "/comments");
// 메모리 업무의 댓글 수 동기화(아이콘 표시용)
const tt = tasks.find(function (x) { return String(x.id) === String(taskId); });
if (tt) tt.comment_count = res.comments.length;
if (!res.comments.length) { ul.innerHTML = "<li class='pj-empty-sm'>댓글 없음</li>"; return; }
ul.innerHTML = "";
res.comments.forEach(function (c) {