feat(project): 댓글·첨부·단계편집·알림센터 + 라이브러리 self-host
아사나식 기능 4종 추가: - 댓글: 업무 모달에서 등록/삭제, 관련자 인앱 알림(task_comments) - 첨부: 업로드(20MB)/다운로드/삭제, 파일은 DATA_DIR/project/<task_id>/, DB엔 메타만(task_attachments) - 단계 편집: 보드 칸반에서 이름변경/완료토글/순서이동/삭제/추가 - 알림센터(인앱): 배정·완료·댓글 시 수신자별 알림, 벨 미읽음 배지, /project/inbox 페이지, 읽음/모두읽음(project_notifications) 라이브러리 self-host: vis-timeline + Material Symbols 를 static/vendor/ 로 내려받아 CDN 의존 제거. DB: scripts/sql/project_db_002_attachments_notifications.sql (서버서 적용 필요). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+197
-5
@@ -171,26 +171,90 @@
|
||||
});
|
||||
}
|
||||
|
||||
// ── 보드 (칸반) ──
|
||||
// ── 보드 (칸반) + 단계 편집 ──
|
||||
function renderBoard() {
|
||||
const board = el("pj-board");
|
||||
board.innerHTML = "";
|
||||
stages.forEach(function (stage) {
|
||||
stages.forEach(function (stage, idx) {
|
||||
const col = document.createElement("div");
|
||||
col.className = "pj-col";
|
||||
col.dataset.stageId = stage.id;
|
||||
let controls = "";
|
||||
if (canManage) {
|
||||
controls =
|
||||
'<span class="pj-col-ctrls">' +
|
||||
'<button type="button" class="pj-icon-btn pj-st-move" data-dir="-1" title="왼쪽으로"' + (idx === 0 ? " disabled" : "") + '>‹</button>' +
|
||||
'<button type="button" class="pj-icon-btn pj-st-move" data-dir="1" title="오른쪽으로"' + (idx === stages.length - 1 ? " disabled" : "") + '>›</button>' +
|
||||
'<button type="button" class="pj-icon-btn pj-st-rename" title="이름 변경">✎</button>' +
|
||||
'<button type="button" class="pj-icon-btn pj-st-done" title="완료단계 토글">✓</button>' +
|
||||
'<button type="button" class="pj-icon-btn pj-st-del" title="단계 삭제">×</button>' +
|
||||
"</span>";
|
||||
}
|
||||
col.innerHTML =
|
||||
'<div class="pj-col-head">' + escapeHtml(stage.name) +
|
||||
'<div class="pj-col-head"><span class="pj-col-name">' + escapeHtml(stage.name) + "</span>" +
|
||||
(stage.is_done_stage ? ' <span class="pj-chip pj-chip-sm">완료</span>' : "") +
|
||||
'<span class="pj-col-count"></span></div>' +
|
||||
'<span class="pj-col-count"></span>' + controls + "</div>" +
|
||||
'<div class="pj-col-body"></div>';
|
||||
const body = col.querySelector(".pj-col-body");
|
||||
const colTasks = tasks.filter(function (t) { return t.stage_id === stage.id; });
|
||||
col.querySelector(".pj-col-count").textContent = colTasks.length;
|
||||
colTasks.forEach(function (t) { body.appendChild(taskCard(t)); });
|
||||
if (canManage) enableDrop(body, stage.id);
|
||||
if (canManage) {
|
||||
enableDrop(body, stage.id);
|
||||
wireStageControls(col, stage, idx);
|
||||
}
|
||||
board.appendChild(col);
|
||||
});
|
||||
if (canManage) {
|
||||
const add = document.createElement("div");
|
||||
add.className = "pj-col pj-col-add";
|
||||
add.innerHTML = '<button type="button" class="pj-add-stage" id="pj-add-stage">+ 단계 추가</button>';
|
||||
add.querySelector("#pj-add-stage").addEventListener("click", addStage);
|
||||
board.appendChild(add);
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadStages() {
|
||||
const res = await api("GET", "/project/api/projects/" + projectId + "/stages");
|
||||
stages = res.stages;
|
||||
}
|
||||
|
||||
function wireStageControls(col, stage, idx) {
|
||||
col.querySelectorAll(".pj-st-move").forEach(function (b) {
|
||||
b.addEventListener("click", async function () {
|
||||
const dir = parseInt(b.dataset.dir, 10);
|
||||
const j = idx + dir;
|
||||
if (j < 0 || j >= stages.length) return;
|
||||
const ids = stages.map(function (s) { return s.id; });
|
||||
const tmp = ids[idx]; ids[idx] = ids[j]; ids[j] = tmp;
|
||||
try {
|
||||
const res = await api("PUT", "/project/api/projects/" + projectId + "/stages/order", { ordered_ids: ids });
|
||||
stages = res.stages; renderBoard();
|
||||
} catch (e) { alert("이동 실패: " + e.message); }
|
||||
});
|
||||
});
|
||||
col.querySelector(".pj-st-rename").addEventListener("click", async function () {
|
||||
const name = prompt("단계 이름", stage.name);
|
||||
if (!name || !name.trim()) return;
|
||||
try { await api("PUT", "/project/api/projects/" + projectId + "/stages/" + stage.id, { name: name.trim() }); await reloadStages(); renderBoard(); }
|
||||
catch (e) { alert("변경 실패: " + e.message); }
|
||||
});
|
||||
col.querySelector(".pj-st-done").addEventListener("click", async function () {
|
||||
try { await api("PUT", "/project/api/projects/" + projectId + "/stages/" + stage.id, { is_done_stage: !stage.is_done_stage }); await reloadStages(); renderBoard(); }
|
||||
catch (e) { alert("변경 실패: " + e.message); }
|
||||
});
|
||||
col.querySelector(".pj-st-del").addEventListener("click", async function () {
|
||||
if (!confirm("이 단계를 삭제할까요? (이 단계의 업무는 단계 미지정으로 남습니다)")) return;
|
||||
try { await api("DELETE", "/project/api/projects/" + projectId + "/stages/" + stage.id); await reloadStages(); renderBoard(); }
|
||||
catch (e) { alert("삭제 실패: " + e.message); }
|
||||
});
|
||||
}
|
||||
|
||||
async function addStage() {
|
||||
const name = prompt("새 단계 이름");
|
||||
if (!name || !name.trim()) return;
|
||||
try { await api("POST", "/project/api/projects/" + projectId + "/stages", { name: name.trim() }); await reloadStages(); renderBoard(); }
|
||||
catch (e) { alert("추가 실패: " + e.message); }
|
||||
}
|
||||
|
||||
function taskCard(t) {
|
||||
@@ -272,6 +336,7 @@
|
||||
// ── 업무 모달 ──
|
||||
const taskModal = el("pj-modal-task");
|
||||
wireModalClose(taskModal);
|
||||
let currentTaskId = null;
|
||||
|
||||
function openTaskModal(task) {
|
||||
el("pj-task-modal-title").textContent = task ? "업무 편집" : "새 업무";
|
||||
@@ -284,9 +349,101 @@
|
||||
el("pj-t-start").value = task ? (task.start_date || "") : "";
|
||||
el("pj-t-due").value = task ? (task.due_date || "") : "";
|
||||
el("pj-delete-task").hidden = !task;
|
||||
// 댓글·첨부는 기존 업무 편집 시에만
|
||||
currentTaskId = task ? task.id : null;
|
||||
el("pj-attach-block").hidden = !task;
|
||||
el("pj-comment-block").hidden = !task;
|
||||
if (task) { loadAttachments(task.id); loadComments(task.id); }
|
||||
openModal(taskModal);
|
||||
}
|
||||
|
||||
// ── 첨부파일 ──
|
||||
function fmtSize(n) {
|
||||
if (n < 1024) return n + " B";
|
||||
if (n < 1048576) return (n / 1024).toFixed(1) + " KB";
|
||||
return (n / 1048576).toFixed(1) + " MB";
|
||||
}
|
||||
async function loadAttachments(taskId) {
|
||||
const ul = el("pj-attach-list");
|
||||
ul.innerHTML = "<li class='pj-empty-sm'>불러오는 중…</li>";
|
||||
try {
|
||||
const res = await api("GET", "/project/api/tasks/" + taskId + "/attachments");
|
||||
if (!res.attachments.length) { ul.innerHTML = "<li class='pj-empty-sm'>없음</li>"; return; }
|
||||
ul.innerHTML = "";
|
||||
res.attachments.forEach(function (a) {
|
||||
const li = document.createElement("li");
|
||||
li.className = "pj-attach-item";
|
||||
li.innerHTML =
|
||||
'<a href="/project/api/attachments/' + a.id + '/download">' + escapeHtml(a.filename) + "</a>" +
|
||||
'<span class="pj-attach-size">' + fmtSize(a.size_bytes) + "</span>" +
|
||||
(canManage ? '<button type="button" class="pj-icon-btn pj-attach-del" data-id="' + a.id + '">×</button>' : "");
|
||||
ul.appendChild(li);
|
||||
});
|
||||
ul.querySelectorAll(".pj-attach-del").forEach(function (b) {
|
||||
b.addEventListener("click", async function () {
|
||||
if (!confirm("이 첨부를 삭제할까요?")) return;
|
||||
try { await api("DELETE", "/project/api/attachments/" + b.dataset.id); loadAttachments(taskId); }
|
||||
catch (e) { alert("삭제 실패: " + e.message); }
|
||||
});
|
||||
});
|
||||
} catch (e) { ul.innerHTML = "<li class='pj-empty-sm'>불러오기 실패</li>"; }
|
||||
}
|
||||
const attachInput = el("pj-attach-input");
|
||||
if (attachInput) attachInput.addEventListener("change", async function () {
|
||||
if (!currentTaskId || !attachInput.files.length) return;
|
||||
const fd = new FormData();
|
||||
fd.append("file", attachInput.files[0]);
|
||||
try {
|
||||
const res = await fetch("/project/api/tasks/" + currentTaskId + "/attachments", { method: "POST", body: fd });
|
||||
if (!res.ok) { let m = res.statusText; try { m = (await res.json()).detail; } catch (_) {} throw new Error(m); }
|
||||
attachInput.value = "";
|
||||
loadAttachments(currentTaskId);
|
||||
} catch (e) { alert("업로드 실패: " + e.message); }
|
||||
});
|
||||
|
||||
// ── 댓글 ──
|
||||
async function loadComments(taskId) {
|
||||
const ul = el("pj-comment-list");
|
||||
ul.innerHTML = "<li class='pj-empty-sm'>불러오는 중…</li>";
|
||||
try {
|
||||
const res = await api("GET", "/project/api/tasks/" + taskId + "/comments");
|
||||
if (!res.comments.length) { ul.innerHTML = "<li class='pj-empty-sm'>댓글 없음</li>"; return; }
|
||||
ul.innerHTML = "";
|
||||
res.comments.forEach(function (c) {
|
||||
const li = document.createElement("li");
|
||||
li.className = "pj-comment-item";
|
||||
li.innerHTML =
|
||||
'<span class="material-symbols-outlined pj-gicon pj-gicon-sm">account_circle</span>' +
|
||||
'<div class="pj-comment-main"><div class="pj-comment-head">' +
|
||||
escapeHtml(idOf(c.author_email)) + ' <span class="pj-comment-time">' +
|
||||
(c.created_at || "").slice(0, 16).replace("T", " ") + "</span></div>" +
|
||||
'<div class="pj-comment-body">' + escapeHtml(c.body) + "</div></div>" +
|
||||
'<button type="button" class="pj-icon-btn pj-comment-del" data-id="' + c.id + '">×</button>';
|
||||
ul.appendChild(li);
|
||||
});
|
||||
ul.querySelectorAll(".pj-comment-del").forEach(function (b) {
|
||||
b.addEventListener("click", async function () {
|
||||
try { await api("DELETE", "/project/api/comments/" + b.dataset.id); loadComments(taskId); }
|
||||
catch (e) { alert("삭제 실패: " + e.message); }
|
||||
});
|
||||
});
|
||||
} catch (e) { ul.innerHTML = "<li class='pj-empty-sm'>불러오기 실패</li>"; }
|
||||
}
|
||||
async function sendComment() {
|
||||
const inp = el("pj-comment-input");
|
||||
const body = inp.value.trim();
|
||||
if (!currentTaskId || !body) return;
|
||||
try {
|
||||
await api("POST", "/project/api/tasks/" + currentTaskId + "/comments", { body: body });
|
||||
inp.value = "";
|
||||
loadComments(currentTaskId);
|
||||
} catch (e) { alert("등록 실패: " + e.message); }
|
||||
}
|
||||
el("pj-comment-send").addEventListener("click", sendComment);
|
||||
el("pj-comment-input").addEventListener("keydown", function (e) {
|
||||
if (e.key === "Enter") { e.preventDefault(); sendComment(); }
|
||||
});
|
||||
|
||||
const newTaskBtn = el("pj-new-task");
|
||||
if (newTaskBtn) newTaskBtn.addEventListener("click", function () { openTaskModal(null); });
|
||||
|
||||
@@ -404,8 +561,43 @@
|
||||
.replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// ── 알림 벨 (미읽음 배지) ──
|
||||
async function initBell() {
|
||||
const badge = document.getElementById("pj-bell-badge");
|
||||
if (!badge) return;
|
||||
try {
|
||||
const res = await fetch("/project/api/notifications/unread-count");
|
||||
if (!res.ok) return;
|
||||
const d = await res.json();
|
||||
if (d.unread > 0) { badge.textContent = d.unread > 99 ? "99+" : d.unread; badge.hidden = false; }
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// ── 알림센터 페이지 ──
|
||||
function initInbox() {
|
||||
const inbox = document.getElementById("pj-inbox");
|
||||
const readAll = document.getElementById("pj-readall");
|
||||
if (!inbox && !readAll) return;
|
||||
if (readAll) readAll.addEventListener("click", async function () {
|
||||
try { await fetch("/project/api/notifications/read-all", { method: "POST" }); location.reload(); }
|
||||
catch (e) { alert("실패: " + e.message); }
|
||||
});
|
||||
if (inbox) inbox.querySelectorAll(".pj-inbox-item").forEach(function (li) {
|
||||
li.addEventListener("click", async function (e) {
|
||||
if (e.target.closest("a")) return; // '이동' 링크는 통과
|
||||
if (!li.classList.contains("is-unread")) return;
|
||||
try {
|
||||
await fetch("/project/api/notifications/" + li.dataset.id + "/read", { method: "POST" });
|
||||
li.classList.remove("is-unread");
|
||||
} catch (_) {}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
initHome();
|
||||
initProject();
|
||||
initBell();
|
||||
initInbox();
|
||||
});
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user