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:
+191
-5
@@ -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();
|
||||
});
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user