Files
dbx-main/app/static/project.js
T
king 776f655db4 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>
2026-06-25 12:32:00 +09:00

604 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 프로젝트 관리(아사나식) 모듈 — 프론트엔드.
* 두 페이지에서 공용:
* - index.html (홈): 새 프로젝트 모달
* - project.html: 달력/타임라인/보드/리스트 뷰 + 업무 CRUD + 서브/멤버 관리
* 의존: FullCalendar(전역), vis(전역). 둘 다 project.html 에서만 로드.
*/
(function () {
"use strict";
// ── 공용 fetch ─────────────────────────────────────────────
async function api(method, url, body) {
const opts = { method, headers: { "Content-Type": "application/json" } };
if (body !== undefined) opts.body = JSON.stringify(body);
const res = await fetch(url, opts);
if (!res.ok) {
let msg = res.statusText;
try { msg = (await res.json()).detail || msg; } catch (_) {}
throw new Error(msg);
}
if (res.status === 204) return null;
return res.json();
}
function el(id) { return document.getElementById(id); }
function openModal(m) { if (m) m.hidden = false; }
function closeModal(m) { if (m) m.hidden = true; }
function wireModalClose(modal) {
if (!modal) return;
modal.addEventListener("click", function (e) {
if (e.target === modal || e.target.hasAttribute("data-close")) closeModal(modal);
});
}
// ── 색상 팔레트 렌더 ───────────────────────────────────────
function renderColorPalette(container, onPick) {
const colors = window.PJ_COLORS || ["#4573d2"];
let selected = colors[0];
container.innerHTML = "";
colors.forEach(function (c) {
const b = document.createElement("button");
b.type = "button";
b.className = "pj-color-swatch";
b.style.background = c;
if (c === selected) b.classList.add("is-active");
b.addEventListener("click", function () {
selected = c;
container.querySelectorAll(".pj-color-swatch").forEach(function (x) {
x.classList.remove("is-active");
});
b.classList.add("is-active");
if (onPick) onPick(c);
});
container.appendChild(b);
});
return function () { return selected; };
}
// ════════════════════════════════════════════════════════════
// 홈 페이지 — 새 프로젝트
// ════════════════════════════════════════════════════════════
function initHome() {
const newBtn = el("pj-new-project");
const modal = el("pj-modal-project");
if (!newBtn || !modal) return;
wireModalClose(modal);
const getColor = renderColorPalette(el("pj-f-colors"));
newBtn.addEventListener("click", function () { openModal(modal); });
el("pj-save-project").addEventListener("click", async function () {
const name = el("pj-f-name").value.trim();
if (!name) { alert("이름을 입력하세요."); return; }
try {
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(),
});
location.reload();
} catch (e) { alert("생성 실패: " + e.message); }
});
}
// ════════════════════════════════════════════════════════════
// 프로젝트 페이지
// ════════════════════════════════════════════════════════════
function initProject() {
const root = document.querySelector(".pj-project-view");
if (!root) return;
const projectId = parseInt(root.dataset.projectId, 10);
const canManage = root.dataset.canManage === "true";
const isAdmin = root.dataset.isAdmin === "true";
const dataEl = el("pj-data");
const data = JSON.parse(dataEl.textContent);
let stages = data.stages || [];
let tasks = data.tasks || [];
const members = data.members || [];
const priorityLabels = data.priorityLabels || {};
let timeline = null;
let currentView = "calendar";
// ── 뷰 토글 ──
const tabs = el("pj-view-tabs");
tabs.addEventListener("click", function (e) {
const btn = e.target.closest(".pj-tab");
if (!btn) return;
const view = btn.dataset.view;
tabs.querySelectorAll(".pj-tab").forEach(function (t) {
t.classList.toggle("is-active", t === btn);
});
document.querySelectorAll(".pj-view").forEach(function (v) {
v.hidden = v.dataset.view !== view;
});
currentView = view;
renderView(view);
});
function renderView(view) {
if (view === "calendar") wireCalendar();
else if (view === "timeline") renderTimeline();
else if (view === "board") renderBoard();
else if (view === "list") renderList();
}
// ── 달력 (휴가식 서버 렌더 그리드) ──
// bar 는 서버가 그려둠. 클릭하면 업무 모달 열기(관리 권한 시).
let calendarWired = false;
function wireCalendar() {
if (calendarWired) return;
calendarWired = true;
document.querySelectorAll(".pj-bar[data-task-id]").forEach(function (bar) {
bar.addEventListener("click", function (e) {
e.preventDefault();
if (!canManage) return;
const t = tasks.find(function (x) { return String(x.id) === bar.dataset.taskId; });
if (t) openTaskModal(t);
});
});
}
// ── 타임라인 (vis-timeline) ──
function renderTimeline() {
const node = el("pj-timeline");
const items = tasks.filter(function (t) { return t.start_date || t.due_date; })
.map(function (t) {
const s = t.start_date || t.due_date;
const e = t.due_date || t.start_date;
return {
id: t.id,
content: t.title,
start: s,
end: e !== s ? e : undefined,
type: e !== s ? "range" : "point",
style: "background-color:" + (t.completed_at ? "#9aa5b1" : (t.project_color || "#4573d2")) + ";color:#fff;border:none;",
};
});
if (timeline) { timeline.destroy(); timeline = null; }
if (!items.length) { node.innerHTML = '<div class="pj-empty">기간이 설정된 업무가 없습니다.</div>'; return; }
timeline = new vis.Timeline(node, new vis.DataSet(items), {
locale: "ko", stack: true, height: "520px", zoomKey: "ctrlKey",
margin: { item: 8 },
});
timeline.on("select", function (props) {
if (!canManage || !props.items.length) return;
const t = tasks.find(function (x) { return x.id === props.items[0]; });
if (t) openTaskModal(t);
});
}
// ── 보드 (칸반) + 단계 편집 ──
function renderBoard() {
const board = el("pj-board");
board.innerHTML = "";
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"><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>' + 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);
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) {
const card = document.createElement("div");
card.className = "pj-card" + (t.completed_at ? " is-done" : "");
card.draggable = canManage;
card.dataset.taskId = t.id;
card.innerHTML =
'<div class="pj-card-title">' + escapeHtml(t.title) + "</div>" +
'<div class="pj-card-meta">' +
(t.priority && t.priority !== "normal"
? '<span class="pj-pri pj-pri-' + t.priority + '">' + (priorityLabels[t.priority] || t.priority) + "</span>"
: "") +
(t.assignee_email
? '<span class="pj-assignee"><span class="material-symbols-outlined pj-gicon pj-gicon-sm">account_circle</span>' +
escapeHtml(idOf(t.assignee_email)) + "</span>"
: "") +
(t.due_date ? '<span class="pj-card-due">' + t.due_date + "</span>" : "") +
"</div>";
card.addEventListener("click", function () { if (canManage) openTaskModal(t); });
if (canManage) {
card.addEventListener("dragstart", function (e) {
e.dataTransfer.setData("text/plain", String(t.id));
card.classList.add("is-dragging");
});
card.addEventListener("dragend", function () { card.classList.remove("is-dragging"); });
}
return card;
}
function enableDrop(body, stageId) {
body.addEventListener("dragover", function (e) { e.preventDefault(); body.classList.add("is-over"); });
body.addEventListener("dragleave", function () { body.classList.remove("is-over"); });
body.addEventListener("drop", async function (e) {
e.preventDefault();
body.classList.remove("is-over");
const taskId = parseInt(e.dataTransfer.getData("text/plain"), 10);
const t = tasks.find(function (x) { return x.id === taskId; });
if (!t || t.stage_id === stageId) return;
try {
const res = await api("PUT", "/project/api/tasks/" + taskId, { stage_id: stageId });
mergeTask(res.task);
renderBoard();
} catch (err) { alert("이동 실패: " + err.message); }
});
}
// ── 리스트 ──
function renderList() {
const tbody = el("pj-list").querySelector("tbody");
tbody.innerHTML = "";
tasks.forEach(function (t) {
const tr = document.createElement("tr");
if (t.completed_at) tr.className = "is-done";
tr.innerHTML =
"<td>" + escapeHtml(t.title) + "</td>" +
"<td>" + (t.assignee_email ? escapeHtml(idOf(t.assignee_email)) : "-") + "</td>" +
"<td>" + escapeHtml(t.stage_name || "-") + "</td>" +
"<td>" + (priorityLabels[t.priority] || t.priority || "-") + "</td>" +
"<td>" + (t.start_date || "-") + "</td>" +
"<td>" + (t.due_date || "-") + "</td>";
if (canManage) { tr.style.cursor = "pointer"; tr.addEventListener("click", function () { openTaskModal(t); }); }
tbody.appendChild(tr);
});
}
function mergeTask(task) {
const i = tasks.findIndex(function (x) { return x.id === task.id; });
if (i >= 0) tasks[i] = Object.assign({}, tasks[i], task);
else tasks.push(task);
}
function refreshActiveView() {
// 달력은 서버 렌더라 변경 반영 위해 새로고침. 그 외 뷰는 즉시 재렌더.
if (currentView === "calendar") { location.reload(); return; }
renderView(currentView);
}
// ── 업무 모달 ──
const taskModal = el("pj-modal-task");
wireModalClose(taskModal);
let currentTaskId = null;
function openTaskModal(task) {
el("pj-task-modal-title").textContent = task ? "업무 편집" : "새 업무";
el("pj-t-id").value = task ? task.id : "";
el("pj-t-title").value = task ? task.title : "";
el("pj-t-desc").value = task ? (task.description || "") : "";
el("pj-t-assignee").value = task ? (task.assignee_email || "") : "";
el("pj-t-stage").value = task && task.stage_id ? task.stage_id : (stages[0] ? stages[0].id : "");
el("pj-t-priority").value = task ? (task.priority || "normal") : "normal";
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); });
el("pj-save-task").addEventListener("click", async function () {
const id = el("pj-t-id").value;
const assigneeSel = el("pj-t-assignee");
const assigneeName = assigneeSel.selectedOptions[0] ? (assigneeSel.selectedOptions[0].dataset.name || "") : "";
const payload = {
title: el("pj-t-title").value.trim(),
description: el("pj-t-desc").value.trim(),
assignee_email: assigneeSel.value,
assignee_name: assigneeName,
stage_id: parseInt(el("pj-t-stage").value, 10) || null,
priority: el("pj-t-priority").value,
start_date: el("pj-t-start").value || null,
due_date: el("pj-t-due").value || null,
};
if (!payload.title) { alert("제목을 입력하세요."); return; }
try {
let res;
if (id) res = await api("PUT", "/project/api/tasks/" + id, payload);
else res = await api("POST", "/project/api/projects/" + projectId + "/tasks", payload);
mergeTask(res.task);
closeModal(taskModal);
refreshActiveView();
} catch (e) { alert("저장 실패: " + e.message); }
});
el("pj-delete-task").addEventListener("click", async function () {
const id = el("pj-t-id").value;
if (!id || !confirm("이 업무를 삭제할까요?")) return;
try {
await api("DELETE", "/project/api/tasks/" + id);
tasks = tasks.filter(function (x) { return String(x.id) !== String(id); });
closeModal(taskModal);
refreshActiveView();
} catch (e) { alert("삭제 실패: " + e.message); }
});
// ── 서브프로젝트 추가/삭제 ──
const newSubBtn = el("pj-new-sub");
if (newSubBtn) newSubBtn.addEventListener("click", async function () {
const name = prompt("서브프로젝트 이름");
if (!name || !name.trim()) return;
try {
await api("POST", "/project/api/projects/" + projectId + "/subprojects", { name: name.trim() });
location.reload();
} catch (e) { alert("생성 실패: " + e.message); }
});
document.querySelectorAll(".pj-del-sub").forEach(function (b) {
b.addEventListener("click", async function () {
if (!confirm("이 서브프로젝트를 삭제할까요? (하위 업무도 모두 삭제)")) return;
try { await api("DELETE", "/project/api/projects/" + b.dataset.id); location.reload(); }
catch (e) { alert("삭제 실패: " + e.message); }
});
});
// ── 멤버 배정/제외 (관리자) ──
const memberModal = el("pj-modal-member");
wireModalClose(memberModal);
const addMemberBtn = el("pj-add-member");
if (addMemberBtn) addMemberBtn.addEventListener("click", async function () {
const sel = el("pj-m-user");
openModal(memberModal);
sel.innerHTML = '<option value="">불러오는 중…</option>';
try {
const res = await api("GET", "/project/api/assignable-users");
const existing = new Set(members.map(function (m) { return m.user_email; }));
const opts = res.users
.filter(function (u) { return !existing.has(u.email); })
.map(function (u) {
return '<option value="' + u.email + '" data-name="' + escapeHtml(u.name) + '">' +
escapeHtml(u.id) + " (" + escapeHtml(u.email) + ")</option>";
});
sel.innerHTML = opts.length
? opts.join("")
: '<option value="">배정 가능한 직원이 없습니다(권한 미부여 또는 전원 배정됨)</option>';
} catch (e) {
sel.innerHTML = '<option value="">불러오기 실패: ' + escapeHtml(e.message) + "</option>";
}
});
const saveMemberBtn = el("pj-save-member");
if (saveMemberBtn) saveMemberBtn.addEventListener("click", async function () {
const sel = el("pj-m-user");
if (!sel.value) { alert("직원을 선택하세요."); return; }
const name = sel.selectedOptions[0] ? (sel.selectedOptions[0].dataset.name || "") : "";
try {
await api("POST", "/project/api/projects/" + projectId + "/members", {
user_email: sel.value, user_name: name, role: el("pj-m-role").value,
});
location.reload();
} catch (e) { alert("배정 실패: " + e.message); }
});
document.querySelectorAll(".pj-del-member").forEach(function (b) {
b.addEventListener("click", async function () {
if (!confirm("이 멤버를 프로젝트에서 제외할까요?")) return;
try {
await api("DELETE", "/project/api/projects/" + projectId + "/members/" + encodeURIComponent(b.dataset.email));
location.reload();
} catch (e) { alert("제외 실패: " + e.message); }
});
});
// 최초 렌더 — 달력은 서버 렌더, 클릭 핸들러만 연결
wireCalendar();
}
function idOf(email) {
return String(email == null ? "" : email).split("@")[0];
}
function escapeHtml(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/"/g, "&quot;").replace(/'/g, "&#39;");
}
// ── 알림 벨 (미읽음 배지) ──
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();
});
})();