/* 프로젝트 관리(아사나식) 모듈 — 프론트엔드. * 두 페이지에서 공용: * - 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, initial) { const colors = window.PJ_COLORS || ["#4573d2"]; let selected = (initial && colors.indexOf(initial) >= 0) ? initial : 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 grid = el("pj-project-grid"); if (!grid && !el("pj-toggle-done")) return; // 홈 아님 // ── 완료 프로젝트 숨기기 토글 (localStorage 기억) ── const toggleBtn = el("pj-toggle-done"); if (toggleBtn) { const KEY = "pj_hide_done"; function apply(hide) { if (grid) grid.classList.toggle("pj-hide-done", hide); const n = toggleBtn.dataset.count; toggleBtn.textContent = hide ? "완료 " + n + "개 보기" : "완료 " + n + "개 숨기기"; } apply(localStorage.getItem(KEY) === "1"); toggleBtn.addEventListener("click", function () { const hide = !(grid && grid.classList.contains("pj-hide-done")); try { localStorage.setItem(KEY, hide ? "1" : "0"); } catch (_) {} apply(hide); }); } // ── 새 프로젝트 모달 ── const newBtn = el("pj-new-project"); const modal = el("pj-modal-project"); if (newBtn && modal) { 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); } }); } // ── 프로젝트 수정 모달 ── const editModal = el("pj-modal-edit"); if (editModal && grid) { wireModalClose(editModal); let getEditColor = null; grid.querySelectorAll(".pj-card-edit").forEach(function (btn) { btn.addEventListener("click", function (e) { e.preventDefault(); e.stopPropagation(); const card = btn.closest(".pj-project-card"); el("pj-e-id").value = card.dataset.id; el("pj-e-name").value = card.dataset.name || ""; el("pj-e-desc").value = card.dataset.desc || ""; el("pj-e-start").value = card.dataset.start || ""; el("pj-e-due").value = card.dataset.due || ""; el("pj-e-done").checked = card.dataset.status === "completed"; getEditColor = renderColorPalette(el("pj-e-colors"), null, card.dataset.color); openModal(editModal); }); }); el("pj-save-edit").addEventListener("click", async function () { const id = el("pj-e-id").value; const name = el("pj-e-name").value.trim(); if (!name) { alert("이름을 입력하세요."); return; } try { await api("PUT", "/project/api/projects/" + id, { name: name, description: el("pj-e-desc").value.trim(), start_date: el("pj-e-start").value || null, due_date: el("pj-e-due").value || null, color: getEditColor ? getEditColor() : undefined, status: el("pj-e-done").checked ? "completed" : "active", }); location.reload(); } catch (e) { alert("저장 실패: " + e.message); } }); el("pj-delete-project").addEventListener("click", async function () { const id = el("pj-e-id").value; if (!confirm("이 프로젝트를 삭제할까요? 안의 업무도 모두 삭제됩니다.")) return; try { await api("DELETE", "/project/api/projects/" + id); 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 || {}; // 전체 프로젝트 집계 데이터 — 4개 뷰는 전 프로젝트를 보여준다. const projectsMeta = data.projects || []; const stagesByProject = data.stagesByProject || {}; const boardStages = data.boardStages || []; 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"); if (timeline) { timeline.destroy(); timeline = null; } const dated = tasks.filter(function (t) { return t.start_date || t.due_date; }); if (!dated.length) { node.innerHTML = '
기간이 설정된 업무가 없습니다.
'; return; } // 프로젝트별로 업무를 묶어 그룹(행) 생성 const byProj = {}; dated.forEach(function (t) { (byProj[t.project_id] = byProj[t.project_id] || []).push(t); }); const items = []; const groupRows = []; Object.keys(byProj).forEach(function (pid) { const list = byProj[pid]; const grp = "P" + pid; const projColor = list[0].project_color || "#4573d2"; groupRows.push({ id: grp, content: list[0].project_name || "프로젝트" }); let minMs = Infinity, maxMs = -Infinity; list.forEach(function (t) { const s = t.start_date || t.due_date; const e = t.due_date || t.start_date; 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); items.push({ id: t.id, group: grp, content: t.title, start: startISO, end: isRange ? endISO : undefined, type: isRange ? "range" : "point", style: "background-color:" + (t.completed_at ? "#9aa5b1" : projColor) + ";color:#fff;border:none;", }); const sm = new Date(startISO).getTime(); const em = new Date(endISO).getTime(); if (sm < minMs) minMs = sm; if (em > maxMs) maxMs = em; }); maxMs += 86400000; // 마지막 날 하루를 덮도록 +1일 items.push({ id: "bg" + pid, group: grp, type: "background", start: new Date(minMs), end: new Date(maxMs), style: "background-color:" + projColor + "22;", }); }); const groups = new vis.DataSet(groupRows); 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), groups, { 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 = ""; // 컬럼 이름 = 서버가 정렬해준 전체 단계 이름 + 단계 미지정 업무용 '미지정' let names = boardStages.slice(); const hasUnstaged = tasks.some(function (t) { return !t.stage_name; }); if (hasUnstaged) names.push("미지정"); names.forEach(function (name) { const col = document.createElement("div"); col.className = "pj-col"; col.dataset.stageName = name; col.innerHTML = '
' + escapeHtml(name) + "" + '
' + '
'; const body = col.querySelector(".pj-col-body"); const colTasks = tasks.filter(function (t) { return name === "미지정" ? !t.stage_name : (t.stage_name || "") === name; }); col.querySelector(".pj-col-count").textContent = colTasks.length; colTasks.forEach(function (t) { body.appendChild(taskCard(t)); }); if (canManage && name !== "미지정") enableDropByName(body, name); 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 = (t.project_name ? '
● ' + escapeHtml(t.project_name) + "
" : "") + '
' + 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; } // 보드 드롭 — 컬럼 이름(단계명)으로 옮긴다. 업무가 속한 '그 프로젝트'의 // 동일 이름 단계 id 를 찾아 적용(프로젝트마다 단계 id 가 다르므로). function enableDropByName(body, name) { 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_name || "") === name) return; const plist = stagesByProject[t.project_id] || []; const target = plist.find(function (s) { return s.name === name; }); if (!target) { alert("'" + (t.project_name || "이 프로젝트") + "' 에는 '" + name + "' 단계가 없습니다."); return; } try { const res = await api("PUT", "/project/api/tasks/" + taskId, { stage_id: target.id }); mergeTask(res.task); const m = tasks.find(function (x) { return x.id === taskId; }); if (m) { m.stage_id = target.id; m.stage_name = name; } renderBoard(); } catch (err) { alert("이동 실패: " + err.message); } }); } // ── 리스트 (정렬·컬럼 너비 조절) ── const LIST_COLS = [ { key: "project", label: "프로젝트", text: function (t) { return t.project_name || "-"; }, sort: function (t) { return (t.project_name || "").toLowerCase(); } }, { key: "title", label: "업무", text: function (t) { return t.title; }, sort: function (t) { return (t.title || "").toLowerCase(); } }, { key: "assignee", label: "담당자", text: function (t) { return t.assignee_email ? idOf(t.assignee_email) : "-"; }, sort: function (t) { return idOf(t.assignee_email || ""); } }, { key: "stage", label: "단계", text: function (t) { return t.stage_name || "-"; }, sort: function (t) { return t.stage_name || ""; } }, { key: "priority", label: "우선순위", text: function (t) { return priorityLabels[t.priority] || t.priority || "-"; }, sort: function (t) { return ({ low: 0, normal: 1, high: 2 })[t.priority]; } }, { key: "start", label: "시작", text: function (t) { return t.start_date || "-"; }, sort: function (t) { return t.start_date || ""; } }, { key: "due", label: "마감", text: function (t) { return t.due_date || "-"; }, sort: function (t) { return t.due_date || ""; } }, ]; let listSort = { key: null, dir: 1 }; function listWidths() { try { return JSON.parse(localStorage.getItem("pj_list_widths") || "{}"); } catch (_) { return {}; } } function saveListWidth(key, w) { const m = listWidths(); m[key] = w; try { localStorage.setItem("pj_list_widths", JSON.stringify(m)); } catch (_) {} } function enableColResize(th, key) { const handle = th.querySelector(".pj-th-resize"); if (!handle) return; handle.addEventListener("mousedown", function (e) { e.preventDefault(); e.stopPropagation(); const startX = e.clientX, startW = th.offsetWidth; function mv(ev) { th.style.width = Math.max(60, startW + (ev.clientX - startX)) + "px"; } function up() { document.removeEventListener("mousemove", mv); document.removeEventListener("mouseup", up); saveListWidth(key, th.offsetWidth); } document.addEventListener("mousemove", mv); document.addEventListener("mouseup", up); }); } function renderList() { const table = el("pj-list"); table.classList.add("pj-table-fixed"); const widths = listWidths(); const trh = document.createElement("tr"); LIST_COLS.forEach(function (col) { const th = document.createElement("th"); th.dataset.key = col.key; if (widths[col.key]) th.style.width = widths[col.key] + "px"; const arrow = listSort.key === col.key ? (listSort.dir > 0 ? "▲" : "▼") : "↕"; th.innerHTML = '' + escapeHtml(col.label) + ' ' + arrow + "" + ''; th.querySelector(".pj-th-label").addEventListener("click", function () { if (listSort.key === col.key) listSort.dir = -listSort.dir; else { listSort.key = col.key; listSort.dir = 1; } renderList(); }); enableColResize(th, col.key); trh.appendChild(th); }); const thead = table.querySelector("thead"); thead.innerHTML = ""; thead.appendChild(trh); let rows = tasks.slice(); if (listSort.key) { const col = LIST_COLS.find(function (c) { return c.key === listSort.key; }); rows.sort(function (a, b) { let va = col.sort(a), vb = col.sort(b); if (va == null) va = -Infinity; if (vb == null) vb = -Infinity; if (va < vb) return -listSort.dir; if (va > vb) return listSort.dir; return 0; }); } const tbody = table.querySelector("tbody"); tbody.innerHTML = ""; rows.forEach(function (t) { const tr = document.createElement("tr"); if (t.completed_at) tr.className = "is-done"; tr.innerHTML = LIST_COLS.map(function (c) { return "" + escapeHtml(String(c.text(t))) + ""; }).join(""); 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; // 단계 select 를 해당 업무가 속한 프로젝트의 단계로 채운다(전체 뷰에서 타 프로젝트 업무 편집 대비). function fillStageSelect(projId) { const sel = el("pj-t-stage"); const list = (projId != null && stagesByProject[projId]) ? stagesByProject[projId] : stages; sel.innerHTML = list.map(function (s) { return '"; }).join(""); } 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 || "") : ""; fillStageSelect(task ? task.project_id : projectId); 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-t-stime").disabled = !useTime; el("pj-t-dtime").disabled = !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' + '
    ' + escapeHtml(idOf(c.author_email)) + ' ' + (c.created_at || "").slice(0, 16).replace("T", " ") + "
    " + '
    ' + '' + '' + '' + ""; 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-t-stime").disabled = !this.checked; el("pj-t-dtime").disabled = !this.checked; }); // 시간 입력란 클릭 시 즉시 시간 피커 드롭다운 열기 ["pj-t-stime", "pj-t-dtime"].forEach(function (id) { const inp = el(id); if (inp) inp.addEventListener("click", function () { try { this.showPicker(); } catch (_) {} }); }); 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; const bell = badge.closest(".pj-bell"); 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; if (bell) bell.classList.add("has-unread"); // 종 흔들림 애니메이션 } } 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(); }); })();