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
+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();
});
})();