/* 프로젝트 관리(아사나식) 모듈 — 프론트엔드.
* 두 페이지에서 공용:
* - 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;
// 시간 지정 시 date+time, 아니면 종일(날짜만)
const startISO = s + (t.start_time ? "T" + t.start_time : "");
const endISO = e + (t.due_time ? "T" + t.due_time : "");
const isRange = (e !== s) || (!!t.start_time && !!t.due_time && t.start_time !== t.due_time);
return {
id: t.id,
content: t.title,
start: startISO,
end: isRange ? endISO : undefined,
type: isRange ? "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 = '
기간이 설정된 업무가 없습니다.
'; return; }
const koWd = function (d) { return ["일", "월", "화", "수", "목", "금", "토"][d.getDay()]; };
const pad2 = function (n) { return ("0" + n).slice(-2); };
timeline = new vis.Timeline(node, new vis.DataSet(items), {
stack: true, height: "520px",
horizontalScroll: true, // 마우스 휠 = 좌우 이동(패닝)
verticalScroll: false,
zoomKey: "ctrlKey", // 확대/축소는 Ctrl+휠
margin: { item: 8 },
format: {
minorLabels: function (date, scale) {
const d = new Date(date.valueOf());
switch (scale) {
case "millisecond": case "second": case "minute": case "hour":
return pad2(d.getHours()) + ":" + pad2(d.getMinutes());
case "weekday": case "day": return d.getDate() + "일 (" + koWd(d) + ")";
case "week": return d.getDate() + "일";
case "month": return (d.getMonth() + 1) + "월";
case "year": return d.getFullYear() + "년";
default: return d.getDate() + "일";
}
},
majorLabels: function (date, scale) {
const d = new Date(date.valueOf());
switch (scale) {
case "millisecond": case "second": case "minute": case "hour":
return (d.getMonth() + 1) + "월 " + d.getDate() + "일 (" + koWd(d) + ")";
case "weekday": case "day": case "week":
return d.getFullYear() + "년 " + (d.getMonth() + 1) + "월";
case "month": return d.getFullYear() + "년";
default: return "";
}
},
},
});
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 =
'' +
'' +
'' +
'' +
'' +
'' +
"";
}
col.innerHTML =
'' + escapeHtml(stage.name) + "" +
(stage.is_done_stage ? ' 완료' : "") +
'' + controls + "
" +
'';
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 =
'' + escapeHtml(t.title) + "
" +
'' +
(t.priority && t.priority !== "normal"
? '' + (priorityLabels[t.priority] || t.priority) + ""
: "") +
(t.assignee_email
? 'account_circle' +
escapeHtml(idOf(t.assignee_email)) + ""
: "") +
(t.due_date ? '' + t.due_date + "" : "") +
"
";
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 =
"" + escapeHtml(t.title) + " | " +
"" + (t.assignee_email ? escapeHtml(idOf(t.assignee_email)) : "-") + " | " +
"" + escapeHtml(t.stage_name || "-") + " | " +
"" + (priorityLabels[t.priority] || t.priority || "-") + " | " +
"" + (t.start_date || "-") + " | " +
"" + (t.due_date || "-") + " | ";
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 || "") : "";
// 시간 — 값이 있으면 '시간 지정' 체크 + 입력란 표시
const useTime = !!(task && (task.start_time || task.due_time));
el("pj-t-usetime").checked = useTime;
el("pj-t-stime").value = task ? (task.start_time || "") : "";
el("pj-t-dtime").value = task ? (task.due_time || "") : "";
el("pj-time-row").hidden = !useTime;
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 = "불러오는 중…";
try {
const res = await api("GET", "/project/api/tasks/" + taskId + "/attachments");
if (!res.attachments.length) { ul.innerHTML = "없음"; return; }
ul.innerHTML = "";
res.attachments.forEach(function (a) {
const li = document.createElement("li");
li.className = "pj-attach-item";
li.innerHTML =
'' + escapeHtml(a.filename) + "" +
'' + fmtSize(a.size_bytes) + "" +
(canManage ? '' : "");
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 = "불러오기 실패"; }
}
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 = "불러오는 중…";
try {
const res = await api("GET", "/project/api/tasks/" + taskId + "/comments");
if (!res.comments.length) { ul.innerHTML = "댓글 없음"; return; }
ul.innerHTML = "";
res.comments.forEach(function (c) {
const li = document.createElement("li");
li.className = "pj-comment-item";
li.innerHTML =
'account_circle' +
'' +
'";
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 = "불러오기 실패"; }
}
// 댓글 인라인 수정 — 본문을 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-t-usetime").addEventListener("change", function () {
el("pj-time-row").hidden = !this.checked;
});
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 useTime = el("pj-t-usetime").checked;
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,
// 시간 미지정(체크 해제) = 종일 → null 전송
start_time: useTime ? (el("pj-t-stime").value || null) : null,
due_time: useTime ? (el("pj-t-dtime").value || null) : 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-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 = '';
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 '";
});
sel.innerHTML = opts.length
? opts.join("")
: '';
} catch (e) {
sel.innerHTML = '";
}
});
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();
// 사이드 트리에서 업무 클릭(?task=ID)으로 들어오면 해당 업무 모달 자동 열기.
// 단, 처리 후 URL 에서 task 파라미터를 제거한다 — 드래그 이동 후 location.reload()
// 시 이 파라미터가 남아 모달이 다시 뜨는 것을 방지.
const params = new URLSearchParams(location.search);
const taskParam = params.get("task");
if (taskParam) {
params.delete("task");
const qs = params.toString();
history.replaceState({}, "", location.pathname + (qs ? "?" + qs : ""));
const t = tasks.find(function (x) { return String(x.id) === String(taskParam); });
if (t) openTaskModal(t);
}
}
function idOf(email) {
return String(email == null ? "" : email).split("@")[0];
}
function escapeHtml(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&").replace(//g, ">")
.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();
});
})();