feat(project): 홈 화면 업무추가·멤버표시·댓글팝업·실시간 새로고침 추가

- 카드 이름 옆 호버 노출 업무 추가 버튼(기존 업무편집 팝업을 생성 모드로 재사용)
- 카드 이름 아래 배정 멤버 아이콘+이름 표시, 새 프로젝트 모달에서 멤버 다중 선택
- 업무별 댓글 말풍선+개수 표시, 더블클릭 시 카톡형 댓글 전용 팝업
- 확인 안 한 새 댓글은 말풍선이 커지며 반짝임(localStorage 로 확인 여부 추적)
- GET /project/api/live-version 폴링으로 다른 사용자의 변경사항을 자동 새로고침
- 날짜 표기를 yy/mm/dd(요일)에서 mm/dd(요일)로 변경

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 13:03:02 +09:00
parent 1aad9844dd
commit f54344c3ef
8 changed files with 344 additions and 27 deletions
+17
View File
@@ -716,6 +716,23 @@ class ProjectStore:
)
return cur.rowcount or 0
# ════════════════════════════════════════════════════════════
# 실시간 새로고침 — 프로젝트/업무/댓글/멤버/단계 중 가장 최근 변경 시각
# ════════════════════════════════════════════════════════════
def latest_version(self) -> str:
with self._pool.connection() as conn:
row = conn.execute(
"SELECT GREATEST("
" COALESCE((SELECT MAX(updated_at) FROM projects), 'epoch'::timestamptz),"
" COALESCE((SELECT MAX(updated_at) FROM tasks), 'epoch'::timestamptz),"
" COALESCE((SELECT MAX(created_at) FROM task_comments), 'epoch'::timestamptz),"
" COALESCE((SELECT MAX(created_at) FROM project_members), 'epoch'::timestamptz),"
" COALESCE((SELECT MAX(created_at) FROM project_stages), 'epoch'::timestamptz)"
") AS ts"
).fetchone()
ts = row["ts"] if row else None
return ts.isoformat() if ts else ""
# ════════════════════════════════════════════════════════════
# 직렬화
# ════════════════════════════════════════════════════════════
+32 -2
View File
@@ -195,14 +195,14 @@ _WEEKDAYS_KR = ["월", "화", "수", "목", "금", "토", "일"]
def _fmt_date_kr(d: str | None) -> str:
"""'YYYY-MM-DD''yy/mm/dd(요일)'. 형식이 아니면 원본 그대로."""
"""'YYYY-MM-DD''mm/dd(요일)'. 형식이 아니면 원본 그대로."""
if not d:
return ""
try:
dd = date.fromisoformat(d[:10])
except (ValueError, TypeError):
return d
return dd.strftime("%y/%m/%d") + f"({_WEEKDAYS_KR[dd.weekday()]})"
return dd.strftime("%m/%d") + f"({_WEEKDAYS_KR[dd.weekday()]})"
def _build_tree(projects: list[dict[str, Any]]) -> list[dict[str, Any]]:
@@ -335,6 +335,18 @@ async def health(request: Request) -> JSONResponse:
return JSONResponse({"status": "error", "detail": str(exc)}, status_code=500)
@router.get("/api/live-version")
async def api_live_version(request: Request) -> JSONResponse:
"""프로젝트/업무/댓글/멤버/단계 중 가장 최근 변경 시각.
페이지를 열어둔 클라이언트가 주기적으로 조회해 값이 바뀌면 새로고침한다
(다른 사용자가 프로젝트·업무·댓글을 추가/수정했을 때 실시간 반영용).
"""
_require_user(request)
st = _db_or_503(request)
return JSONResponse({"ts": st.latest_version()})
@router.get("/", response_class=HTMLResponse)
async def index(request: Request) -> HTMLResponse:
from app.main import build_erp_nav, render_template # noqa: WPS433
@@ -389,6 +401,24 @@ async def index(request: Request) -> HTMLResponse:
p["task_more"] = max(0, len(ordered_tasks) - 8)
p["due_label"] = _fmt_date_kr(p.get("due_date"))
# 프로젝트 이름 아래 표시할 멤버(아이콘+이름) + 업무추가 버튼 노출 권한
# (관리자 또는 해당 프로젝트 멤버/owner).
p_members = st.list_members(project_id=p["id"])
p["members"] = [
{
"email": m["user_email"],
"name": m.get("user_name") or m["user_email"].split("@")[0],
"avatar": avatars.get(store.norm_str(m["user_email"], lower=True), ""),
}
for m in p_members
]
member_emails = {store.norm_str(m["user_email"], lower=True) for m in p_members}
p["can_add_task"] = (
admin
or store.norm_str(user["email"], lower=True) in member_emails
or store.norm_str(p.get("owner_email"), lower=True) == store.norm_str(user["email"], lower=True)
)
my_tasks = st.list_tasks(assignee_email=user["email"], limit=500)
for t in my_tasks:
t["start_label"] = _fmt_date_kr(t.get("start_date"))
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/project.css?v=20260915e" />
<link rel="stylesheet" href="/static/project.css?v=20260915f" />
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
{% endblock %}
@@ -45,5 +45,5 @@
{% endif %}
</div>
<script src="/static/project.js?v=20260915d" defer></script>
<script src="/static/project.js?v=20260915e" defer></script>
{% endblock %}
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/project.css?v=20260915e" />
<link rel="stylesheet" href="/static/project.css?v=20260915f" />
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
{% endblock %}
@@ -28,6 +28,17 @@
{% if p.status == 'completed' %}<span class="pj-chip pj-chip-sm">완료</span>{% endif %}
</div>
{% if p.description %}<div class="pj-project-desc">{{ p.description }}</div>{% endif %}
{% if p.members %}
<div class="pj-project-members">
{% for m in p.members %}
<span class="pj-project-member">
{% if m.avatar %}<img class="pj-avatar pj-gicon-sm" src="{{ m.avatar }}" alt="" />
{% else %}<span class="material-symbols-outlined pj-gicon pj-gicon-sm">account_circle</span>{% endif %}
{{ m.name }}
</span>
{% endfor %}
</div>
{% endif %}
<div class="pj-project-meta">
{% if p.due_date %}<span class="pj-chip">마감 {{ p.due_label }}</span>{% endif %}
{% if p.task_total %}<span class="pj-chip">업무 {{ p.task_done }}/{{ p.task_total }}</span>{% endif %}
@@ -39,11 +50,18 @@
{% endif %}
</div>
</a>
{% if is_admin %}
<button type="button" class="pj-icon-btn pj-card-edit" title="프로젝트 수정">
<span class="material-symbols-outlined">edit</span>
</button>
{% endif %}
<div class="pj-card-actions">
{% if p.can_add_task %}
<button type="button" class="pj-icon-btn pj-card-addtask" data-project-id="{{ p.id }}" title="업무 추가">
<span class="material-symbols-outlined">add_task</span>
</button>
{% endif %}
{% if is_admin %}
<button type="button" class="pj-icon-btn pj-card-edit" title="프로젝트 수정">
<span class="material-symbols-outlined">edit</span>
</button>
{% endif %}
</div>
{% if p.task_preview %}
<ul class="pj-project-tasklist">
@@ -64,6 +82,11 @@
{% if t.due_date %}
<span class="pj-project-task-due {% if t.due_overdue %}is-overdue{% endif %}">{{ t.due_label }}</span>
{% endif %}
{% if t.comment_count %}
<span class="pj-cmt-badge" data-task-id="{{ t.id }}" data-count="{{ t.comment_count }}" title="댓글 {{ t.comment_count }}개">
<span class="pj-cmt-ic">💬</span><span class="pj-cmt-n">{{ t.comment_count }}</span>
</span>
{% endif %}
</a>
</li>
{% endfor %}
@@ -150,6 +173,11 @@
</span>
{% endif %}
</span>
{% if t.comment_count %}
<span class="pj-cmt-badge" data-task-id="{{ t.id }}" data-count="{{ t.comment_count }}" title="댓글 {{ t.comment_count }}개">
<span class="pj-cmt-ic">💬</span><span class="pj-cmt-n">{{ t.comment_count }}</span>
</span>
{% endif %}
</a>
</li>
{% endfor %}
@@ -176,6 +204,9 @@
<label>색상
<span class="pj-color-palette" id="pj-f-colors"></span>
</label>
<div class="pj-field-label">멤버
<div class="pj-member-picker" id="pj-f-members">불러오는 중…</div>
</div>
<div class="pj-modal-actions">
<button type="button" class="pj-btn" data-close>취소</button>
<button type="button" class="pj-btn pj-btn-primary" id="pj-save-project">만들기</button>
@@ -273,11 +304,24 @@
</div>
</div>
<!-- 댓글만 보는 팝업 (업무 카드의 말풍선 아이콘 더블클릭) -->
<div class="pj-modal" id="pj-modal-comments" hidden>
<div class="pj-modal-card pj-modal-chat">
<h3>댓글</h3>
<input type="hidden" id="pj-cm-task-id" />
<ul class="pj-chat-list" id="pj-chat-list"></ul>
<div class="pj-comment-form">
<input type="text" id="pj-cm-input" placeholder="댓글 입력…" />
<button type="button" class="pj-btn pj-btn-primary" id="pj-cm-send">전송</button>
</div>
</div>
</div>
<script id="pj-home-data" type="application/json">
{{ home_tasks | tojson }}
</script>
<script>
window.PJ_COLORS = {{ ["#4573d2","#37a3a3","#62a420","#e8a33d","#e8384f","#aa62e3","#f06a6a","#5a6772"] | tojson }};
</script>
<script src="/static/project.js?v=20260915d" defer></script>
<script src="/static/project.js?v=20260915e" defer></script>
{% endblock %}
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/project.css?v=20260915e" />
<link rel="stylesheet" href="/static/project.css?v=20260915f" />
<!-- 구글 머티리얼 심볼(담당자 아이콘 등) — self-host -->
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
<!-- 타임라인 vis-timeline — self-host -->
@@ -291,5 +291,5 @@
</script>
<script src="/static/vendor/vis-timeline/vis-timeline-graph2d.min.js"></script>
<script src="/static/project.js?v=20260915d" defer></script>
<script src="/static/project.js?v=20260915e" defer></script>
{% endblock %}
+41 -7
View File
@@ -44,18 +44,24 @@
.pj-project-card.is-done { background: #f6f7f8; opacity: .62; }
.pj-project-card.is-done:hover { opacity: .85; }
.pj-project-card.is-done .pj-project-dot { background: #9aa5b1; }
/* 카드 수정 버튼 */
.pj-card-edit {
position: absolute; top: 8px; right: 8px; width: 28px; height: 28px;
/* 카드 우측 상단 액션(업무 추가/수정) — 호버 시에만 노출 */
.pj-card-actions {
position: absolute; top: 8px; right: 8px; display: flex; gap: 2px;
}
.pj-card-actions .pj-icon-btn {
width: 28px; height: 28px;
display: inline-flex; align-items: center; justify-content: center;
border-radius: 8px; color: #6b7280; opacity: 0; transition: opacity .12s;
}
.pj-card-edit .material-symbols-outlined { font-size: 18px; }
.pj-project-card:hover .pj-card-edit { opacity: 1; }
.pj-card-edit:hover { background: #eceef0; color: #1e1f21; }
.pj-card-actions .pj-icon-btn .material-symbols-outlined { font-size: 18px; }
.pj-project-card:hover .pj-card-actions .pj-icon-btn { opacity: 1; }
.pj-card-actions .pj-icon-btn:hover { background: #eceef0; color: #1e1f21; }
.pj-project-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--pj-color, #4573d2); margin-top: 5px; flex: 0 0 auto; }
.pj-project-name { font-weight: 700; font-size: 15px; }
.pj-project-desc { color: #6b7280; font-size: 12.5px; margin-top: 3px; }
/* 프로젝트 이름 아래 — 배정 멤버(아이콘+이름) */
.pj-project-members { display: flex; flex-wrap: wrap; gap: 3px 10px; margin-top: 6px; }
.pj-project-member { display: inline-flex; align-items: center; gap: 4px; font-size: 11.5px; color: #6b7280; }
.pj-project-meta { margin-top: 10px; display: flex; gap: 6px; flex-wrap: wrap; }
.pj-project-progress { margin-top: 10px; height: 5px; border-radius: 999px; background: #eef0f2; overflow: hidden; }
.pj-project-progress-bar { height: 100%; border-radius: 999px; background: var(--pj-color, #4573d2); }
@@ -326,9 +332,37 @@
.pj-card-proj { font-size: 11px; font-weight: 700; margin-bottom: 3px; letter-spacing: -.2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* 댓글 말풍선(💬 이모지) 표시 */
.pj-cmt-ic { font-size: 12px; vertical-align: middle; margin-left: 4px; line-height: 1; }
.pj-cmt-ic { font-size: 12px; vertical-align: middle; margin-left: 4px; line-height: 1; display: inline-block; }
.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-cmt-badge { display: inline-flex; align-items: center; flex: 0 0 auto; color: #6b7280; cursor: pointer; }
/* 새 댓글이 등록된 뒤 아직 확인 안 한 업무 — 살짝 커지며 반짝임. 확인하면(더블클릭) 원래대로. */
@keyframes pj-cmt-pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.5); opacity: .55; }
}
.pj-cmt-badge.is-unread .pj-cmt-ic { animation: pj-cmt-pulse 1.1s ease-in-out infinite; }
.pj-cmt-badge.is-unread .pj-cmt-n { color: #e8384f; font-weight: 800; }
/* ── 새 프로젝트 모달 — 멤버 선택 ── */
.pj-modal-card .pj-field-label { display: block; font-size: 12.5px; font-weight: 600; color: #525860; margin-bottom: 12px; }
.pj-member-picker { display: flex; flex-wrap: wrap; gap: 4px 12px; margin-top: 5px; max-height: 140px; overflow-y: auto; padding: 6px 2px; border: 1px solid #eef0f2; border-radius: 8px; }
.pj-member-pick { display: inline-flex !important; align-items: center; gap: 5px; font-size: 12.5px; font-weight: 500 !important; color: #333; width: auto !important; margin: 0 !important; }
.pj-member-pick input { width: auto !important; margin: 0 !important; }
/* ── 댓글만 보는 채팅형 팝업(카톡 대화창 느낌) ── */
.pj-modal-chat { width: min(420px, 92vw); display: flex; flex-direction: column; }
.pj-chat-list { list-style: none; margin: 0 0 10px; padding: 4px; max-height: 420px; min-height: 120px; overflow-y: auto; background: #eef0f2; border-radius: 10px; display: flex; flex-direction: column; gap: 8px; }
.pj-chat-msg { display: flex; flex-direction: column; align-items: flex-start; max-width: 78%; }
.pj-chat-msg.mine { align-self: flex-end; align-items: flex-end; }
.pj-chat-name { font-size: 11px; color: #6b7280; margin-bottom: 2px; padding: 0 2px; }
.pj-chat-bubble {
background: #fff; border-radius: 14px 14px 14px 4px; padding: 8px 12px;
font-size: 13.5px; color: #1e1f21; white-space: pre-wrap; word-break: break-word;
box-shadow: 0 1px 2px rgba(0,0,0,.06);
}
.pj-chat-msg.mine .pj-chat-bubble { background: #fee500; border-radius: 14px 14px 4px 14px; }
.pj-chat-time { font-size: 10px; color: #9aa1a9; margin-top: 3px; padding: 0 2px; }
/* ── 보드 단계 컨트롤 ── */
.pj-col-head { position: relative; }
+191 -5
View File
@@ -23,7 +23,13 @@
function el(id) { return document.getElementById(id); }
function openModal(m) { if (m) m.hidden = false; }
function closeModal(m) { if (m) m.hidden = true; }
// 실시간 새로고침 대기 중이던 변경사항 — 모든 모달이 닫힌 시점에 반영(입력 중 방해 방지).
let pjPendingReload = false;
function anyModalOpen() { return !!document.querySelector(".pj-modal:not([hidden])"); }
function closeModal(m) {
if (m) m.hidden = true;
if (pjPendingReload && !anyModalOpen()) location.reload();
}
function wireModalClose(modal) {
if (!modal) return;
@@ -98,18 +104,42 @@
if (newBtn && modal) {
wireModalClose(modal);
const getColor = renderColorPalette(el("pj-f-colors"));
newBtn.addEventListener("click", function () { openModal(modal); });
const membersBox = el("pj-f-members");
newBtn.addEventListener("click", async function () {
openModal(modal);
if (membersBox) {
membersBox.innerHTML = "불러오는 중…";
try {
const res = await api("GET", "/project/api/assignable-users");
const users = res.users || [];
membersBox.innerHTML = users.length ? users.map(function (u) {
return '<label class="pj-member-pick"><input type="checkbox" value="' + u.email + '" /> ' +
escapeHtml(u.id) + "</label>";
}).join("") : "<span class='pj-empty-sm'>배정 가능한 직원이 없습니다.</span>";
} catch (e) {
membersBox.innerHTML = "<span class='pj-empty-sm'>불러오기 실패</span>";
}
}
});
el("pj-save-project").addEventListener("click", async function () {
const name = el("pj-f-name").value.trim();
if (!name) { alert("이름을 입력하세요."); return; }
const memberEmails = membersBox
? Array.from(membersBox.querySelectorAll("input[type=checkbox]:checked")).map(function (c) { return c.value; })
: [];
try {
await api("POST", "/project/api/projects", {
const res = await api("POST", "/project/api/projects", {
name: name,
description: el("pj-f-desc").value.trim(),
start_date: el("pj-f-start").value || null,
due_date: el("pj-f-due").value || null,
color: getColor(),
});
const pid = res.project.id;
for (const email of memberEmails) {
try { await api("POST", "/project/api/projects/" + pid + "/members", { user_email: email }); }
catch (_) {}
}
location.reload();
} catch (e) { alert("생성 실패: " + e.message); }
});
@@ -283,6 +313,10 @@
el("pj-t-stime").disabled = !useTime;
el("pj-t-dtime").disabled = !useTime;
el("pj-t-open-project").href = "/project/p/" + task.project_id + "?task=" + task.id;
el("pj-delete-task").hidden = false;
el("pj-t-open-project").hidden = false;
el("pj-attach-block").hidden = false;
el("pj-comment-block").hidden = false;
openModal(homeTaskModal);
await Promise.all([
fillAssignee(task.project_id, task.assignee_email || ""),
@@ -292,6 +326,33 @@
loadHomeComments(task.id);
}
// 카드 이름 옆 "업무 추가" 버튼 — 같은 모달을 새 업무 작성용으로 초기화해 연다.
async function openHomeNewTaskModal(projectId) {
el("pj-task-modal-title").textContent = "새 업무 추가";
el("pj-t-id").value = "";
el("pj-t-project-id").value = projectId;
el("pj-t-title").value = "";
el("pj-t-desc").value = "";
el("pj-t-priority").value = "normal";
el("pj-t-start").value = "";
el("pj-t-due").value = "";
el("pj-t-usetime").checked = false;
el("pj-t-stime").value = ""; el("pj-t-stime").disabled = true;
el("pj-t-dtime").value = ""; el("pj-t-dtime").disabled = true;
el("pj-delete-task").hidden = true;
el("pj-t-open-project").hidden = true;
el("pj-attach-block").hidden = true;
el("pj-comment-block").hidden = true;
openModal(homeTaskModal);
await Promise.all([fillAssignee(projectId, ""), fillStage(projectId, null)]);
}
document.querySelectorAll(".pj-card-addtask").forEach(function (btn) {
btn.addEventListener("click", function (e) {
e.preventDefault(); e.stopPropagation();
openHomeNewTaskModal(btn.dataset.projectId);
});
});
el("pj-t-usetime").addEventListener("change", function () {
el("pj-t-stime").disabled = !this.checked;
el("pj-t-dtime").disabled = !this.checked;
@@ -303,7 +364,7 @@
el("pj-save-task").addEventListener("click", async function () {
const id = el("pj-t-id").value;
if (!id) return;
const pid = el("pj-t-project-id").value;
const assigneeSel = el("pj-t-assignee");
const assigneeName = assigneeSel.selectedOptions[0] ? (assigneeSel.selectedOptions[0].dataset.name || "") : "";
const useTime = el("pj-t-usetime").checked;
@@ -321,7 +382,12 @@
};
if (!payload.title) { alert("제목을 입력하세요."); return; }
try {
await api("PUT", "/project/api/tasks/" + id, payload);
if (id) {
await api("PUT", "/project/api/tasks/" + id, payload);
} else {
if (!pid) return;
await api("POST", "/project/api/projects/" + pid + "/tasks", payload);
}
location.reload();
} catch (e) { alert("저장 실패: " + e.message); }
});
@@ -343,6 +409,100 @@
});
});
}
// ── 댓글 말풍선(카드/내 업무) — 더블클릭 시 댓글만 보는 채팅형 팝업 ──
const commentsModal = el("pj-modal-comments");
if (commentsModal) {
wireModalClose(commentsModal);
function loadSeenComments() {
try { return JSON.parse(localStorage.getItem("pj_seen_comments") || "{}"); }
catch (_) { return {}; }
}
function markSeen(taskId, count) {
const m = loadSeenComments();
m[taskId] = count;
try { localStorage.setItem("pj_seen_comments", JSON.stringify(m)); } catch (_) {}
}
// 페이지 로드 시 — 마지막으로 확인한 개수보다 늘었으면 반짝임(is-unread) 표시.
const seen = loadSeenComments();
document.querySelectorAll(".pj-cmt-badge").forEach(function (b) {
const n = parseInt(b.dataset.count, 10) || 0;
const seenN = seen[b.dataset.taskId] || 0;
if (n > seenN) b.classList.add("is-unread");
});
async function renderChat(taskId) {
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 || [];
if (!comments.length) {
list.innerHTML = "<li class='pj-empty-sm'>댓글 없음</li>";
} else {
list.innerHTML = "";
comments.forEach(function (c) {
const mine = (c.author_email || "").toLowerCase() === me;
const li = document.createElement("li");
li.className = "pj-chat-msg" + (mine ? " mine" : "");
li.innerHTML =
(mine ? "" : '<span class="pj-chat-name">' + escapeHtml(idOf(c.author_email)) + "</span>") +
'<div class="pj-chat-bubble"></div>' +
'<span class="pj-chat-time">' + (c.created_at || "").slice(0, 16).replace("T", " ") + "</span>";
li.querySelector(".pj-chat-bubble").textContent = c.body;
list.appendChild(li);
});
list.scrollTop = list.scrollHeight;
}
return comments.length;
} catch (e) {
list.innerHTML = "<li class='pj-empty-sm'>불러오기 실패</li>";
return null;
}
}
function syncBadge(taskId, n) {
const badge = document.querySelector('.pj-cmt-badge[data-task-id="' + taskId + '"]');
if (!badge) return;
badge.dataset.count = n;
badge.title = "댓글 " + n + "개";
const numEl = badge.querySelector(".pj-cmt-n");
if (numEl) numEl.textContent = n;
badge.classList.remove("is-unread");
}
async function openCommentsModal(taskId) {
el("pj-cm-task-id").value = taskId;
openModal(commentsModal);
const n = await renderChat(taskId);
if (n !== null) { markSeen(taskId, n); syncBadge(taskId, n); }
}
document.querySelectorAll(".pj-cmt-badge").forEach(function (b) {
b.addEventListener("click", function (e) { e.preventDefault(); e.stopPropagation(); });
b.addEventListener("dblclick", function (e) {
e.preventDefault(); e.stopPropagation();
openCommentsModal(b.dataset.taskId);
});
});
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;
try {
await api("POST", "/project/api/tasks/" + taskId + "/comments", { body: body });
inp.value = "";
const n = await renderChat(taskId);
if (n !== null) { markSeen(taskId, n); syncBadge(taskId, n); }
} catch (e) { alert("등록 실패: " + e.message); }
});
el("pj-cm-input").addEventListener("keydown", function (e) {
if (e.key === "Enter") { e.preventDefault(); el("pj-cm-send").click(); }
});
}
}
// ════════════════════════════════════════════════════════════
@@ -1430,10 +1590,36 @@
});
}
// ── 실시간 새로고침 ──
// 다른 사용자가 프로젝트/업무/댓글/멤버/단계를 추가·수정하면, 같은 화면을 열어둔
// 모든 사용자가 자동으로 새로고침되도록 주기적으로 서버 버전을 확인한다.
// 모달을 열어 입력 중이면 방해하지 않고, 모달이 모두 닫힐 때(closeModal) 반영한다.
function initLiveRefresh() {
if (!document.querySelector(".pj-wrap, .pj-project-view")) return;
let baseline = null;
async function poll() {
try {
const res = await fetch("/project/api/live-version");
if (res.ok) {
const d = await res.json();
if (baseline === null) {
baseline = d.ts;
} else if (d.ts !== baseline) {
if (anyModalOpen()) { pjPendingReload = true; }
else { location.reload(); return; }
}
}
} catch (_) {}
setTimeout(poll, 8000);
}
setTimeout(poll, 8000);
}
document.addEventListener("DOMContentLoaded", function () {
initHome();
initProject();
initBell();
initInbox();
initLiveRefresh();
});
})();