Files
dbx-main/app/static/project.js
T
king a22bb1e043 feat(project): 업무추가·알림벨을 상단바로 이동, 보드 단계추가 제거
- erp_base 토픈바에 {% block topbar_actions %} 추가
- 프로젝트 페이지: 알림벨 + '업무 추가' 를 탭 줄 → 상단 메뉴 오른쪽(사용자
  칩 옆)으로 이동. 탭 줄은 뷰 탭만 남김
- 보드 뷰의 '+ 단계 추가' 트레일링 컬럼 제거(단계 추가 기능 삭제)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 13:13:12 +09:00

775 lines
34 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;
// 날짜 문자열 더하기(일 단위). 'YYYY-MM-DD' 입출력.
function addDays(dstr, days) {
if (!dstr) return null;
const d = new Date(dstr + "T00:00:00");
d.setDate(d.getDate() + days);
const y = d.getFullYear(), m = String(d.getMonth() + 1).padStart(2, "0"), dd = String(d.getDate()).padStart(2, "0");
return y + "-" + m + "-" + dd;
}
function dayDiff(a, b) {
return Math.round((new Date(b + "T00:00:00") - new Date(a + "T00:00:00")) / 86400000);
}
function clearDropHL() {
document.querySelectorAll(".pj-day.pj-drop-over").forEach(function (d) { d.classList.remove("pj-drop-over"); });
}
// 화면 좌표 → 그 위치의 날짜 셀(있으면).
function dayCellAt(x, y) {
const elAt = document.elementFromPoint(x, y);
return elAt ? elAt.closest(".pj-day[data-date]") : null;
}
async function moveTaskTo(taskId, dropDate) {
const t = tasks.find(function (x) { return String(x.id) === String(taskId); });
if (!t) return;
const anchor = t.start_date || t.due_date;
if (!anchor) { alert("날짜가 없는 업무는 달력에서 이동할 수 없습니다."); return; }
const delta = dayDiff(anchor, dropDate);
if (delta === 0) return;
const payload = {
start_date: t.start_date ? addDays(t.start_date, delta) : null,
due_date: t.due_date ? addDays(t.due_date, delta) : null,
};
try {
await api("PUT", "/project/api/tasks/" + taskId, payload);
location.reload();
} catch (err) { alert("이동 실패: " + err.message); }
}
// 네이티브 HTML5 드래그는 이 절대배치 grid 오버레이에서 불안정해서,
// 포인터(mousedown→mousemove→mouseup) 기반으로 직접 구현한다.
function wireCalendar() {
if (calendarWired) return;
calendarWired = true;
const cal = document.querySelector(".pj-cal");
document.querySelectorAll(".pj-bar[data-task-id]").forEach(function (bar) {
bar.addEventListener("mousedown", function (e) {
if (e.button !== 0) return;
e.preventDefault(); // 텍스트 선택/네이티브 드래그 방지
const taskId = bar.dataset.taskId;
const sx = e.clientX, sy = e.clientY;
let dragging = false, ghost = null;
function onMove(ev) {
if (!dragging) {
if (Math.abs(ev.clientX - sx) + Math.abs(ev.clientY - sy) < 5) return;
dragging = true;
if (cal) cal.classList.add("pj-cal-dragging");
if (canManage) {
ghost = document.createElement("div");
ghost.className = "pj-bar-ghost";
ghost.textContent = (bar.textContent || "").trim();
document.body.appendChild(ghost);
}
}
if (ghost) { ghost.style.left = (ev.clientX + 10) + "px"; ghost.style.top = (ev.clientY + 12) + "px"; }
clearDropHL();
const cell = dayCellAt(ev.clientX, ev.clientY);
if (cell) cell.classList.add("pj-drop-over");
}
function onUp(ev) {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
if (cal) cal.classList.remove("pj-cal-dragging");
if (ghost) ghost.remove();
clearDropHL();
const t = tasks.find(function (x) { return String(x.id) === String(taskId); });
if (!dragging) { // 안 움직였으면 클릭 = 모달
if (t && canManage) openTaskModal(t);
return;
}
if (!canManage) return;
const cell = dayCellAt(ev.clientX, ev.clientY);
if (cell) moveTaskTo(taskId, cell.dataset.date);
}
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
});
});
}
// ── 타임라인 (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);
});
}
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); }
});
}
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"></div></div>' +
'<span class="pj-comment-acts">' +
'<button type="button" class="pj-icon-btn pj-comment-edit" title="수정">✎</button>' +
'<button type="button" class="pj-icon-btn pj-comment-del" title="삭제">×</button>' +
"</span>";
li.querySelector(".pj-comment-body").textContent = c.body; // XSS 안전(textContent)
li.querySelector(".pj-comment-del").addEventListener("click", async function () {
try { await api("DELETE", "/project/api/comments/" + c.id); loadComments(taskId); }
catch (e) { alert("삭제 실패: " + e.message); }
});
li.querySelector(".pj-comment-edit").addEventListener("click", function () {
editComment(li, c, taskId);
});
ul.appendChild(li);
});
} catch (e) { ul.innerHTML = "<li class='pj-empty-sm'>불러오기 실패</li>"; }
}
// 댓글 인라인 수정 — 본문을 textarea 로 교체, 저장/취소.
function editComment(li, c, taskId) {
const main = li.querySelector(".pj-comment-main");
const bodyEl = li.querySelector(".pj-comment-body");
const acts = li.querySelector(".pj-comment-acts");
if (li.querySelector(".pj-comment-edit-box")) return; // 중복 방지
acts.style.visibility = "hidden";
const box = document.createElement("div");
box.className = "pj-comment-edit-box";
const ta = document.createElement("textarea");
ta.rows = 2; ta.value = c.body;
const bar = document.createElement("div");
bar.className = "pj-comment-edit-actions";
const save = document.createElement("button");
save.type = "button"; save.className = "pj-btn pj-btn-primary"; save.textContent = "저장";
const cancel = document.createElement("button");
cancel.type = "button"; cancel.className = "pj-btn"; cancel.textContent = "취소";
bar.appendChild(cancel); bar.appendChild(save);
box.appendChild(ta); box.appendChild(bar);
bodyEl.style.display = "none";
main.appendChild(box);
ta.focus();
cancel.addEventListener("click", function () {
box.remove(); bodyEl.style.display = ""; acts.style.visibility = "";
});
save.addEventListener("click", async function () {
const text = ta.value.trim();
if (!text) { alert("내용을 입력하세요."); return; }
try {
await api("PUT", "/project/api/comments/" + c.id, { body: text });
loadComments(taskId);
} catch (e) { alert("수정 실패: " + e.message); }
});
}
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); }
});
// ── 프로젝트 트리 (아사나식 접기/펼치기) ──
function setTreeOpen(li, open) {
const children = li.querySelector(":scope > .pj-tree-children");
const caret = li.querySelector(":scope > .pj-tree-row > .pj-tree-caret");
if (!children) return;
children.hidden = !open;
if (caret) { caret.setAttribute("aria-expanded", open ? "true" : "false"); caret.classList.toggle("is-open", open); }
}
function treeOpenSet() {
try { return new Set(JSON.parse(localStorage.getItem("pj_tree_open") || "[]")); }
catch (_) { return new Set(); }
}
function treeSaveOpen(id, open) {
const s = treeOpenSet();
if (open) s.add(String(id)); else s.delete(String(id));
try { localStorage.setItem("pj_tree_open", JSON.stringify(Array.from(s))); } catch (_) {}
}
document.querySelectorAll(".pj-tree-caret").forEach(function (caret) {
caret.addEventListener("click", function (e) {
e.preventDefault(); e.stopPropagation();
const li = caret.closest(".pj-tree-li");
const children = li.querySelector(":scope > .pj-tree-children");
const open = children.hidden; // 현재 닫혀있으면 → 연다
setTreeOpen(li, open);
treeSaveOpen(li.dataset.id, open);
});
});
// 저장된 펼침 상태 복원 + 활성 프로젝트의 상위 자동 펼침
(function restoreTree() {
const openSet = treeOpenSet();
document.querySelectorAll(".pj-tree-li").forEach(function (li) {
if (openSet.has(String(li.dataset.id))) setTreeOpen(li, true);
});
const active = document.querySelector(".pj-tree-row.is-active");
let box = active ? active.closest(".pj-tree-children") : null;
while (box) {
box.hidden = false;
const li = box.closest(".pj-tree-li");
if (li) setTreeOpen(li, true);
box = li ? (li.parentElement ? li.parentElement.closest(".pj-tree-children") : null) : null;
}
})();
// 서브프로젝트 추가 (트리 각 노드)
document.querySelectorAll(".pj-tree-addsub").forEach(function (b) {
b.addEventListener("click", async function (e) {
e.preventDefault(); e.stopPropagation();
const name = prompt("서브프로젝트 이름");
if (!name || !name.trim()) return;
try {
await api("POST", "/project/api/projects/" + b.dataset.id + "/subprojects", { name: name.trim() });
location.reload();
} catch (err) { alert("생성 실패: " + err.message); }
});
});
// 프로젝트/서브 삭제 (트리)
document.querySelectorAll(".pj-tree-del").forEach(function (b) {
b.addEventListener("click", async function (e) {
e.preventDefault(); e.stopPropagation();
if (!confirm("'" + (b.dataset.name || "") + "' 삭제할까요? 하위 서브프로젝트·업무도 모두 삭제됩니다.")) return;
try {
await api("DELETE", "/project/api/projects/" + b.dataset.id);
if (String(b.dataset.id) === String(projectId)) location.href = "/project/";
else location.reload();
} catch (err) { alert("삭제 실패: " + err.message); }
});
});
// 새 최상위 프로젝트 (관리자)
const newProjSide = el("pj-new-project-side");
if (newProjSide) newProjSide.addEventListener("click", async function () {
const name = prompt("새 프로젝트 이름");
if (!name || !name.trim()) return;
try {
await api("POST", "/project/api/projects", { name: name.trim() });
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();
});
})();