/* 프로젝트 관리(아사나식) 모듈 — 프론트엔드. * 두 페이지에서 공용: * - 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 calendar = null; 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") renderCalendar(); else if (view === "timeline") renderTimeline(); else if (view === "board") renderBoard(); else if (view === "list") renderList(); } // ── 달력 (FullCalendar) ── function taskEvents() { return tasks.filter(function (t) { return t.due_date || t.start_date; }) .map(function (t) { const start = t.start_date || t.due_date; // FullCalendar end 는 배타적 → 마감일 포함 위해 +1일은 생략(단일일 표시) return { id: String(t.id), title: t.title, start: start, end: t.due_date && t.due_date !== start ? t.due_date : undefined, backgroundColor: t.completed_at ? "#9aa5b1" : (t.project_color || "#4573d2"), borderColor: "transparent", extendedProps: { task: t }, }; }); } function renderCalendar() { const node = el("pj-calendar"); if (calendar) { calendar.removeAllEvents(); calendar.addEventSource(taskEvents()); calendar.render(); return; } calendar = new FullCalendar.Calendar(node, { initialView: "dayGridMonth", locale: "ko", height: "auto", headerToolbar: { left: "prev,next today", center: "title", right: "dayGridMonth,dayGridWeek" }, events: taskEvents(), eventClick: function (info) { info.jsEvent.preventDefault(); if (canManage) openTaskModal(info.event.extendedProps.task); }, }); calendar.render(); } // ── 타임라인 (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 = '
기간이 설정된 업무가 없습니다.
'; 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) { const col = document.createElement("div"); col.className = "pj-col"; col.dataset.stageId = stage.id; col.innerHTML = '
' + escapeHtml(stage.name) + (stage.is_done_stage ? ' 완료' : "") + '
' + '
'; 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); board.appendChild(col); }); } 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 ? '' + escapeHtml((t.assignee_name || t.assignee_email)[0].toUpperCase()) + "" : "") + (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) + "" + "" + escapeHtml(t.assignee_name || 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" && calendar) { calendar.removeAllEvents(); calendar.addEventSource(taskEvents()); } else renderView(currentView); } // ── 업무 모달 ── const taskModal = el("pj-modal-task"); wireModalClose(taskModal); 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; openModal(taskModal); } 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 addMemberBtn = el("pj-add-member"); if (addMemberBtn) addMemberBtn.addEventListener("click", async function () { const email = prompt("배정할 직원 이메일 (@dbxcorp.co.kr)"); if (!email || !email.trim()) return; try { await api("POST", "/project/api/projects/" + projectId + "/members", { user_email: email.trim(), role: "member", }); 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); } }); }); // 최초 렌더 renderCalendar(); } function escapeHtml(s) { return String(s == null ? "" : s) .replace(/&/g, "&").replace(//g, ">") .replace(/"/g, """).replace(/'/g, "'"); } document.addEventListener("DOMContentLoaded", function () { initHome(); initProject(); }); })();