/* 프로젝트 관리(아사나식) 모듈 — 프론트엔드. * 두 페이지에서 공용: * - 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); }); } // ── 토스트 알림 — alert() 대신 쓰는 비차단 피드백 ── let toastEl = null, toastTimer = null; function showToast(msg, type) { if (!toastEl) { toastEl = document.createElement("div"); toastEl.className = "erp-toast"; toastEl.setAttribute("role", "status"); toastEl.setAttribute("aria-live", "polite"); document.body.appendChild(toastEl); } toastEl.textContent = msg; toastEl.dataset.type = type || "info"; toastEl.dataset.visible = "true"; clearTimeout(toastTimer); toastTimer = setTimeout(function () { toastEl.dataset.visible = "false"; }, 2400); } // ── 우클릭 컨텍스트 메뉴 — 하나만 열리는 공용 컴포넌트. // openDayPopover(아래)의 위치잡기/외부클릭 닫기 방식을 그대로 따르되, 앵커 // 엘리먼트 대신 클릭 좌표(clientX/Y) 기준으로 뷰포트에 클램프한다. let ctxMenuEl = null; function closeContextMenu() { if (!ctxMenuEl) return; ctxMenuEl.remove(); ctxMenuEl = null; document.removeEventListener("mousedown", onCtxDocDown, true); document.removeEventListener("keydown", onCtxKeyDown, true); window.removeEventListener("scroll", closeContextMenu, true); window.removeEventListener("resize", closeContextMenu, true); } function onCtxDocDown(e) { if (ctxMenuEl && !ctxMenuEl.contains(e.target)) closeContextMenu(); } function onCtxKeyDown(e) { if (e.key === "Escape") closeContextMenu(); } // items: [{label, danger?, onClick} | null(구분선)] function showContextMenu(x, y, items) { closeContextMenu(); const menu = document.createElement("div"); menu.className = "pj-ctxmenu"; items.forEach(function (item) { if (!item) { const sep = document.createElement("div"); sep.className = "pj-ctxmenu-sep"; menu.appendChild(sep); return; } const b = document.createElement("button"); b.type = "button"; b.className = "pj-ctxmenu-item" + (item.danger ? " is-danger" : ""); b.textContent = item.label; b.addEventListener("click", function () { closeContextMenu(); item.onClick(); }); menu.appendChild(b); }); document.body.appendChild(menu); ctxMenuEl = menu; let left = window.scrollX + x; let top = window.scrollY + y; left = Math.min(left, window.scrollX + document.documentElement.clientWidth - menu.offsetWidth - 8); top = Math.min(top, window.scrollY + document.documentElement.clientHeight - menu.offsetHeight - 8); menu.style.left = Math.max(8, left) + "px"; menu.style.top = Math.max(8, top) + "px"; setTimeout(function () { document.addEventListener("mousedown", onCtxDocDown, true); document.addEventListener("keydown", onCtxKeyDown, true); window.addEventListener("scroll", closeContextMenu, true); window.addEventListener("resize", closeContextMenu, true); }, 0); } // ── 날짜 표기 'YYYY-MM-DD' → 'mm/dd(요일)' — 서버 _fmt_date_kr 과 동일 규칙 ── function fmtDateKr(dstr) { if (!dstr) return ""; const d = new Date(String(dstr).slice(0, 10) + "T00:00:00"); if (isNaN(d.getTime())) return dstr; const mm = String(d.getMonth() + 1).padStart(2, "0"); const dd = String(d.getDate()).padStart(2, "0"); const wd = ["월", "화", "수", "목", "금", "토", "일"][(d.getDay() + 6) % 7]; return mm + "/" + dd + "(" + wd + ")"; } // 멤버 선택 후보 카드(아이콘 위 / 이름 가운데 / 체크박스 아래) — 새 프로젝트 // 모달(홈·프로젝트 페이지)과 멤버 배정 모달 3곳이 전부 이 함수 하나를 쓴다. // /api/assignable-users 응답의 avatar 필드를 그대로 쓰므로 페이지별 // avatars 맵(홈 페이지엔 없음)에 기대지 않는다. function memberPickHtml(u) { const icon = u.avatar ? '' : 'account_circle'; return '"; } // ── 색상 팔레트 렌더 ─────────────────────────────────────── 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; // 홈 아님 const wrap = document.querySelector(".pj-wrap"); const me = ((wrap && wrap.dataset.me) || "").toLowerCase(); const isSuper = !!(wrap && wrap.dataset.isSuper === "true"); const isAdmin = !!(wrap && wrap.dataset.isAdmin === "true"); function canDelete(createdBy) { if (isSuper) return true; const cb = (createdBy || "").toLowerCase(); return !!cb && cb === me; } // 나중에 정의되는(블록 스코프) 모달 오픈 함수를 우클릭 메뉴 등 바깥에서도 // 부를 수 있도록 참조만 미리 선언해둔다(정의되는 즉시 아래서 채워짐). let openNewTaskModalRef = null; let openHomeTaskModalRef = null; let openCommentsModalRef = null; // ── 프로젝트 카드 수정/삭제 (카드 ✎ 버튼·우클릭 메뉴 공용) ── const editModal = el("pj-modal-edit"); let getEditColor = null; function openCardEditModal(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); } async function deleteProjectCard(card) { if (!canDelete(card.dataset.creator)) { alert("삭제는 만든 사람만 할 수 있습니다."); return; } if (!confirm("이 프로젝트를 삭제할까요? 안의 업무도 모두 삭제됩니다.")) return; try { await api("DELETE", "/project/api/projects/" + card.dataset.id); card.remove(); } catch (e) { showToast("삭제 실패: " + e.message, "error"); } } // 프로젝트 수정 저장 결과(row)를 기존 카드 DOM에 그대로 반영 — 새로고침 없음. function patchProjectCardDom(card, p) { card.dataset.name = p.name || ""; card.dataset.desc = p.description || ""; card.dataset.start = p.start_date || ""; card.dataset.due = p.due_date || ""; card.dataset.color = p.color || "#4573d2"; card.dataset.status = p.status || "active"; card.style.setProperty("--pj-color", p.color || "#4573d2"); card.classList.toggle("is-done", p.status === "completed"); const nameEl = card.querySelector(".pj-project-name"); if (nameEl) { if (nameEl.firstChild && nameEl.firstChild.nodeType === Node.TEXT_NODE) { nameEl.firstChild.textContent = p.name || ""; } else { nameEl.insertBefore(document.createTextNode(p.name || ""), nameEl.firstChild); } let doneChip = nameEl.querySelector(".pj-chip"); if (p.status === "completed") { if (!doneChip) { doneChip = document.createElement("span"); doneChip.className = "pj-chip pj-chip-sm"; doneChip.textContent = "완료"; nameEl.appendChild(doneChip); } } else if (doneChip) doneChip.remove(); } let descEl = card.querySelector(".pj-project-desc"); if (p.description) { if (!descEl) { descEl = document.createElement("div"); descEl.className = "pj-project-desc"; if (nameEl) nameEl.insertAdjacentElement("afterend", descEl); } descEl.textContent = p.description; } else if (descEl) descEl.remove(); const meta = card.querySelector(".pj-project-meta"); if (meta) { let dueChip = null; meta.querySelectorAll(".pj-chip").forEach(function (c) { if (c.textContent.indexOf("마감") === 0) dueChip = c; }); if (p.due_date) { const label = "마감 " + fmtDateKr(p.due_date); if (dueChip) dueChip.textContent = label; else { dueChip = document.createElement("span"); dueChip.className = "pj-chip"; dueChip.textContent = label; meta.insertBefore(dueChip, meta.firstChild); } } else if (dueChip) dueChip.remove(); } // 진행중 ↔ 완료 그리드 이동. 완료 그리드가 화면에 아직 없으면(첫 완료 // 프로젝트) 다음 새로고침까지 원래 위치에 남는다 — 드문 경우라 허용. const targetGrid = el(p.status === "completed" ? "pj-project-grid-done" : "pj-project-grid"); if (targetGrid && card.parentElement !== targetGrid) targetGrid.appendChild(card); } // 새 프로젝트 카드 DOM — project_card(p) 매크로(index.html)를 그대로 미러링. // 방금 만든 프로젝트라 멤버/업무는 아직 없다(멤버는 생성 직후 별도 배정, // 업무는 카드의 "업무 추가"로 나중에). 만든 사람은 자동 owner 라 관리 가능. function projectCardHtml(p) { const color = p.color || "#4573d2"; const html = '
' + '' + '' + '
' + '
' + escapeHtml(p.name || "") + "
" + (p.description ? '
' + escapeHtml(p.description) + "
" : "") + '
' + (p.due_date ? '마감 ' + fmtDateKr(p.due_date) + "" : "") + "
" + '
' + '' + '
' + '
진행 중인 업무가 없습니다.
'; const wrap = document.createElement("div"); wrap.innerHTML = html; return wrap.firstElementChild; } // 카드 하나에 수정/업무추가/우클릭 메뉴를 건다 — 최초 로드된 카드·새로 // 만든 카드 모두 이 함수 하나로 통일(initHome 맨 끝에서 전체 카드에 호출). function wireProjectCard(card) { const editBtn = card.querySelector(".pj-card-edit"); if (editBtn) editBtn.addEventListener("click", function (e) { e.preventDefault(); e.stopPropagation(); openCardEditModal(card); }); const addBtn = card.querySelector(".pj-card-addtask"); if (addBtn) addBtn.addEventListener("click", function (e) { e.preventDefault(); e.stopPropagation(); if (openNewTaskModalRef) openNewTaskModalRef(card.dataset.id, card.dataset.name); }); card.addEventListener("contextmenu", function (e) { if (!card.querySelector(".pj-card-edit")) return; // 관리 권한 없는 카드는 메뉴 없음 e.preventDefault(); showContextMenu(e.clientX, e.clientY, [ { label: "열기", onClick: function () { location.href = "/project/p/" + card.dataset.id; } }, { label: "업무 추가", onClick: function () { if (openNewTaskModalRef) openNewTaskModalRef(card.dataset.id, card.dataset.name); } }, { label: "수정", onClick: function () { openCardEditModal(card); } }, null, { label: "삭제", danger: true, onClick: function () { deleteProjectCard(card); } }, ]); }); } // ── 홈 카드 안 업무 리스트 patch (서버 재조회 없이) ── function homeTaskRowHtml(t) { const assigneeDisplay = t.assignee_name || (t.assignee_email ? idOf(t.assignee_email) : ""); const today = new Date().toISOString().slice(0, 10); const overdue = !!(t.due_date && !t.completed_at && String(t.due_date).slice(0, 10) < today); return '
  • ' + '' + '' + (t.comment_count ? '' + '💬' + t.comment_count + "" : "") + "" + '' + escapeHtml(t.title || "") + "" + (t.stage_name ? '' + escapeHtml(t.stage_name) + "" : "") + (assigneeDisplay ? 'account_circle' + escapeHtml(assigneeDisplay) + "" : "") + (t.due_date ? '' + fmtDateKr(t.due_date) + "" : "") + "
  • "; } function updateHomeCardProgress(projectId) { const card = document.querySelector('.pj-project-card[data-id="' + projectId + '"]'); if (!card) return; const total = card.querySelectorAll(".pj-project-taskrow").length; const done = card.querySelectorAll(".pj-project-taskrow.is-done").length; const meta = card.querySelector(".pj-project-meta"); if (meta) { let chip = null; meta.querySelectorAll(".pj-chip").forEach(function (c) { if (c.textContent.indexOf("업무") === 0) chip = c; }); if (total) { const label = "업무 " + done + "/" + total; if (chip) chip.textContent = label; else { chip = document.createElement("span"); chip.className = "pj-chip"; chip.textContent = label; meta.appendChild(chip); } } else if (chip) chip.remove(); } const body = card.querySelector(".pj-project-body"); let bar = card.querySelector(".pj-project-progress-bar"); if (total) { if (!bar && body) { const w = document.createElement("div"); w.className = "pj-project-progress"; w.innerHTML = '
    '; body.appendChild(w); bar = w.firstElementChild; } if (bar) bar.style.width = Math.round(done * 100 / total) + "%"; } else if (bar) { const prog = bar.closest(".pj-project-progress"); if (prog) prog.remove(); } } // 업무 우클릭 메뉴 — 홈 카드 안 업무 행·"내 업무" 패널 행 공용(둘 다 같은 // .pj-task-popup-link 를 씀). 열기=프로젝트 페이지로 실제 이동, 수정=이 // 자리에서 팝업 편집 — 서로 다른 동작이라 항목을 나눴다. 카드 자체의 // 우클릭 메뉴와 겹치지 않도록 stopPropagation 필수(안 하면 이벤트가 // 버블링돼 카드 메뉴가 나중에 열려 덮어써 버린다). function homeTaskContextItems(t) { return [ { label: "열기", onClick: function () { location.href = "/project/p/" + t.project_id + "?task=" + t.id; } }, { label: "업무 추가", onClick: function () { if (openNewTaskModalRef) openNewTaskModalRef(t.project_id, t.project_name); } }, { label: "수정", onClick: function () { if (openHomeTaskModalRef) openHomeTaskModalRef(t); } }, { label: t.completed_at ? "완료 취소" : "완료로 표시", onClick: async function () { try { const res = await api("PUT", "/project/api/tasks/" + t.id, { completed: !t.completed_at }); upsertHomeTaskRow(res.task); } catch (e) { showToast("처리 실패: " + e.message, "error"); } }, }, null, { label: "삭제", danger: true, onClick: async function () { if (!confirm("이 업무를 삭제할까요?")) return; try { await api("DELETE", "/project/api/tasks/" + t.id); removeHomeTaskRow(t.id, t.project_id); } catch (e) { showToast("삭제 실패: " + e.message, "error"); } }, }, ]; } // .pj-task-popup-link 하나에 클릭(팝업 열기)+우클릭(메뉴) 배선 — 최초 // 로드된 행과 optimistic 하게 새로 그려진 행(upsertHomeTaskRow/ // syncMyTaskPanel) 모두 이 함수 하나로 통일. function wireHomeTaskPopupLink(a, taskOverride) { a.addEventListener("click", function (e) { const task = taskOverride || homeTasks[a.dataset.taskId]; if (!task) return; // 정보 없으면 기존 링크 이동으로 폴백 e.preventDefault(); if (openHomeTaskModalRef) openHomeTaskModalRef(task); }); a.addEventListener("contextmenu", function (e) { const task = taskOverride || homeTasks[a.dataset.taskId]; if (!task) return; e.preventDefault(); e.stopPropagation(); showContextMenu(e.clientX, e.clientY, homeTaskContextItems(task)); }); } function upsertHomeTaskRow(t) { const card = document.querySelector('.pj-project-card[data-id="' + t.project_id + '"]'); if (!card) { syncMyTaskPanel(t); return; } homeTasks[t.id] = t; let list = card.querySelector(".pj-project-tasklist"); const emptyMsg = card.querySelector(".pj-project-tasks-empty"); if (!list) { list = document.createElement("ul"); list.className = "pj-project-tasklist"; if (emptyMsg) emptyMsg.replaceWith(list); else { const body = card.querySelector(".pj-project-body"); if (body) body.appendChild(list); } } const wrap = document.createElement("div"); wrap.innerHTML = homeTaskRowHtml(t); const newLi = wrap.firstElementChild; wireHomeTaskPopupLink(newLi.querySelector(".pj-task-popup-link"), t); const badge = newLi.querySelector(".pj-cmt-badge"); if (badge) { badge.addEventListener("click", function (e) { e.preventDefault(); e.stopPropagation(); }); badge.addEventListener("dblclick", function (e) { e.preventDefault(); e.stopPropagation(); if (openCommentsModalRef) openCommentsModalRef(t.id); }); } // 서버가 처음 그린
  • (macro)는 data-task-id 를
  • 자체가 아니라 // 안쪽 .pj-task-popup-link 에만 붙인다 — 그 기준으로 찾아야 최초 // 페이지 로드 때부터 있던 행(아직 한 번도 patch 안 된 행)도 잡힌다. const existingLink = list.querySelector('.pj-task-popup-link[data-task-id="' + t.id + '"]'); const existing = existingLink ? existingLink.closest("li") : null; if (existing) existing.replaceWith(newLi); else list.appendChild(newLi); updateHomeCardProgress(t.project_id); syncMyTaskPanel(t); } function removeHomeTaskRow(id, projectId) { const link = document.querySelector('.pj-project-taskrow .pj-task-popup-link[data-task-id="' + id + '"]'); const li = link ? link.closest("li") : null; const pid = projectId || (li && li.closest(".pj-project-card") && li.closest(".pj-project-card").dataset.id); if (li) { const list = li.closest(".pj-project-tasklist"); li.remove(); if (list && !list.children.length) { const empty = document.createElement("div"); empty.className = "pj-project-tasks-empty"; empty.textContent = "진행 중인 업무가 없습니다."; list.replaceWith(empty); } } delete homeTasks[id]; if (pid) updateHomeCardProgress(pid); syncMyTaskPanel({ id: id, project_id: pid }, true); } // "내 업무" 패널(우측) 동기화 — 이 프로젝트에 내 업무 그룹이 이미 있을 // 때만 patch 한다(그룹 자체가 없던 경우는 드물어 다음 새로고침까지 생략). function syncMyTaskPanel(t, removed) { const group = document.querySelector('.pj-mytree-group[data-group-id="' + t.project_id + '"] .pj-mytree-tasks'); if (!group) return; const isMine = !removed && t.assignee_email && String(t.assignee_email).toLowerCase() === me; const existingLink = group.querySelector('.pj-task-popup-link[data-task-id="' + t.id + '"]'); const existingLi = existingLink ? existingLink.closest("li") : null; if (!isMine) { if (existingLi) existingLi.remove(); return; } const dates = t.start_date || t.due_date ? '' + (t.start_date && t.due_date ? fmtDateKr(t.start_date) + " ~ " + fmtDateKr(t.due_date) : t.due_date ? "~ " + fmtDateKr(t.due_date) : fmtDateKr(t.start_date) + " ~") + "" : ""; const html = '
  • ' + '' + '' + (t.comment_count ? '' + '💬' + t.comment_count + "" : "") + "" + '' + escapeHtml(t.title || "") + "" + dates + "" + "
  • "; const wrap = document.createElement("div"); wrap.innerHTML = html; const newLi = wrap.firstElementChild; wireHomeTaskPopupLink(newLi.querySelector(".pj-task-popup-link"), t); if (existingLi) existingLi.replaceWith(newLi); else group.appendChild(newLi); } // ── 완료 프로젝트 섹션 접기/펼치기 (localStorage 기억) ── // display 를 인라인 스타일로 직접 제어 — 외부 CSS 규칙과의 특이도/우선순위 // 문제를 원천 차단(어떤 스타일시트보다 인라인 style 이 항상 우선한다). const toggleBtn = el("pj-toggle-done"); const doneGrid = el("pj-project-grid-done"); if (toggleBtn && doneGrid) { const KEY = "pj_hide_done"; let hideDone = localStorage.getItem(KEY) === "1"; function apply() { doneGrid.style.display = hideDone ? "none" : ""; const n = toggleBtn.dataset.count; toggleBtn.textContent = hideDone ? "완료 " + n + "개 보기" : "완료 " + n + "개 숨기기"; } apply(); toggleBtn.addEventListener("click", function () { hideDone = !hideDone; try { localStorage.setItem(KEY, hideDone ? "1" : "0"); } catch (_) {} apply(); }); } // ── 새 프로젝트 모달 ── const newBtn = el("pj-new-project"); const modal = el("pj-modal-project"); if (newBtn && modal) { wireModalClose(modal); const getColor = renderColorPalette(el("pj-f-colors")); const membersBox = el("pj-f-members"); newBtn.addEventListener("click", async function () { openModal(modal); if (membersBox) { // 멤버 배정은 관리자 전용 API(/assignable-users)라 관리자가 아니면 // 호출해도 403 이 난다 — 아예 시도하지 않고 안내만 보여준다. // (프로젝트 생성 자체는 이제 누구나 가능 — 멤버는 나중에 관리자가 배정) if (!isAdmin) { membersBox.innerHTML = "멤버 배정은 관리자가 나중에 할 수 있습니다."; } else { membersBox.innerHTML = "불러오는 중…"; try { const res = await api("GET", "/project/api/assignable-users"); const users = res.users || []; membersBox.innerHTML = users.length ? users.map(memberPickHtml).join("") : "배정 가능한 직원이 없습니다."; } catch (e) { membersBox.innerHTML = "불러오기 실패"; } } } }); el("pj-save-project").addEventListener("click", async function () { const name = el("pj-f-name").value.trim(); if (!name) { alert("이름을 입력하세요."); return; } const memberEmails = membersBox ? Array.from(membersBox.querySelectorAll("input[type=checkbox]:checked")).map(function (c) { return c.value; }) : []; try { const res = 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(), }); const pid = res.project.id; for (const email of memberEmails) { try { await api("POST", "/project/api/projects/" + pid + "/members", { user_email: email }); } catch (_) {} } closeModal(modal); if (grid) { const card = projectCardHtml(res.project); grid.insertBefore(card, grid.firstChild); wireProjectCard(card); } } catch (e) { showToast("생성 실패: " + e.message, "error"); } }); } // ── 프로젝트 수정 모달 (진행중/완료 두 그리드 모두 대상) ── if (editModal && grid) { wireModalClose(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 { const res = 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", }); const card = document.querySelector('.pj-project-card[data-id="' + id + '"]'); if (card) patchProjectCardDom(card, res.project); closeModal(editModal); } catch (e) { showToast("저장 실패: " + e.message, "error"); } }); el("pj-delete-project").addEventListener("click", async function () { const id = el("pj-e-id").value; const card = document.querySelector('.pj-project-card[data-id="' + id + '"]'); if (card && !canDelete(card.dataset.creator)) { alert("삭제는 만든 사람만 할 수 있습니다."); return; } if (!confirm("이 프로젝트를 삭제할까요? 안의 업무도 모두 삭제됩니다.")) return; try { await api("DELETE", "/project/api/projects/" + id); if (card) card.remove(); closeModal(editModal); } catch (e) { showToast("삭제 실패: " + e.message, "error"); } }); } // 카드/내 업무 클릭 팝업 + 댓글 팝업 공용 — 업무 원본 데이터(제목 등). const homeDataEl = el("pj-home-data"); let homeTasks = {}; if (homeDataEl) { try { homeTasks = JSON.parse(homeDataEl.textContent) || {}; } catch (_) {} } // ── 업무 편집 팝업 (카드/내 업무 클릭 시 페이지 이동 없이) ── const homeTaskModal = el("pj-modal-task"); if (homeTaskModal) { const descEditor = initDescEditor(); const subtaskBlock = initSubtaskBlock(); const linksBlock = initLinksBlock(); if (descEditor) { descEditor.setOnImageRemoved(function () { const tid = el("pj-t-id").value; if (tid) loadHomeAttachments(tid); }); } // 닫을 때(배경 클릭·Esc·취소 버튼) 수정한 내용이 있으면 저장할지 물어본다. let homeTaskSnapshot = null; function homeTaskFormSnapshot() { return JSON.stringify({ title: el("pj-t-title").value, assignee: el("pj-t-assignee").value, stage: el("pj-t-stage").value, priority: el("pj-t-priority").value, start: el("pj-t-start").value, due: el("pj-t-due").value, usetime: el("pj-t-usetime").checked, stime: el("pj-t-stime").value, dtime: el("pj-t-dtime").value, desc: descEditor ? descEditor.getHtml() : "", }); } function isHomeTaskDirty() { return homeTaskSnapshot !== null && homeTaskFormSnapshot() !== homeTaskSnapshot; } let homeTaskClosing = false; async function attemptCloseHomeTaskModal() { // erpConfirm 도 Esc 로 닫히는데 stopPropagation 을 안 해서, 확인창이 // 뜬 상태에서 Esc 를 누르면 이 함수가 다시 불릴 수 있다 — 재진입 방지. if (homeTaskModal.hidden || homeTaskClosing) return; homeTaskClosing = true; try { if (isHomeTaskDirty()) { const save = await window.erpConfirm( "수정한 내용이 있습니다. 저장하시겠습니까?", { okText: "저장", cancelText: "저장 안 함" } ); if (save) { el("pj-save-task").click(); return; } } homeTaskSnapshot = null; closeModal(homeTaskModal); } finally { homeTaskClosing = false; } } homeTaskModal.addEventListener("click", function (e) { if (e.target === homeTaskModal || e.target.hasAttribute("data-close")) attemptCloseHomeTaskModal(); }); document.addEventListener("keydown", function (e) { if (e.key === "Escape" && !homeTaskModal.hidden) { e.preventDefault(); attemptCloseHomeTaskModal(); } }); async function loadTaskExtras(task) { if (!subtaskBlock && !linksBlock) return; try { const res = await api("GET", "/project/api/tasks/" + task.id); const detail = res.task; if (subtaskBlock) { subtaskBlock.setContext({ taskId: task.id, projectId: task.project_id, canDelete: canDelete }); subtaskBlock.render(detail.subtasks || []); } if (linksBlock) { linksBlock.setContext({ taskId: task.id, projectId: task.project_id, onChange: function () { loadTaskExtras(task); }, }); linksBlock.render(detail.links || []); } } catch (_) { /* 조용히 무시 — 본편집은 계속 가능해야 한다 */ } } async function fillAssignee(pid, currentEmail) { const sel = el("pj-t-assignee"); let list = []; try { list = (await api("GET", "/project/api/projects/" + pid + "/members")).members || []; } catch (_) { list = []; } sel.innerHTML = '' + list.map(function (m) { const name = idOf(m.user_email); return '"; }).join(""); sel.value = currentEmail || ""; } async function fillStage(pid, currentStageId) { const sel = el("pj-t-stage"); let list = []; try { list = (await api("GET", "/project/api/projects/" + pid + "/stages")).stages || []; } catch (_) { list = []; } sel.innerHTML = list.map(function (s) { return '"; }).join(""); sel.value = currentStageId || (list[0] ? list[0].id : ""); } async function loadHomeAttachments(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) + "" + ''; 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); loadHomeAttachments(taskId); } catch (e) { alert("삭제 실패: " + e.message); } }); }); } catch (e) { ul.innerHTML = "
  • 불러오기 실패
  • "; } } const homeAttachInput = el("pj-attach-input"); if (homeAttachInput) homeAttachInput.addEventListener("change", async function () { const taskId = el("pj-t-id").value; if (!taskId || !homeAttachInput.files.length) return; const fd = new FormData(); fd.append("file", homeAttachInput.files[0]); try { const res = await fetch("/project/api/tasks/" + taskId + "/attachments", { method: "POST", body: fd }); if (!res.ok) { let m = res.statusText; try { m = (await res.json()).detail; } catch (_) {} throw new Error(m); } homeAttachInput.value = ""; loadHomeAttachments(taskId); } catch (e) { alert("업로드 실패: " + e.message); } }); async function loadHomeComments(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; li.querySelector(".pj-comment-del").addEventListener("click", async function () { try { await api("DELETE", "/project/api/comments/" + c.id); loadHomeComments(taskId); } catch (e) { alert("삭제 실패: " + e.message); } }); ul.appendChild(li); }); } catch (e) { ul.innerHTML = "
  • 불러오기 실패
  • "; } } el("pj-comment-send").addEventListener("click", async function () { const taskId = el("pj-t-id").value; const inp = el("pj-comment-input"); const body = inp.value.trim(); if (!taskId || !body) return; try { await api("POST", "/project/api/tasks/" + taskId + "/comments", { body: body }); inp.value = ""; loadHomeComments(taskId); } catch (e) { alert("등록 실패: " + e.message); } }); el("pj-comment-input").addEventListener("keydown", function (e) { if (e.key === "Enter") { e.preventDefault(); el("pj-comment-send").click(); } }); async function openHomeTaskModal(task) { el("pj-task-modal-title").textContent = "[" + (task.project_name || "") + "]의 업무 편집"; el("pj-t-id").value = task.id; el("pj-t-project-id").value = task.project_id; el("pj-t-title").value = task.title || ""; if (descEditor) { descEditor.setTaskId(task.id); descEditor.setHtml(task.description || ""); } el("pj-t-priority").value = task.priority || "normal"; el("pj-t-start").value = task.start_date || ""; el("pj-t-due").value = task.due_date || ""; const useTime = !!(task.start_time || task.due_time); el("pj-t-usetime").checked = useTime; el("pj-t-stime").value = task.start_time || ""; el("pj-t-dtime").value = task.due_time || ""; el("pj-t-stime").disabled = !useTime; el("pj-t-dtime").disabled = !useTime; el("pj-t-open-project").href = "/project/p/" + task.project_id + "?task=" + task.id; el("pj-delete-task").hidden = false; el("pj-toggle-complete").hidden = false; setHomeCompleteBtn(!!task.completed_at); el("pj-t-open-project").hidden = false; el("pj-attach-block").hidden = false; el("pj-comment-block").hidden = false; if (subtaskBlock) el("pj-subtask-block").hidden = false; if (linksBlock) el("pj-links-block").hidden = false; openModal(homeTaskModal); await Promise.all([ fillAssignee(task.project_id, task.assignee_email || ""), fillStage(task.project_id, task.stage_id), ]); loadHomeAttachments(task.id); loadHomeComments(task.id); loadTaskExtras(task); homeTaskSnapshot = homeTaskFormSnapshot(); } openHomeTaskModalRef = openHomeTaskModal; // 카드 이름 옆 "업무 추가" 버튼 — 같은 모달을 새 업무 작성용으로 초기화해 연다. async function openHomeNewTaskModal(projectId, projectName) { el("pj-task-modal-title").textContent = "[" + (projectName || "") + "]의 새 업무"; el("pj-t-id").value = ""; el("pj-t-project-id").value = projectId; el("pj-t-title").value = ""; if (descEditor) { descEditor.setTaskId(null); descEditor.setHtml(""); } el("pj-t-priority").value = "normal"; el("pj-t-start").value = ""; el("pj-t-due").value = ""; el("pj-t-usetime").checked = false; el("pj-t-stime").value = ""; el("pj-t-stime").disabled = true; el("pj-t-dtime").value = ""; el("pj-t-dtime").disabled = true; el("pj-delete-task").hidden = true; el("pj-toggle-complete").hidden = true; el("pj-t-open-project").hidden = true; el("pj-attach-block").hidden = true; el("pj-comment-block").hidden = true; if (subtaskBlock) el("pj-subtask-block").hidden = true; if (linksBlock) el("pj-links-block").hidden = true; openModal(homeTaskModal); await Promise.all([fillAssignee(projectId, ""), fillStage(projectId, null)]); homeTaskSnapshot = homeTaskFormSnapshot(); } openNewTaskModalRef = openHomeNewTaskModal; function setHomeCompleteBtn(done) { const btn = el("pj-toggle-complete"); btn.textContent = done ? "↩ 완료 취소" : "✓ 완료로 표시"; btn.classList.toggle("is-done", done); btn.dataset.completed = done ? "1" : ""; } el("pj-toggle-complete").addEventListener("click", async function () { const id = el("pj-t-id").value; if (!id) return; const done = el("pj-toggle-complete").dataset.completed === "1"; try { const res = await api("PUT", "/project/api/tasks/" + id, { completed: !done }); setHomeCompleteBtn(!!res.task.completed_at); upsertHomeTaskRow(res.task); } catch (e) { showToast("처리 실패: " + e.message, "error"); } }); 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 () { if (descEditor && descEditor.isUploading()) { alert("이미지 업로드가 끝나면 저장해주세요."); return; } const id = el("pj-t-id").value; const pid = el("pj-t-project-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: descEditor ? descEditor.getHtml() : "", 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, 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 { if (!pid) return; res = await api("POST", "/project/api/projects/" + pid + "/tasks", payload); } upsertHomeTaskRow(res.task); homeTaskSnapshot = null; closeModal(homeTaskModal); } catch (e) { showToast("저장 실패: " + e.message, "error"); } }); el("pj-delete-task").addEventListener("click", async function () { const id = el("pj-t-id").value; if (!id) return; const pid = el("pj-t-project-id").value; if (!confirm("이 업무를 삭제할까요?")) return; try { await api("DELETE", "/project/api/tasks/" + id); removeHomeTaskRow(id, pid); closeModal(homeTaskModal); } catch (e) { showToast("삭제 실패: " + e.message, "error"); } }); document.querySelectorAll(".pj-task-popup-link").forEach(function (a) { wireHomeTaskPopupLink(a); }); } // ── 댓글 말풍선(카드/내 업무) — 더블클릭 시 댓글만 보는 채팅형 팝업 ── const commentsModal = el("pj-modal-comments"); if (commentsModal) { wireModalClose(commentsModal); function loadSeenComments() { try { return JSON.parse(localStorage.getItem("pj_seen_comments") || "{}"); } catch (_) { return {}; } } function markSeen(taskId, count) { const m = loadSeenComments(); m[taskId] = count; try { localStorage.setItem("pj_seen_comments", JSON.stringify(m)); } catch (_) {} } // 페이지 로드 시 — 마지막으로 확인한 개수보다 늘었으면 반짝임(is-unread) 표시. const seen = loadSeenComments(); document.querySelectorAll(".pj-cmt-badge").forEach(function (b) { const n = parseInt(b.dataset.count, 10) || 0; const seenN = seen[b.dataset.taskId] || 0; if (n > seenN) b.classList.add("is-unread"); }); async function renderChat(taskId) { const list = el("pj-chat-list"); list.innerHTML = "
  • 불러오는 중…
  • "; try { const res = await api("GET", "/project/api/tasks/" + taskId + "/comments"); const comments = res.comments || []; if (!comments.length) { list.innerHTML = "
  • 댓글 없음
  • "; } else { list.innerHTML = ""; comments.forEach(function (c) { const mine = (c.author_email || "").toLowerCase() === me; const li = document.createElement("li"); li.className = "pj-chat-msg" + (mine ? " mine" : ""); li.innerHTML = (mine ? "" : '' + escapeHtml(idOf(c.author_email)) + "") + '
    ' + '' + (mine ? '' : "") + '' + (c.created_at || "").slice(0, 16).replace("T", " ") + "" + ""; li.querySelector(".pj-chat-bubble").textContent = c.body; if (mine) { li.querySelector(".pj-chat-del").addEventListener("click", async function () { if (!confirm("이 댓글을 삭제할까요?")) return; try { await api("DELETE", "/project/api/comments/" + c.id); const n = await renderChat(taskId); if (n !== null) { markSeen(taskId, n); syncBadge(taskId, n); } } catch (e) { alert("삭제 실패: " + e.message); } }); } list.appendChild(li); }); list.scrollTop = list.scrollHeight; } return comments.length; } catch (e) { list.innerHTML = "
  • 불러오기 실패
  • "; return null; } } function syncBadge(taskId, n) { const badge = document.querySelector('.pj-cmt-badge[data-task-id="' + taskId + '"]'); if (!badge) return; badge.dataset.count = n; badge.title = "댓글 " + n + "개"; const numEl = badge.querySelector(".pj-cmt-n"); if (numEl) numEl.textContent = n; badge.classList.remove("is-unread"); } async function openCommentsModal(taskId) { el("pj-cm-task-id").value = taskId; const task = homeTasks[taskId]; el("pj-cm-title").textContent = (task && task.title) || "댓글"; openModal(commentsModal); const n = await renderChat(taskId); if (n !== null) { markSeen(taskId, n); syncBadge(taskId, n); } } openCommentsModalRef = openCommentsModal; document.querySelectorAll(".pj-cmt-badge").forEach(function (b) { b.addEventListener("click", function (e) { e.preventDefault(); e.stopPropagation(); }); b.addEventListener("dblclick", function (e) { e.preventDefault(); e.stopPropagation(); openCommentsModal(b.dataset.taskId); }); }); el("pj-cm-send").addEventListener("click", async function () { const taskId = el("pj-cm-task-id").value; const inp = el("pj-cm-input"); const body = inp.value.trim(); if (!taskId || !body) return; try { await api("POST", "/project/api/tasks/" + taskId + "/comments", { body: body }); inp.value = ""; const n = await renderChat(taskId); if (n !== null) { markSeen(taskId, n); syncBadge(taskId, n); } } catch (e) { alert("등록 실패: " + e.message); } }); el("pj-cm-input").addEventListener("keydown", function (e) { if (e.key === "Enter") { e.preventDefault(); el("pj-cm-send").click(); } }); } // 최초 로드된 카드 + 이후 새로 만든 카드 모두 여기서 한 번에 배선. document.querySelectorAll(".pj-project-card").forEach(wireProjectCard); } // ════════════════════════════════════════════════════════════ // 프로젝트 페이지 // ════════════════════════════════════════════════════════════ 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 me = (root.dataset.me || "").toLowerCase(); const isSuper = root.dataset.isSuper === "true"; // 삭제는 만든 사람만(슈퍼관리자 예외). created_by 없으면 서버가 최종 판정. function canDelete(createdBy) { if (isSuper) return true; const cb = (createdBy || "").toLowerCase(); return !!cb && cb === me; } const DELETE_DENIED = "삭제는 만든 사람만 할 수 있습니다."; // 댓글 있으면 말풍선 아이콘(+개수). 색은 currentColor 상속(막대=흰, 카드/리스트=진회색). function cmtBadge(t, cls) { const n = t.comment_count || 0; if (!n) return ""; return '💬' + n + ""; } const dataEl = el("pj-data"); const data = JSON.parse(dataEl.textContent); const currentProject = data.project || {}; let stages = data.stages || []; let tasks = data.tasks || []; let members = data.members || []; const priorityLabels = data.priorityLabels || {}; // 전체 프로젝트 집계 데이터 — 4개 뷰는 전 프로젝트를 보여준다. let projectsMeta = data.projects || []; const stagesByProject = data.stagesByProject || {}; const boardStages = data.boardStages || []; const avatars = data.avatars || {}; const myTasksById = {}; (data.myTasks || []).forEach(function (t) { myTasksById[t.id] = t; }); // 우측 "내 업무" 패널 — openTaskModal/mergeTask/refreshActiveView 는 이 // 함수 뒤쪽에서 선언되지만 function 선언은 호이스팅되어 여기서 참조해도 // 문제없다. 이 패널의 업무는 전부 "내가 담당자"인 게 보장돼 있어(서버가 // assignee_email 로 조회) 홈 페이지 syncMyTaskPanel 과 달리 재배정 시 // 사라지는 경우를 신경 쓸 필요 없다. function myTaskRowHtml(t) { const dates = t.start_date || t.due_date ? '' + (t.start_date && t.due_date ? fmtDateKr(t.start_date) + " ~ " + fmtDateKr(t.due_date) : t.due_date ? "~ " + fmtDateKr(t.due_date) : fmtDateKr(t.start_date) + " ~") + "" : ""; return '
  • ' + '' + '' + (t.comment_count ? '' + '💬' + t.comment_count + "" : "") + "" + '' + escapeHtml(t.title || "") + "" + dates + "" + "
  • "; } function syncProjectMyTaskPanel(t, removed) { const group = document.querySelector('.pj-project-mytasks .pj-mytree-group[data-group-id="' + t.project_id + '"] .pj-mytree-tasks'); if (!group) return; // 이 프로젝트에 내 업무가 하나도 없었으면 다음 새로고침까지 생략. const existingLink = group.querySelector('.pj-task-popup-link[data-task-id="' + t.id + '"]'); const existingLi = existingLink ? existingLink.closest("li") : null; if (removed) { if (existingLi) existingLi.remove(); return; } const wrap = document.createElement("div"); wrap.innerHTML = myTaskRowHtml(t); const newLi = wrap.firstElementChild; wireMyTaskPopupLink(newLi.querySelector(".pj-task-popup-link")); if (existingLi) existingLi.replaceWith(newLi); else group.appendChild(newLi); } // 내 업무 항목 우클릭 메뉴 — 열기(프로젝트 페이지로 이동)/업무 추가/수정 // (팝업 편집)/완료토글/삭제. "업무 추가"는 현재 열려 있는 프로젝트로만 // 새 업무를 만들 수 있는 openTaskModal(null) 의 제약 때문에, 이 업무가 // 다른 프로젝트 소속이면 그 프로젝트 페이지로 이동시켜 거기서 추가하게 한다. function myTaskContextItems(t) { return [ { label: "열기", onClick: function () { location.href = "/project/p/" + t.project_id + "?task=" + t.id; } }, { label: "업무 추가", onClick: function () { if (t.project_id === projectId) openTaskModal(null); else location.href = "/project/p/" + t.project_id; }, }, { label: "수정", onClick: function () { openTaskModal(t); } }, { label: t.completed_at ? "완료 취소" : "완료로 표시", onClick: async function () { try { const res = await api("PUT", "/project/api/tasks/" + t.id, { completed: !t.completed_at }); mergeTask(res.task); myTasksById[t.id] = Object.assign({}, t, res.task); syncProjectMyTaskPanel(myTasksById[t.id]); refreshActiveView(); } catch (e) { showToast("처리 실패: " + e.message, "error"); } }, }, null, { label: "삭제", danger: true, onClick: async function () { if (!canDelete(t.created_by)) { alert(DELETE_DENIED); return; } if (!confirm("이 업무를 삭제할까요?")) return; try { await api("DELETE", "/project/api/tasks/" + t.id); tasks = tasks.filter(function (x) { return x.id !== t.id; }); delete myTasksById[t.id]; syncProjectMyTaskPanel(t, true); refreshActiveView(); } catch (e) { showToast("삭제 실패: " + e.message, "error"); } }, }, ]; } function wireMyTaskPopupLink(a) { a.addEventListener("click", function (e) { e.preventDefault(); const t = myTasksById[a.dataset.taskId]; if (t) openTaskModal(t); }); a.addEventListener("contextmenu", function (e) { const t = myTasksById[a.dataset.taskId]; if (!t) return; e.preventDefault(); e.stopPropagation(); showContextMenu(e.clientX, e.clientY, myTaskContextItems(t)); }); } document.querySelectorAll(".pj-project-mytasks .pj-task-popup-link").forEach(function (a) { wireMyTaskPopupLink(a); }); // 담당자/작성자 아이콘 = 구글 프로필 이미지(원형). 없으면 기본 아이콘. function avatarHtml(email, cls) { const url = email ? avatars[String(email).toLowerCase()] : ""; const c = cls || ""; if (url) return ''; return 'account_circle'; } let timeline = null; // 마지막으로 보던 뷰를 기억한다 — 달력 이전/다음달 이동은 페이지를 통째로 // 새로고침하므로(?y=&m=), 기억이 없으면 달력을 보다가도 매번 기본값(보드) // 으로 튕겨나간다. const VIEW_KEY = "pj_view"; let currentView = "board"; try { const saved = localStorage.getItem(VIEW_KEY); if (saved && ["board", "list", "calendar", "timeline", "dashboard"].indexOf(saved) >= 0) currentView = saved; } catch (_) {} // ── 프로젝트 스코프 — 아사나처럼 기본은 "이 프로젝트만", "전체 프로젝트"로 // 전환하면 예전처럼 접근 가능한 모든 프로젝트를 합쳐서 보여준다. // tasks 배열의 각 행은 서버가 어느 프로젝트를 보다가 담았는지를 // context_project_id 로 표시한다(멀티호밍된 업무는 원 소속과 연결된 // 프로젝트 양쪽에서 각각 한 번씩 나온다 — 그래야 각 화면에 다 보인다). // "전체" 모드는 원 소속(context_project_id === project_id)만 세어 중복을 없앤다. let viewScope = "project"; function visibleTasks() { return viewScope === "project" ? tasks.filter(function (t) { return t.context_project_id === projectId; }) : tasks.filter(function (t) { return t.context_project_id === t.project_id; }); } // ── 뷰 토글 ── 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; try { localStorage.setItem(VIEW_KEY, view); } catch (_) {} renderView(view); }); // 서버가 그린 기본 탭(보드)과 기억된 탭이 다르면 화면을 맞춰준다. if (currentView !== "board") { tabs.querySelectorAll(".pj-tab").forEach(function (t) { t.classList.toggle("is-active", t.dataset.view === currentView); }); document.querySelectorAll(".pj-view").forEach(function (v) { v.hidden = v.dataset.view !== currentView; }); } // ── 프로젝트 스코프 토글 ── const scopeToggle = el("pj-scope-toggle"); if (scopeToggle) { scopeToggle.addEventListener("click", function (e) { const btn = e.target.closest("[data-scope]"); if (!btn) return; viewScope = btn.dataset.scope; scopeToggle.querySelectorAll("[data-scope]").forEach(function (b) { b.classList.toggle("is-active", b === btn); }); renderView(currentView); }); } function renderView(view) { if (view === "calendar") renderCalendar(); else if (view === "timeline") renderTimeline(); else if (view === "board") renderBoard(); else if (view === "list") renderList(); else if (view === "dashboard") renderDashboard(); } // ════════════════════════════════════════════════════════════ // 달력 — 구글 캘린더식. 막대(칩)는 클라이언트가 그린다. // · 한 주에 표시 행 수 제한(MAX_LANES) → 넘치면 "+N" 칩, 클릭 시 그날 목록 팝오버 // · 드래그 이동: 낙관적 업데이트(즉시 다시 그림, 새로고침 없음) + 실패 시 롤백 // ════════════════════════════════════════════════════════════ const MAX_LANES = 3; 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; } // 업무 표시 기간 [시작, 끝] (문자열, 끝>=시작). 둘 중 하나만 있으면 단일일. function taskSpan(t) { const s = t.start_date, d = t.due_date; if (s && d) return s <= d ? [s, d] : [d, s]; if (s) return [s, s]; if (d) return [d, d]; return null; } // 업무마다 '한 달 전체' 기준으로 고정 레인(행) 배정 → 여러 주에 걸쳐도 같은 줄 유지. // (주마다 따로 배치하면 첫주/다음주의 순서가 바뀌는 문제 방지) function computeLanes() { const items = []; visibleTasks().forEach(function (t) { const sp = taskSpan(t); if (sp) items.push({ id: t.id, s: sp[0], e: sp[1] }); }); // 시작일 → 종료일 → id 순(결정적). 문자열 'YYYY-MM-DD' 사전식 비교. items.sort(function (a, b) { if (a.s !== b.s) return a.s < b.s ? -1 : 1; if (a.e !== b.e) return a.e < b.e ? -1 : 1; return a.id - b.id; }); const laneEnd = []; // lane 별 마지막 점유 종료일 const map = {}; items.forEach(function (it) { for (var i = 0; i < laneEnd.length; i++) { if (it.s > laneEnd[i]) { map[it.id] = i; laneEnd[i] = it.e; return; } } map[it.id] = laneEnd.length; laneEnd.push(it.e); }); return map; } // 전체 달력 다시 그리기 — 서버가 그린 날짜 셀(.pj-day[data-date])을 읽어 주별로 막대 배치. function renderCalendar() { const cal = document.querySelector(".pj-cal"); if (!cal) return; closeDayPop(); const laneOf = computeLanes(); cal.querySelectorAll(".pj-week").forEach(function (weekEl) { const dayEls = weekEl.querySelectorAll(".pj-day[data-date]"); if (!dayEls.length) return; const weekStart = dayEls[0].dataset.date; const weekEnd = dayEls[dayEls.length - 1].dataset.date; // 이 주에 걸치는 업무 → 막대 세그먼트(레인은 전역 배정값 사용) const bars = []; visibleTasks().forEach(function (t) { const sp = taskSpan(t); if (!sp) return; if (sp[1] < weekStart || sp[0] > weekEnd) return; const segStart = sp[0] < weekStart ? weekStart : sp[0]; const segEnd = sp[1] > weekEnd ? weekEnd : sp[1]; bars.push({ t: t, startCol: dayDiff(weekStart, segStart), span: dayDiff(segStart, segEnd) + 1, cl: sp[0] < weekStart, cr: sp[1] > weekEnd, lane: laneOf[t.id] || 0, }); }); // 전역 레인 순서는 유지하되, 이 주에 실제 존재하는 레인만 0,1,2…로 압축 // (빈 행 제거 → 헛된 '+N' 방지, 단 두 막대의 상하 순서는 항상 동일). const present = {}; bars.forEach(function (b) { present[b.lane] = true; }); const lanesSorted = Object.keys(present).map(Number).sort(function (a, b) { return a - b; }); const rankOf = {}; lanesSorted.forEach(function (l, i) { rankOf[l] = i; }); const cont = weekEl.querySelector(".pj-week-bars"); cont.innerHTML = ""; const overflow = [0, 0, 0, 0, 0, 0, 0]; bars.forEach(function (b) { const row = rankOf[b.lane]; if (row >= MAX_LANES) { // 넘침 → 그 칸들 +N 카운트 for (var c = b.startCol; c < b.startCol + b.span; c++) overflow[c]++; return; } const div = document.createElement("div"); div.className = "pj-bar" + (b.t.completed_at ? " pj-bar-done" : "") + (b.cl ? " pj-bar-l" : "") + (b.cr ? " pj-bar-r" : ""); div.dataset.taskId = b.t.id; div.style.gridColumn = (b.startCol + 1) + " / span " + b.span; div.style.gridRow = (row + 1); div.style.background = b.t.completed_at ? "#9aa5b1" : (b.t.project_color || "#4573d2"); div.title = "[" + (b.t.project_name || "") + "] " + (b.t.title || "") + (b.t.assignee_email ? " · " + idOf(b.t.assignee_email) : ""); div.innerHTML = '' + escapeHtml(b.t.title || "") + "" + cmtBadge(b.t); cont.appendChild(div); }); for (var col = 0; col < 7; col++) { if (overflow[col] > 0) { const m = document.createElement("div"); m.className = "pj-bar-more"; m.style.gridColumn = (col + 1) + " / span 1"; m.style.gridRow = (MAX_LANES + 1); m.dataset.date = dayEls[col].dataset.date; m.textContent = "+" + overflow[col] + " 더"; cont.appendChild(m); } } }); wireCalendarDrag(cal); } // 드래그(포인터 기반). 안 움직이면 클릭=모달, +N 칩 클릭=그날 팝오버. function wireCalendarDrag(cal) { cal.querySelectorAll(".pj-bar[data-task-id]").forEach(function (bar) { if (canManage) bar.addEventListener("contextmenu", function (e) { e.preventDefault(); const t = tasks.find(function (x) { return String(x.id) === String(bar.dataset.taskId); }); if (t) showContextMenu(e.clientX, e.clientY, taskContextItems(t)); }); 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; 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); 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); }); }); } // 낙관적 이동 — 즉시 다시 그리고 백그라운드 저장, 실패 시 롤백. 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 old = { s: t.start_date, d: t.due_date }; t.start_date = t.start_date ? addDays(t.start_date, delta) : null; t.due_date = t.due_date ? addDays(t.due_date, delta) : null; renderCalendar(); try { await api("PUT", "/project/api/tasks/" + taskId, { start_date: t.start_date, due_date: t.due_date }); } catch (err) { t.start_date = old.s; t.due_date = old.d; renderCalendar(); alert("이동 실패: " + err.message); } } // ── "+N 더" 그날 업무 팝오버 ── let dayPop = null; function closeDayPop() { if (dayPop) { dayPop.remove(); dayPop = null; document.removeEventListener("mousedown", onDocDown, true); } } function onDocDown(e) { if (dayPop && !dayPop.contains(e.target) && !e.target.closest(".pj-bar-more")) closeDayPop(); } function openDayPopover(dateStr, anchorEl) { closeDayPop(); const list = visibleTasks().filter(function (t) { const sp = taskSpan(t); return sp && sp[0] <= dateStr && dateStr <= sp[1]; }); dayPop = document.createElement("div"); dayPop.className = "pj-daypop"; const head = document.createElement("div"); head.className = "pj-daypop-head"; head.textContent = dateStr; dayPop.appendChild(head); list.forEach(function (t) { const row = document.createElement("div"); row.className = "pj-daypop-item" + (t.completed_at ? " is-done" : ""); row.innerHTML = '' + '' + escapeHtml(t.title || "") + ""; row.addEventListener("click", function () { closeDayPop(); if (canManage) openTaskModal(t); }); dayPop.appendChild(row); }); document.body.appendChild(dayPop); const r = anchorEl.getBoundingClientRect(); let left = window.scrollX + r.left; const top = window.scrollY + r.bottom + 4; left = Math.min(left, window.scrollX + document.documentElement.clientWidth - dayPop.offsetWidth - 8); dayPop.style.left = Math.max(8, left) + "px"; dayPop.style.top = top + "px"; setTimeout(function () { document.addEventListener("mousedown", onDocDown, true); }, 0); } (function () { const cal = document.querySelector(".pj-cal"); if (cal) cal.addEventListener("click", function (e) { const more = e.target.closest(".pj-bar-more"); if (more) openDayPopover(more.dataset.date, more); }); })(); // ── 타임라인 (vis-timeline) — 프로젝트별 행(그룹), 행마다 전체 일정 배경 + 업무 막대 ── function renderTimeline() { const node = el("pj-timeline"); if (timeline) { timeline.destroy(); timeline = null; } const dated = visibleTasks().filter(function (t) { return t.start_date || t.due_date; }); if (!dated.length) { node.innerHTML = '
    기간이 설정된 업무가 없습니다.
    '; return; } // 프로젝트 메타(시작/마감/색) 조회용 const projById = {}; projectsMeta.forEach(function (p) { projById[p.id] = p; }); // "이 프로젝트" 스코프는 세션별로 행을 나눈다(아사나 타임라인=섹션 스윔레인). // "전체 프로젝트"는 예전처럼 프로젝트별로 나눈다(세션이 프로젝트마다 달라 // 하나로 묶을 수 없다). const groupBySession = viewScope === "project"; const byGroup = {}; dated.forEach(function (t) { const gid = groupBySession ? String(t.stage_id || "none") : String(t.project_id); (byGroup[gid] = byGroup[gid] || []).push(t); }); let groupOrder = Object.keys(byGroup); if (groupBySession) { const sessionOrder = (stagesByProject[projectId] || []).slice() .sort(function (a, b) { return a.sort_order - b.sort_order; }) .map(function (s) { return String(s.id); }); groupOrder = sessionOrder.filter(function (id) { return byGroup[id]; }); if (byGroup.none) groupOrder.push("none"); } const items = []; const groupRows = []; groupOrder.forEach(function (gid) { const list = byGroup[gid]; const grp = (groupBySession ? "S" : "P") + gid; const meta = groupBySession ? {} : (projById[gid] || {}); const groupColor = list[0].project_color || meta.color || "#4573d2"; const groupName = groupBySession ? (gid === "none" ? "미지정" : (list[0].stage_name || "세션")) : (list[0].project_name || meta.name || "프로젝트"); groupRows.push({ id: grp, content: escapeHtml(groupName) }); 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({ // vis 가 HTML 을 정화(sanitize)하므로 아이콘 폰트 대신 이모지로 댓글 표시. id: t.id, group: grp, content: (t.title || "") + (t.comment_count ? " 💬" + t.comment_count : ""), start: startISO, end: isRange ? endISO : undefined, type: isRange ? "range" : "point", style: "background-color:" + (t.completed_at ? "#9aa5b1" : groupColor) + ";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; }); // 배경(전체 일정) = 프로젝트 모드일 때만 프로젝트 자체 시작/마감일 우선, // 세션 모드는 세션에 자체 기간이 없으므로 업무 최소~최대만 쓴다. let bgStart = (!groupBySession && meta.start_date) ? new Date(meta.start_date + "T00:00:00").getTime() : minMs; let bgEnd = (!groupBySession && meta.due_date) ? new Date(meta.due_date + "T00:00:00").getTime() : maxMs; // 업무가 그 기간 밖에 있어도 가려지지 않게 범위를 넓힌다. bgStart = Math.min(bgStart, minMs); bgEnd = Math.max(bgEnd, maxMs); bgEnd += 86400000; // 마지막 날 하루를 덮도록 +1일 items.push({ id: "bg" + grp, group: grp, type: "background", start: new Date(bgStart), end: new Date(bgEnd), style: "background-color:" + groupColor + "22;", }); }); const groups = new vis.DataSet(groupRows); const koWd = function (d) { return ["일", "월", "화", "수", "목", "금", "토"][d.getDay()]; }; const pad2 = function (n) { return ("0" + n).slice(-2); }; // 저장된 보던 구간이 있으면 생성 옵션에 start/end 로 넣어 자동맞춤(autofit)을 막는다. // (setWindow 로 나중에 바꾸면 초기 autofit 이 rangechanged 로 덮어써 초기화됨) const saved = loadTLWindow(); const opts = { 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 ""; } }, }, }; if (saved) { opts.start = new Date(saved.start); opts.end = new Date(saved.end); } timeline = new vis.Timeline(node, new vis.DataSet(items), groups, opts); 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); }); // 배경 블록("bg"+그룹id)이나 빈 공간 우클릭은 무시 — 업무 아이템일 때만 메뉴. timeline.on("contextmenu", function (props) { if (props.event && props.event.preventDefault) props.event.preventDefault(); if (!canManage || props.item == null || String(props.item).indexOf("bg") === 0) return; const t = tasks.find(function (x) { return x.id === props.item; }); if (t) showContextMenu(props.event.clientX, props.event.clientY, taskContextItems(t)); }); // 사용자가 직접 확대/이동했을 때만 저장(autofit 등 프로그램 변경은 무시). timeline.on("rangechanged", function (p) { if (p.byUser) saveTLWindow(p.start, p.end); }); } function loadTLWindow() { try { const r = JSON.parse(localStorage.getItem("pj_tl_window") || "null"); return (r && r.start && r.end) ? r : null; } catch (_) { return null; } } function saveTLWindow(s, e) { try { localStorage.setItem("pj_tl_window", JSON.stringify({ start: new Date(s).toISOString(), end: new Date(e).toISOString() })); } catch (_) {} } // ── 보드 (칸반 = 세션) ── // scope==="project" → 이 프로젝트의 실제 세션(id 기준) + 세션 관리(이름변경· // 완료토글·삭제·추가·드래그 재정렬). // scope==="all" → 여러 프로젝트를 세션 '이름' 기준으로 합쳐 보여준다(기존 // 동작 그대로 — 세션 하나를 특정할 수 없어 관리 컨트롤은 없다). // "+ 세션 추가" 버튼은 보드(스크롤 영역) 밖 고정 위치에 있어 세션이 // 늘어나 스크롤이 생겨도 항상 보인다 — 한 번만 wiring, 표시 여부만 매 // 렌더마다 토글. const boardAddStageBtn = el("pj-board-addstage"); if (boardAddStageBtn) { boardAddStageBtn.addEventListener("click", async function () { const name = prompt("새 세션 이름"); if (!name || !name.trim()) return; try { await api("POST", "/project/api/projects/" + projectId + "/stages", { name: name.trim() }); await reloadStagesForCurrentProject(); renderBoard(); } catch (e) { alert("추가 실패: " + e.message); } }); } function renderBoard() { // pj-board(바깥)는 overflow-y:auto 스크롤 뷰포트, pj-board-canvas(안쪽)가 // 실제 매이슨리 절대배치 캔버스 — 스크롤 컨테이너와 콘텐츠 높이 계산을 // 분리해야 pj-board 의 높이가 부모(flex) 크기를 그대로 따라간다. const canvas = el("pj-board-canvas"); canvas.innerHTML = ""; const vis = visibleTasks(); if (boardAddStageBtn) boardAddStageBtn.hidden = !(viewScope === "project" && canManage); if (viewScope === "project") renderBoardByStage(canvas, vis); else renderBoardByName(canvas, vis); layoutMasonry(canvas); } // 세션(보드 컬럼) 매이슨리 배치 — 열은 왼쪽부터 순서대로 고정 배정하고, // 각 열은 자기 안에 쌓인 카드 높이만큼만 다음 세션을 그 밑에 이어붙인다 // (CSS Grid처럼 행 전체를 가장 큰 세션에 맞춰 늘리지 않는다). function layoutMasonry(board) { const items = Array.prototype.slice.call(board.children).filter(function (node) { return node.classList.contains("pj-col"); }); if (!items.length) { board.style.height = "auto"; return; } const gap = 16, minColWidth = 220, maxCols = 4; const boardWidth = board.clientWidth || 1; const cols = Math.max(1, Math.min(maxCols, Math.floor((boardWidth + gap) / (minColWidth + gap)))); const colWidth = (boardWidth - gap * (cols - 1)) / cols; const colHeights = new Array(cols).fill(0); items.forEach(function (node, i) { const c = i % cols; node.style.width = colWidth + "px"; node.style.left = (c * (colWidth + gap)) + "px"; node.style.top = colHeights[c] + "px"; colHeights[c] += node.offsetHeight + gap; }); board.style.height = Math.max.apply(null, colHeights) + "px"; } let boardResizeTimer = null; window.addEventListener("resize", function () { if (currentView !== "board") return; clearTimeout(boardResizeTimer); boardResizeTimer = setTimeout(function () { layoutMasonry(el("pj-board-canvas")); }, 120); }); function renderBoardByName(board, vis) { // 컬럼 이름 = 서버가 정렬해준 전체 세션 이름 + 세션 미지정 업무용 '미지정' let names = boardStages.slice(); const hasUnstaged = vis.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 = vis.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); }); } // 보드 드롭(전체보기) — 컬럼 이름(세션명)으로 옮긴다. 업무가 속한 '그 // 프로젝트'의 동일 이름 세션 id 를 찾아 적용(프로젝트마다 세션 id 가 다르다). function enableDropByName(body, name) { body.addEventListener("dragover", function (e) { if (!e.dataTransfer.types.includes("text/plain")) return; e.preventDefault(); body.classList.add("is-over"); }); body.addEventListener("dragleave", function () { body.classList.remove("is-over"); }); body.addEventListener("drop", async function (e) { if (!e.dataTransfer.types.includes("text/plain")) return; 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); renderBoard(); } catch (err) { alert("이동 실패: " + err.message); } }); } // ── 보드(프로젝트 스코프) — 세션은 이 프로젝트의 실제 행(id)이라 관리 UI를 // 붙일 수 있다: 이름변경·완료토글·삭제(만든 사람만)·추가·드래그 재정렬. function renderBoardByStage(board, vis) { const list = (stagesByProject[projectId] || []).slice() .sort(function (a, b) { return a.sort_order - b.sort_order; }); // 세션은 프로젝트를 만들 때 자동으로 생기지 않는다(아사나처럼 직접 추가) — // 아직 하나도 없으면 안내만 보여준다. if (!list.length && !vis.length) { const empty = document.createElement("div"); empty.className = "pj-empty"; empty.style.gridColumn = "1 / -1"; empty.textContent = "세션이 없습니다. 위 \"+ 세션 추가\"로 만들어 보세요."; board.appendChild(empty); } list.forEach(function (s) { const col = document.createElement("div"); col.className = "pj-col"; col.dataset.stageId = s.id; col.innerHTML = '
    ' + '' + escapeHtml(s.name) + "" + (s.is_done_stage ? '완료' : "") + '' + (canManage ? '' + '' + '" + '' + "" : "") + "
    "; const body = col.querySelector(".pj-col-body"); const colTasks = vis.filter(function (t) { return t.stage_id === s.id; }); col.querySelector(".pj-col-count").textContent = colTasks.length; colTasks.forEach(function (t) { body.appendChild(taskCard(t)); }); if (canManage) { enableDropByStageId(body, s.id); wireColumnControls(col, s); wireColumnDrag(col, s); } board.appendChild(col); }); const hasUnstaged = vis.some(function (t) { return !t.stage_id; }); if (hasUnstaged) { const col = document.createElement("div"); col.className = "pj-col"; col.dataset.stageName = "미지정"; col.innerHTML = '
    미지정' + '
    '; const body = col.querySelector(".pj-col-body"); const colTasks = vis.filter(function (t) { return !t.stage_id; }); col.querySelector(".pj-col-count").textContent = colTasks.length; colTasks.forEach(function (t) { body.appendChild(taskCard(t)); }); board.appendChild(col); } } async function reloadStagesForCurrentProject() { const res = await api("GET", "/project/api/projects/" + projectId + "/stages"); stages = res.stages || []; stagesByProject[projectId] = stages; } function wireColumnControls(col, s) { async function renameStage() { const name = prompt("세션 이름", s.name); if (!name || !name.trim() || name.trim() === s.name) return; try { await api("PUT", "/project/api/projects/" + projectId + "/stages/" + s.id, { name: name.trim() }); await reloadStagesForCurrentProject(); renderBoard(); } catch (err) { showToast("이름 변경 실패: " + err.message, "error"); } } async function toggleDoneStage() { try { await api("PUT", "/project/api/projects/" + projectId + "/stages/" + s.id, { is_done_stage: !s.is_done_stage }); await reloadStagesForCurrentProject(); renderBoard(); } catch (err) { showToast("변경 실패: " + err.message, "error"); } } async function deleteStage() { if (!canDelete(s.created_by)) { alert(DELETE_DENIED); return; } if (!confirm("'" + s.name + "' 세션을 삭제할까요? 이 세션의 업무는 '미지정'이 됩니다.")) return; try { await api("DELETE", "/project/api/projects/" + projectId + "/stages/" + s.id); await reloadStagesForCurrentProject(); renderBoard(); } catch (err) { showToast("삭제 실패: " + err.message, "error"); } } const renameBtn = col.querySelector(".pj-col-rename"); if (renameBtn) renameBtn.addEventListener("click", function (e) { e.stopPropagation(); renameStage(); }); const doneBtn = col.querySelector(".pj-col-toggledone"); if (doneBtn) doneBtn.addEventListener("click", function (e) { e.stopPropagation(); toggleDoneStage(); }); const delBtn = col.querySelector(".pj-col-del"); if (delBtn) delBtn.addEventListener("click", function (e) { e.stopPropagation(); deleteStage(); }); const head = col.querySelector(".pj-col-head"); if (head) head.addEventListener("contextmenu", function (e) { e.preventDefault(); showContextMenu(e.clientX, e.clientY, [ { label: "이름 변경", onClick: renameStage }, { label: s.is_done_stage ? "완료 세션 해제" : "완료 세션으로 지정", onClick: toggleDoneStage }, null, { label: "삭제", danger: true, onClick: deleteStage }, ]); }); } // 세션(컬럼) 드래그 재정렬 — 카드 드래그와 같은 네이티브 HTML5 DnD 방식이되, // dataTransfer 타입을 달리 써서(application/x-pj-stage) 카드 드롭과 안 섞인다. function wireColumnDrag(col, s) { const head = col.querySelector(".pj-col-head"); if (!head || head.getAttribute("draggable") !== "true") return; head.addEventListener("dragstart", function (e) { e.dataTransfer.setData("application/x-pj-stage", String(s.id)); e.dataTransfer.effectAllowed = "move"; col.classList.add("is-dragging"); }); head.addEventListener("dragend", function () { col.classList.remove("is-dragging"); }); col.addEventListener("dragover", function (e) { if (!e.dataTransfer.types.includes("application/x-pj-stage")) return; e.preventDefault(); col.classList.add("pj-col-drop-target"); }); col.addEventListener("dragleave", function () { col.classList.remove("pj-col-drop-target"); }); col.addEventListener("drop", async function (e) { if (!e.dataTransfer.types.includes("application/x-pj-stage")) return; e.preventDefault(); col.classList.remove("pj-col-drop-target"); const draggedId = parseInt(e.dataTransfer.getData("application/x-pj-stage"), 10); if (!draggedId || draggedId === s.id) return; const list = (stagesByProject[projectId] || []).slice() .sort(function (a, b) { return a.sort_order - b.sort_order; }); const ids = list.map(function (x) { return x.id; }); const from = ids.indexOf(draggedId), to = ids.indexOf(s.id); if (from < 0 || to < 0) return; ids.splice(from, 1); ids.splice(to, 0, draggedId); try { const res = await api("PUT", "/project/api/projects/" + projectId + "/stages/order", { ordered_ids: ids }); stages = res.stages || []; stagesByProject[projectId] = stages; renderBoard(); } catch (err) { alert("순서 변경 실패: " + err.message); } }); } // 보드 드롭(프로젝트 스코프) — 세션 id 로 바로 옮긴다. 이 프로젝트가 원 // 소속이 아닌(멀티호밍된) 업무는 원본을 안 건드리고 "이 프로젝트 안에서의 // 세션"만 연결(POST .../links)로 바꾼다. // 드롭 지점(마우스 Y)이 이 컬럼의 카드들 중 어디에 해당하는지 인덱스로. // 드래그 중인 카드 자신은 계산에서 제외(자기 위치가 기준을 흔들지 않게). function cardDropIndex(body, clientY, excludeTaskId) { const cards = Array.prototype.slice.call(body.querySelectorAll(".pj-card")) .filter(function (c) { return String(c.dataset.taskId) !== String(excludeTaskId); }); for (let i = 0; i < cards.length; i++) { const r = cards[i].getBoundingClientRect(); if (clientY < r.top + r.height / 2) return i; } return cards.length; } function enableDropByStageId(body, stageId) { body.addEventListener("dragover", function (e) { if (!e.dataTransfer.types.includes("text/plain")) return; e.preventDefault(); body.classList.add("is-over"); }); body.addEventListener("dragleave", function () { body.classList.remove("is-over"); }); body.addEventListener("drop", async function (e) { if (!e.dataTransfer.types.includes("text/plain")) return; 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) return; if (t.stage_id === stageId) { // 같은 세션 안에서 카드 위치만 이동 — 세션 이동이 아니라 정렬. const currentOrder = tasks.filter(function (x) { return x.stage_id === stageId; }); const idx = cardDropIndex(body, e.clientY, taskId); const withoutDragged = currentOrder.filter(function (x) { return x.id !== taskId; }); withoutDragged.splice(idx, 0, t); const orderedIds = withoutDragged.map(function (x) { return x.id; }); const beforeIds = currentOrder.map(function (x) { return x.id; }); if (beforeIds.join(",") === orderedIds.join(",")) return; // 놓은 자리가 원래 자리와 같으면 아무것도 안 함 try { await api("PUT", "/project/api/projects/" + projectId + "/stages/" + stageId + "/tasks/order", { ordered_ids: orderedIds }); applyStageOrder(stageId, orderedIds); renderBoard(); } catch (err) { showToast("순서 변경 실패: " + err.message, "error"); } return; } try { if (t.project_id === projectId) { const res = await api("PUT", "/project/api/tasks/" + taskId, { stage_id: stageId }); mergeTask(res.task); } else { await api("POST", "/project/api/tasks/" + taskId + "/links", { project_id: projectId, stage_id: stageId }); t.stage_id = stageId; t.is_linked = true; } renderBoard(); } catch (err) { showToast("이동 실패: " + err.message, "error"); } }); } 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; // 전체보기는 항상 원 프로젝트 배지를 보여준다. 프로젝트 스코프에서는 // "이 프로젝트가 원 소속"인 카드는 굳이 표시하지 않고, 멀티호밍으로 // 들어온(다른 프로젝트가 원 소속인) 카드만 아사나처럼 배지를 붙인다. const showProjTag = viewScope === "all" || t.project_id !== projectId; card.innerHTML = (showProjTag && t.project_name ? '
    ● ' + escapeHtml(t.project_name) + "
    " : "") + '
    ' + escapeHtml(t.title) + "
    " + '
    ' + (t.priority && t.priority !== "normal" ? '' + (priorityLabels[t.priority] || t.priority) + "" : "") + (t.assignee_email ? '' + avatarHtml(t.assignee_email, "pj-gicon-sm") + escapeHtml(idOf(t.assignee_email)) + "" : "") + (t.subtask_total ? '' + (t.subtask_done || 0) + "/" + t.subtask_total + "" : "") + (t.due_date ? '' + t.due_date + "" : "") + (t.comment_count ? '' + cmtBadge(t) + "" : "") + "
    "; 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"); }); card.addEventListener("contextmenu", function (e) { e.preventDefault(); showContextMenu(e.clientX, e.clientY, taskContextItems(t)); }); } return card; } // ── 리스트 (정렬·컬럼 너비 조절) ── // "전체 프로젝트" 스코프 — 세션이 프로젝트마다 달라 그룹화 의미가 없으므로 // 예전처럼 프로젝트·세션을 컬럼으로 둔 평범한 정렬 테이블. const LIST_COLS_ALL = [ { 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 || ""; } }, ]; // "이 프로젝트" 스코프 — 아사나 리스트 뷰처럼 세션이 컬럼이 아니라 그룹 // 구분선이 된다(renderListGrouped). 그래서 세션 컬럼은 뺀다. const LIST_COLS_PROJECT = LIST_COLS_ALL.filter(function (c) { return c.key !== "project" && c.key !== "stage"; }); function listCols() { return viewScope === "project" ? LIST_COLS_PROJECT : LIST_COLS_ALL; } 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 sortRows(rows, cols) { if (!listSort.key) return rows; const col = cols.find(function (c) { return c.key === listSort.key; }); if (!col) return rows; 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; }); return rows; } function renderListHead(table, cols) { const widths = listWidths(); const trh = document.createElement("tr"); 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); } function taskRow(t, cols) { const tr = document.createElement("tr"); if (t.completed_at) tr.className = "is-done"; // 행 배경 = 프로젝트 색 연하게(10%) — 다른 프로젝트에서 온(멀티호밍) 업무를 // 구분하기 위함. 이 프로젝트 고유 업무는 색이 다 같아 사실상 안 보인다. const pc = t.project_color || "#4573d2"; tr.style.background = pc + "14"; tr.innerHTML = cols.map(function (c) { let inner = escapeHtml(String(c.text(t))); if (c.key === "title") inner += cmtBadge(t); if (c.key === "assignee" && t.assignee_email) { inner = '' + avatarHtml(t.assignee_email, "pj-gicon-sm") + escapeHtml(idOf(t.assignee_email)) + ""; } return "" + inner + ""; }).join(""); if (canManage) { tr.style.cursor = "pointer"; tr.addEventListener("click", function () { openTaskModal(t); }); tr.addEventListener("contextmenu", function (e) { e.preventDefault(); showContextMenu(e.clientX, e.clientY, taskContextItems(t)); }); } return tr; } function listCollapsedSet() { try { return new Set(JSON.parse(localStorage.getItem("pj_list_collapsed") || "[]")); } catch (_) { return new Set(); } } function toggleListCollapsed(key) { const s = listCollapsedSet(); if (s.has(key)) s.delete(key); else s.add(key); try { localStorage.setItem("pj_list_collapsed", JSON.stringify(Array.from(s))); } catch (_) {} } function renderList() { const table = el("pj-list"); table.classList.add("pj-table-fixed"); const cols = listCols(); renderListHead(table, cols); const tbody = table.querySelector("tbody"); tbody.innerHTML = ""; if (viewScope !== "project") { sortRows(visibleTasks(), cols).forEach(function (t) { tbody.appendChild(taskRow(t, cols)); }); return; } // ── 아사나 리스트 뷰처럼 세션을 그룹 구분선으로 ── renderListGrouped(tbody, cols); } function renderListGrouped(tbody, cols) { const vis = visibleTasks(); const sessions = (stagesByProject[projectId] || []).slice() .sort(function (a, b) { return a.sort_order - b.sort_order; }); const collapsed = listCollapsedSet(); const colSpan = cols.length; function addGroup(key, name, groupTasks) { const head = document.createElement("tr"); head.className = "pj-list-group-row"; const isCollapsed = collapsed.has(key); const td = document.createElement("td"); td.colSpan = colSpan; td.className = "pj-list-group-head"; td.innerHTML = '' + (isCollapsed ? "chevron_right" : "expand_more") + "" + '' + escapeHtml(name) + "" + '' + groupTasks.length + ""; td.addEventListener("click", function () { toggleListCollapsed(key); renderList(); }); head.appendChild(td); tbody.appendChild(head); if (!isCollapsed) sortRows(groupTasks.slice(), cols).forEach(function (t) { tbody.appendChild(taskRow(t, cols)); }); } sessions.forEach(function (s) { addGroup("s" + s.id, s.name, vis.filter(function (t) { return t.stage_id === s.id; })); }); const unstaged = vis.filter(function (t) { return !t.stage_id; }); if (unstaged.length) addGroup("none", "미지정", unstaged); if (!sessions.length && !unstaged.length) { const tr = document.createElement("tr"); const td = document.createElement("td"); td.colSpan = colSpan; td.className = "pj-empty-sm"; td.textContent = "세션이 없습니다."; tr.appendChild(td); tbody.appendChild(tr); } } // ════════════════════════════════════════════════════════════ // 대시보드 — 통계 카드 + 차트 3종. 전부 이미 로드된 tasks(visibleTasks()) // 로 클라이언트에서 즉시 집계 — 서버 재조회 없음. 스코프 토글(이 // 프로젝트/전체 프로젝트)도 다른 뷰와 똑같이 따른다. // ════════════════════════════════════════════════════════════ function animateCount(el, target, duration) { const start = 0; const t0 = performance.now(); function step(now) { const p = Math.min(1, (now - t0) / duration); const eased = 1 - Math.pow(1 - p, 3); // ease-out cubic el.textContent = Math.round(start + (target - start) * eased); if (p < 1) requestAnimationFrame(step); else el.textContent = String(target); } requestAnimationFrame(step); } function dashStatTile(label, value) { const tile = document.createElement("div"); tile.className = "pj-dash-stat"; tile.innerHTML = '
    ' + '
    0
    '; tile.querySelector(".pj-dash-stat-label").textContent = label; const valEl = tile.querySelector(".pj-dash-stat-value"); animateCount(valEl, value, 700); return tile; } // 세로 막대 그래프 — 섹션별/담당자별 공용. data = [{label, value}]. function dashBarChart(data) { const wrap = document.createElement("div"); wrap.className = "pj-dash-bars"; if (!data.length) { wrap.innerHTML = "
    데이터가 없습니다.
    "; return wrap; } const max = Math.max.apply(null, data.map(function (d) { return d.value; }).concat([1])); const tooltip = dashTooltip(); data.forEach(function (d) { const col = document.createElement("div"); col.className = "pj-dash-barcol"; col.tabIndex = 0; col.innerHTML = '
    ' + d.value + "
    " + '
    ' + '
    '; col.querySelector(".pj-dash-barcol-label").textContent = d.label; const fill = col.querySelector(".pj-dash-barcol-fill"); const pct = Math.round((d.value / max) * 100); requestAnimationFrame(function () { requestAnimationFrame(function () { fill.style.height = pct + "%"; }); }); function showTip() { tooltip.show(col, d.label, d.value); } col.addEventListener("mouseenter", showTip); col.addEventListener("focus", showTip); col.addEventListener("mouseleave", tooltip.hide); col.addEventListener("blur", tooltip.hide); wrap.appendChild(col); }); return wrap; } // 우클릭 메뉴와 같은 자리에 하나만 떠 있는 공용 호버 툴팁(값 강조, 카테고리는 보조). function dashTooltip() { let el = null; return { show: function (anchor, label, value) { if (!el) { el = document.createElement("div"); el.className = "pj-dash-tooltip"; document.body.appendChild(el); } el.innerHTML = ''; el.querySelector("b").textContent = value; el.querySelector("span").textContent = label; const r = anchor.getBoundingClientRect(); el.style.left = (window.scrollX + r.left + r.width / 2) + "px"; el.style.top = (window.scrollY + r.top - 8) + "px"; el.hidden = false; }, hide: function () { if (el) el.hidden = true; }, }; } // 도넛(완료 상태별) — SVG stroke-dasharray 로 두 구간을 그린다. function dashDonutChart(segments) { const total = segments.reduce(function (s, x) { return s + x.value; }, 0); const R = 52, C = 2 * Math.PI * R; const wrap = document.createElement("div"); wrap.className = "pj-dash-donut-wrap"; let svg = '' + ''; let offsetAcc = 0; segments.forEach(function (s, i) { const frac = total ? s.value / total : 0; const len = frac * C; svg += ''; offsetAcc += len; }); svg += ''; wrap.innerHTML = svg; wrap.querySelector(".pj-dash-donut-center").textContent = total; const legend = document.createElement("div"); legend.className = "pj-dash-legend"; segments.forEach(function (s) { const item = document.createElement("div"); item.className = "pj-dash-legend-item"; item.innerHTML = ' '; item.querySelector(".pj-dash-legend-dot").style.background = s.color; item.querySelector(".pj-dash-legend-label").textContent = s.label; item.querySelector("b").textContent = s.value; legend.appendChild(item); }); wrap.appendChild(legend); // 다음 프레임에 목표 길이로 트랜지션(애니메이션이 실제로 재생되도록 한 틱 늦춘다). requestAnimationFrame(function () { requestAnimationFrame(function () { const arcs = wrap.querySelectorAll(".pj-dash-donut-seg"); segments.forEach(function (s, i) { const frac = total ? s.value / total : 0; const len = frac * C; arcs[i].setAttribute("stroke-dasharray", len + " " + C); }); }); }); return wrap; } function renderDashboard() { const root = el("pj-dash"); if (!root) return; root.innerHTML = ""; const vis = visibleTasks().filter(function (t) { return !t.parent_task_id; }); // 하위 업무는 보드에도 안 보이므로 제외 const today = new Date().toISOString().slice(0, 10); const completed = vis.filter(function (t) { return !!t.completed_at; }); const incomplete = vis.filter(function (t) { return !t.completed_at; }); const overdue = incomplete.filter(function (t) { return t.due_date && t.due_date.slice(0, 10) < today; }); const stats = document.createElement("div"); stats.className = "pj-dash-stats"; stats.appendChild(dashStatTile("완료된 작업 합계", completed.length)); stats.appendChild(dashStatTile("미완료 작업 합계", incomplete.length)); stats.appendChild(dashStatTile("마감일이 지난 작업 합계", overdue.length)); stats.appendChild(dashStatTile("작업 합계", vis.length)); root.appendChild(stats); const charts = document.createElement("div"); charts.className = "pj-dash-charts"; // 섹션별 미완료 — "이 프로젝트" 스코프면 보드 컬럼과 같은 순서로. const sectionOrder = viewScope === "project" ? (stagesByProject[projectId] || []).slice().sort(function (a, b) { return a.sort_order - b.sort_order; }).map(function (s) { return s.name; }) : boardStages.slice(); const bySection = {}; incomplete.forEach(function (t) { const k = t.stage_name || "미지정"; bySection[k] = (bySection[k] || 0) + 1; }); const sectionNames = sectionOrder.filter(function (n) { return bySection[n]; }); Object.keys(bySection).forEach(function (n) { if (sectionNames.indexOf(n) < 0) sectionNames.push(n); }); const sectionData = sectionNames.map(function (n) { return { label: n, value: bySection[n] }; }); const sectionCard = document.createElement("div"); sectionCard.className = "pj-dash-card"; sectionCard.innerHTML = '
    미완료 작업 합계(섹션별)
    '; sectionCard.appendChild(dashBarChart(sectionData)); charts.appendChild(sectionCard); // 완료 상태별 — 2구간 도넛(완료=초록, 미완료=포인트 컬러). const statusCard = document.createElement("div"); statusCard.className = "pj-dash-card"; statusCard.innerHTML = '
    작업 합계(완료 상태별)
    '; statusCard.appendChild(dashDonutChart([ { label: "완료", value: completed.length, color: "var(--color-success-green, #2e9e5b)" }, { label: "미완료", value: incomplete.length, color: "var(--pj-accent)" }, ])); charts.appendChild(statusCard); // 담당자별 예정된 업무 — 미완료 + 일정(시작/마감일) 있는 업무만, 상위 10명. const byAssignee = {}; incomplete.forEach(function (t) { if (!t.start_date && !t.due_date) return; const k = t.assignee_email ? (t.assignee_name || idOf(t.assignee_email)) : "미지정"; byAssignee[k] = (byAssignee[k] || 0) + 1; }); let assigneeEntries = Object.keys(byAssignee).map(function (k) { return { label: k, value: byAssignee[k] }; }) .sort(function (a, b) { return b.value - a.value; }); if (assigneeEntries.length > 10) { const rest = assigneeEntries.slice(10).reduce(function (s, x) { return s + x.value; }, 0); assigneeEntries = assigneeEntries.slice(0, 10).concat([{ label: "기타", value: rest }]); } const assigneeCard = document.createElement("div"); assigneeCard.className = "pj-dash-card pj-dash-card-wide"; assigneeCard.innerHTML = '
    예정된 작업 합계(담당자별)
    '; assigneeCard.appendChild(dashBarChart(assigneeEntries)); charts.appendChild(assigneeCard); root.appendChild(charts); } 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); } // 같은 세션 안 카드 드래그 재정렬 — tasks 배열은 여러 세션이 뒤섞인 // 하나의 배열이라, 이 세션이 차지하던 자리(인덱스)만 그대로 두고 그 // 자리에 들어갈 값만 새 순서대로 바꿔치기한다(다른 세션 업무의 상대 // 위치는 건드리지 않는다). function applyStageOrder(stageId, orderedIds) { const orderMap = {}; orderedIds.forEach(function (id, i) { orderMap[id] = i; }); const idxs = []; tasks.forEach(function (t, i) { if (t.stage_id === stageId) idxs.push(i); }); const sorted = idxs.map(function (i) { return tasks[i]; }) .sort(function (a, b) { return orderMap[a.id] - orderMap[b.id]; }); idxs.forEach(function (i, k) { tasks[i] = sorted[k]; }); } function refreshActiveView() { // 모든 뷰 + 좌측 트리 클라이언트 재렌더(새로고침 없음). renderView(currentView); if (typeof renderTree === "function") renderTree(); } // 업무 우클릭 메뉴 공용 항목 — 보드 카드·리스트 행·달력 막대·타임라인 // 아이템 4곳이 전부 이 함수 하나를 쓴다(열기/완료토글/삭제, 각각 기존 // 버튼과 동일한 API 경로·권한 검사를 그대로 재사용). function taskContextItems(t) { return [ { label: "열기", onClick: function () { openTaskModal(t); } }, { label: t.completed_at ? "완료 취소" : "완료로 표시", onClick: async function () { try { const res = await api("PUT", "/project/api/tasks/" + t.id, { completed: !t.completed_at }); mergeTask(res.task); refreshActiveView(); } catch (e) { showToast("처리 실패: " + e.message, "error"); } }, }, null, { label: "삭제", danger: true, onClick: async function () { if (!canDelete(t.created_by)) { alert(DELETE_DENIED); return; } if (!confirm("이 업무를 삭제할까요?")) return; try { await api("DELETE", "/project/api/tasks/" + t.id); tasks = tasks.filter(function (x) { return x.id !== t.id; }); refreshActiveView(); } catch (e) { showToast("삭제 실패: " + e.message, "error"); } }, }, ]; } // ── 업무 모달 ── const taskModal = el("pj-modal-task"); let currentTaskId = null; const descEditor = initDescEditor(); const subtaskBlock = initSubtaskBlock(); const linksBlock = initLinksBlock(); if (descEditor) { descEditor.setOnImageRemoved(function () { const tid = el("pj-t-id").value; if (tid) loadAttachments(tid); }); } // 닫을 때(배경 클릭·Esc·취소 버튼) 수정한 내용이 있으면 저장할지 물어본다. // 하위업무/첨부/댓글/연결은 각자 즉시 저장돼서 여기서 추적할 필요 없다 — // "저장" 버튼이 실제로 보내는 필드만 스냅샷으로 비교. let taskSnapshot = null; function taskFormSnapshot() { return JSON.stringify({ title: el("pj-t-title").value, assignee: el("pj-t-assignee").value, stage: el("pj-t-stage").value, priority: el("pj-t-priority").value, start: el("pj-t-start").value, due: el("pj-t-due").value, usetime: el("pj-t-usetime").checked, stime: el("pj-t-stime").value, dtime: el("pj-t-dtime").value, desc: descEditor ? descEditor.getHtml() : "", }); } function isTaskDirty() { return taskSnapshot !== null && taskFormSnapshot() !== taskSnapshot; } let taskClosing = false; async function attemptCloseTaskModal() { // erpConfirm 도 Esc 로 닫히는데 stopPropagation 을 안 해서, 확인창이 // 뜬 상태에서 Esc 를 누르면 이 함수가 다시 불릴 수 있다 — 재진입 방지. if (taskModal.hidden || taskClosing) return; taskClosing = true; try { if (isTaskDirty()) { const save = await window.erpConfirm( "수정한 내용이 있습니다. 저장하시겠습니까?", { okText: "저장", cancelText: "저장 안 함" } ); if (save) { el("pj-save-task").click(); return; } } taskSnapshot = null; closeModal(taskModal); } finally { taskClosing = false; } } taskModal.addEventListener("click", function (e) { if (e.target === taskModal || e.target.hasAttribute("data-close")) attemptCloseTaskModal(); }); document.addEventListener("keydown", function (e) { if (e.key === "Escape" && !taskModal.hidden) { e.preventDefault(); attemptCloseTaskModal(); } }); async function loadTaskExtras(task) { if (!task || (!subtaskBlock && !linksBlock)) return; try { const res = await api("GET", "/project/api/tasks/" + task.id); const detail = res.task; if (subtaskBlock) { subtaskBlock.setContext({ taskId: task.id, projectId: task.project_id, canDelete: canDelete }); subtaskBlock.render(detail.subtasks || []); } if (linksBlock) { linksBlock.setContext({ taskId: task.id, projectId: task.project_id, onChange: function () { loadTaskExtras(task); }, }); linksBlock.render(detail.links || []); } } catch (_) { /* 조용히 무시 — 본편집은 계속 가능해야 한다 */ } } // 단계 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(""); } // 담당자 select — 업무가 현재 로드된 프로젝트가 아니면 그 프로젝트 멤버를 새로 불러온다 // (전체 뷰/좌측 트리에서 타 프로젝트 업무를 열 때 담당자 옵션이 비어 보이는 문제 방지). async function fillAssigneeSelect(projId, currentEmail) { const sel = el("pj-t-assignee"); let list = members; if (projId != null && projId !== projectId) { try { list = (await api("GET", "/project/api/projects/" + projId + "/members")).members || []; } catch (_) { list = []; } } sel.innerHTML = '' + list.map(function (m) { const name = idOf(m.user_email); return '"; }).join(""); sel.value = currentEmail || ""; } function openTaskModal(task) { const projName = task ? (task.project_name || currentProject.name || "") : (currentProject.name || ""); el("pj-task-modal-title").textContent = "[" + projName + "]의 " + (task ? "업무 편집" : "새 업무"); el("pj-t-id").value = task ? task.id : ""; el("pj-t-title").value = task ? task.title : ""; if (descEditor) { descEditor.setTaskId(task ? task.id : null); descEditor.setHtml(task ? (task.description || "") : ""); } fillAssigneeSelect(task ? task.project_id : projectId, 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; el("pj-toggle-complete").hidden = !task; if (task) setCompleteBtn(!!task.completed_at); // 댓글·첨부·하위업무·연결은 기존 업무 편집 시에만 currentTaskId = task ? task.id : null; el("pj-attach-block").hidden = !task; el("pj-comment-block").hidden = !task; if (subtaskBlock) el("pj-subtask-block").hidden = !task; if (linksBlock) el("pj-links-block").hidden = !task; if (task) { loadAttachments(task.id); loadComments(task.id); loadTaskExtras(task); } openModal(taskModal); taskSnapshot = taskFormSnapshot(); } function setCompleteBtn(done) { const btn = el("pj-toggle-complete"); btn.textContent = done ? "↩ 완료 취소" : "✓ 완료로 표시"; btn.classList.toggle("is-done", done); btn.dataset.completed = done ? "1" : ""; } el("pj-toggle-complete").addEventListener("click", async function () { const id = el("pj-t-id").value; if (!id) return; const done = el("pj-toggle-complete").dataset.completed === "1"; try { const res = await api("PUT", "/project/api/tasks/" + id, { completed: !done }); mergeTask(res.task); setCompleteBtn(!!res.task.completed_at); refreshActiveView(); } catch (e) { alert("처리 실패: " + e.message); } }); // ── 첨부파일 ── 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"); // 메모리 업무의 댓글 수 동기화(아이콘 표시용) const tt = tasks.find(function (x) { return String(x.id) === String(taskId); }); if (tt) tt.comment_count = res.comments.length; 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 = avatarHtml(c.author_email, "pj-gicon-sm") + '
    ' + 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 () { if (descEditor && descEditor.isUploading()) { alert("이미지 업로드가 끝나면 저장해주세요."); return; } 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: descEditor ? descEditor.getHtml() : "", 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); taskSnapshot = null; 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) return; const t = tasks.find(function (x) { return String(x.id) === String(id); }); if (t && !canDelete(t.created_by)) { alert(DELETE_DENIED); return; } if (!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 treeProjLi(p) { let acts = ""; if (isAdmin) { acts = '' + '' + '' + ""; } const initial = escapeHtml((p.name || "?").trim().charAt(0).toUpperCase() || "?"); return '
  • ' + '' + '' + initial + "" + '' + escapeHtml(p.name) + "" + acts + "
  • "; } async function deleteTreeProject(id, name, createdBy) { if (!canDelete(createdBy)) { alert(DELETE_DENIED); return; } if (!confirm("'" + (name || "") + "' 프로젝트를 삭제할까요? 안의 업무도 모두 삭제됩니다.")) return; try { await api("DELETE", "/project/api/projects/" + id); if (String(id) === String(projectId)) { location.href = "/project/"; return; } projectsMeta = projectsMeta.filter(function (x) { return String(x.id) !== String(id); }); renderTree(); } catch (err) { showToast("삭제 실패: " + err.message, "error"); } } function wireTreeHandlers(rootUl) { rootUl.querySelectorAll(".pj-tree-del").forEach(function (b) { b.addEventListener("click", function (e) { e.preventDefault(); e.stopPropagation(); deleteTreeProject(b.dataset.id, b.dataset.name, b.dataset.creator); }); }); rootUl.querySelectorAll(".pj-tree-edit").forEach(function (b) { b.addEventListener("click", function (e) { e.preventDefault(); e.stopPropagation(); openProjectEditModal(b.dataset); }); }); if (isAdmin) rootUl.querySelectorAll(".pj-tree-li").forEach(function (li) { li.addEventListener("contextmenu", function (e) { e.preventDefault(); const editBtn = li.querySelector(".pj-tree-edit"); const delBtn = li.querySelector(".pj-tree-del"); showContextMenu(e.clientX, e.clientY, [ { label: "열기", onClick: function () { location.href = "/project/p/" + li.dataset.id; } }, { label: "수정", onClick: function () { if (editBtn) openProjectEditModal(editBtn.dataset); } }, null, { label: "삭제", danger: true, onClick: function () { if (delBtn) deleteTreeProject(delBtn.dataset.id, delBtn.dataset.name, delBtn.dataset.creator); }, }, ]); }); }); } function renderTree() { const rootUl = el("pj-tree-root"); if (!rootUl) return; rootUl.innerHTML = projectsMeta.map(treeProjLi).join(""); wireTreeHandlers(rootUl); } function mergeProject(p) { const i = projectsMeta.findIndex(function (x) { return String(x.id) === String(p.id); }); if (i >= 0) projectsMeta[i] = Object.assign({}, projectsMeta[i], p); else projectsMeta.push(p); renderTree(); if (String(p.id) === String(projectId)) { Object.assign(currentProject, p); const titleEl = document.querySelector(".pj-project-title"); if (titleEl) titleEl.textContent = currentProject.name || ""; } } renderTree(); // 새 최상위 프로젝트 (관리자) const newProjSide = el("pj-new-project-side"); const newProjModal = el("pj-modal-project"); if (newProjSide && newProjModal) { wireModalClose(newProjModal); const getNewProjColor = renderColorPalette(el("pj-f-colors")); const newProjMembersBox = el("pj-f-members"); newProjSide.addEventListener("click", async function () { openModal(newProjModal); if (newProjMembersBox) { // 멤버 배정은 관리자 전용 API — 관리자가 아니면 시도하지 않고 안내만. if (!isAdmin) { newProjMembersBox.innerHTML = "멤버 배정은 관리자가 나중에 할 수 있습니다."; } else { newProjMembersBox.innerHTML = "불러오는 중…"; try { const res = await api("GET", "/project/api/assignable-users"); const users = res.users || []; newProjMembersBox.innerHTML = users.length ? users.map(memberPickHtml).join("") : "배정 가능한 직원이 없습니다."; } catch (e) { newProjMembersBox.innerHTML = "불러오기 실패"; } } } }); el("pj-save-project").addEventListener("click", async function () { const name = el("pj-f-name").value.trim(); if (!name) { alert("이름을 입력하세요."); return; } const memberEmails = newProjMembersBox ? Array.from(newProjMembersBox.querySelectorAll("input[type=checkbox]:checked")).map(function (c) { return c.value; }) : []; try { const res = 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: getNewProjColor(), }); const pid = res.project.id; for (const email of memberEmails) { try { await api("POST", "/project/api/projects/" + pid + "/members", { user_email: email }); } catch (_) {} } stagesByProject[pid] = []; mergeProject(res.project); closeModal(newProjModal); el("pj-f-name").value = ""; el("pj-f-desc").value = ""; el("pj-f-start").value = ""; el("pj-f-due").value = ""; } catch (e) { showToast("생성 실패: " + e.message, "error"); } }); } // ── 좌측 프로젝트 목록 ✎ 버튼 — 프로젝트 수정 모달(홈 화면과 동일). // 트리는 renderTree() 로 매번 다시 그려지므로(라이브 새로고침 포함) 열기 // 로직을 재사용 가능한 함수로 두고, wireTreeHandlers 에서 매번 다시 건다. const editModal = el("pj-modal-edit"); let getEditColor = null; function openProjectEditModal(data) { el("pj-e-id").value = data.id; el("pj-e-name").value = data.name || ""; el("pj-e-desc").value = data.desc || ""; el("pj-e-start").value = data.start || ""; el("pj-e-due").value = data.due || ""; el("pj-e-done").checked = data.status === "completed"; getEditColor = renderColorPalette(el("pj-e-colors"), null, data.color); openModal(editModal); } if (editModal) { wireModalClose(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 { const res = 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", }); mergeProject(res.project); closeModal(editModal); } catch (e) { showToast("저장 실패: " + e.message, "error"); } }); el("pj-delete-project").addEventListener("click", function () { const id = el("pj-e-id").value; const p = projectsMeta.find(function (x) { return String(x.id) === String(id); }); closeModal(editModal); deleteTreeProject(id, p && p.name, p && p.created_by); }); } // ── 멤버 배정/제외 (관리자) ── const memberModal = el("pj-modal-member"); wireModalClose(memberModal); function memberRowHtml(m) { const url = avatars[String(m.user_email).toLowerCase()]; return "
  • " + (url ? '' : 'account_circle') + '' + escapeHtml(idOf(m.user_email)) + "" + (m.role === "manager" ? '관리' : "") + (isAdmin ? '' : "") + "
  • "; } async function removeMember(email) { if (!confirm("이 멤버를 프로젝트에서 제외할까요?")) return; try { await api("DELETE", "/project/api/projects/" + projectId + "/members/" + encodeURIComponent(email)); members = members.filter(function (m) { return m.user_email !== email; }); renderMemberList(); } catch (e) { showToast("제외 실패: " + e.message, "error"); } } function renderMemberList() { const ul = el("pj-member-list"); if (!ul) return; ul.innerHTML = members.length ? members.map(memberRowHtml).join("") : "
  • 없음
  • "; ul.querySelectorAll(".pj-del-member").forEach(function (b) { b.addEventListener("click", function () { removeMember(b.dataset.email); }); }); if (isAdmin) ul.querySelectorAll("li[data-email]").forEach(function (li) { li.addEventListener("contextmenu", function (e) { e.preventDefault(); showContextMenu(e.clientX, e.clientY, [ { label: "제외", danger: true, onClick: function () { removeMember(li.dataset.email); } }, ]); }); }); } renderMemberList(); const addMemberBtn = el("pj-add-member"); if (addMemberBtn) addMemberBtn.addEventListener("click", async function () { const box = el("pj-m-users"); openModal(memberModal); box.innerHTML = "불러오는 중…"; try { const res = await api("GET", "/project/api/assignable-users"); const existing = new Set(members.map(function (m) { return m.user_email; })); const candidates = res.users.filter(function (u) { return !existing.has(u.email); }); box.innerHTML = candidates.length ? candidates.map(memberPickHtml).join("") : "배정 가능한 직원이 없습니다(권한 미부여 또는 전원 배정됨)"; } catch (e) { box.innerHTML = "불러오기 실패: " + escapeHtml(e.message) + ""; } }); const saveMemberBtn = el("pj-save-member"); if (saveMemberBtn) saveMemberBtn.addEventListener("click", async function () { const box = el("pj-m-users"); const checked = box ? Array.from(box.querySelectorAll("input[type=checkbox]:checked")) : []; if (!checked.length) { alert("직원을 선택하세요."); return; } const role = el("pj-m-role").value; let failCount = 0; for (const cb of checked) { try { const res = await api("POST", "/project/api/projects/" + projectId + "/members", { user_email: cb.value, user_name: cb.dataset.name || "", role: role, }); members.push(res.member); } catch (e) { failCount++; } } renderMemberList(); closeModal(memberModal); if (failCount) showToast(failCount + "명 배정 실패", "error"); }); // 최초 렌더 — 아사나처럼 프로젝트에 들어오면 보드(세션)가 기본 화면이다 // (달력을 보던 중이었다면 그 기억된 뷰를 그린다, 위 VIEW_KEY 복원 참고). renderView(currentView); // 사이드 트리에서 업무 클릭(?task=ID)으로 들어오면 해당 업무 모달 자동 열기. // 단, 처리 후 URL 에서 task 파라미터를 제거한다 — 같은 페이지에 머무는 동안 // 다시 열리지 않도록. 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); } // 8초 주기 실시간 동기화(initLiveRefresh)가 전체 새로고침 대신 부를 함수 — // 이 프로젝트의 업무·세션·멤버만 재조회해 로컬 상태에 patch(입력 중인 // 모달 폼은 건드리지 않는다). "전체 프로젝트" 스코프에 걸린 다른 // 프로젝트의 변경이나 세션 이름변경/재정렬/삭제·멤버 제외·댓글 수정/삭제는 // live-version 자체가 감지 못하는 기존 사각지대 — 다음 새로고침까지 반영 안 됨. softSyncProjectRef = async function () { try { const [tRes, sRes, mRes] = await Promise.all([ api("GET", "/project/api/projects/" + projectId + "/tasks"), api("GET", "/project/api/projects/" + projectId + "/stages"), api("GET", "/project/api/projects/" + projectId + "/members"), ]); // 서버 페이지 렌더 시엔 router.py 가 각 업무에 context_project_id(=지금 // 보는 프로젝트 id)를 붙여준다(멀티호밍 스코프 필터의 기준) — 이 API // 응답엔 그 필드가 없으므로 직접 붙여야 visibleTasks() 필터에서 // 안 사라진다. (tRes.tasks || []).forEach(function (t) { t.context_project_id = projectId; mergeTask(t); }); stages = sRes.stages || []; stagesByProject[projectId] = stages; members = mRes.members || []; renderMemberList(); refreshActiveView(); } catch (_) {} }; } // ════════════════════════════════════════════════════════════ // 업무 모달 공용 확장 — 하위 업무 · 연결된 프로젝트(멀티호밍) · 설명 이미지 편집기 // 홈(index.html)과 프로젝트(project.html) 페이지가 업무 모달의 요소 id 를 // 그대로 공유하므로(각자 모달을 따로 갖지만 id는 같다) 여기 한 번만 만들어 // 양쪽 initHome()/initProject() 에서 그대로 쓴다. // ════════════════════════════════════════════════════════════ // ── 설명(contenteditable) — 붙여넣기/업로드로 이미지를 끼워 넣는다 ── function initDescEditor() { const box = el("pj-t-desc"); if (!box) return null; const wrap = box.parentElement; const imgInput = el("pj-desc-img-input"); const hint = el("pj-desc-hint"); let taskId = null; // ── 서식 도구 모음(간단 워드프로세서) — execCommand 는 구식 API지만 // 크롬/엣지는 여전히 지원하고, 이미 같은 파일의 이미지 삽입에서도 쓰고 // 있어(insertHTML) 일관된 패턴이다. 버튼 mousedown 에서 preventDefault // 안 하면 클릭 순간 에디터가 포커스를 잃어 선택 범위가 풀린다. const fmtbar = el("pj-desc-fmtbar"); if (fmtbar) { fmtbar.addEventListener("mousedown", function (e) { e.preventDefault(); }); fmtbar.addEventListener("click", function (e) { const btn = e.target.closest(".pj-desc-fmtbtn"); if (!btn) return; box.focus(); const cmd = btn.dataset.cmd; let arg = btn.dataset.arg || null; if (cmd === "createLink") { arg = prompt("연결할 주소(URL)를 입력하세요", "https://"); if (!arg) return; } try { document.execCommand(cmd, false, arg); } catch (_) {} updateFmtState(); }); function updateFmtState() { fmtbar.querySelectorAll(".pj-desc-fmtbtn[data-cmd]").forEach(function (btn) { const cmd = btn.dataset.cmd; if (cmd === "createLink" || cmd === "removeFormat") return; let active = false; try { active = document.queryCommandState(cmd); } catch (_) { active = false; } btn.classList.toggle("is-active", active); }); } box.addEventListener("keyup", updateFmtState); box.addEventListener("mouseup", updateFmtState); box.addEventListener("focus", updateFmtState); } // ── 설명란 이미지 선택 → 크기 조절 핸들 + 삭제 버튼 오버레이 ── // 크기는 style이 아니라 width 속성으로 저장한다(서버 sanitize가 style은 // 버리지만 img width/height 속성은 허용 목록에 있어 저장 후에도 유지된다). const imgTools = document.createElement("div"); imgTools.className = "pj-img-tools"; imgTools.hidden = true; imgTools.innerHTML = '' + ''; wrap.appendChild(imgTools); const imgDelBtn = imgTools.querySelector(".pj-img-del"); const imgHandle = imgTools.querySelector(".pj-img-handle"); let selectedImg = null; let onImageRemoved = null; // 설명란 이미지는 첨부파일 업로드로 들어간 실제 첨부 레코드다 — 에디터에서 // 지우면 첨부파일 목록에도 안 남게 서버 첨부도 같이 삭제한다. async function deleteSelectedImage() { if (!selectedImg) return; const img = selectedImg; const m = (img.getAttribute("src") || "").match(/\/attachments\/(\d+)\/inline/); img.remove(); selectImg(null); if (!m) return; try { await api("DELETE", "/project/api/attachments/" + m[1]); } catch (_) { /* 이미 지워졌을 수 있음 — 에디터에서는 이미 빠졌으니 조용히 무시 */ } if (onImageRemoved) onImageRemoved(); } function positionImgTools() { if (!selectedImg || !selectedImg.isConnected) { selectedImg = null; imgTools.hidden = true; return; } imgTools.style.left = selectedImg.offsetLeft + "px"; imgTools.style.top = selectedImg.offsetTop + "px"; imgTools.style.width = selectedImg.offsetWidth + "px"; imgTools.style.height = selectedImg.offsetHeight + "px"; imgTools.hidden = false; } function selectImg(img) { if (selectedImg) selectedImg.classList.remove("pj-img-selected"); selectedImg = img || null; if (selectedImg) { selectedImg.classList.add("pj-img-selected"); positionImgTools(); } else { imgTools.hidden = true; } } box.addEventListener("click", function (e) { if (e.target.tagName === "IMG") selectImg(e.target); else selectImg(null); }); document.addEventListener("click", function (e) { if (selectedImg && !wrap.contains(e.target)) selectImg(null); }); imgDelBtn.addEventListener("click", function () { deleteSelectedImage(); }); let dragging = false, dragStartX = 0, dragStartW = 0; imgHandle.addEventListener("mousedown", function (e) { if (!selectedImg) return; e.preventDefault(); dragging = true; dragStartX = e.clientX; dragStartW = selectedImg.offsetWidth; }); document.addEventListener("mousemove", function (e) { if (!dragging || !selectedImg) return; let newW = Math.round(dragStartW + (e.clientX - dragStartX)); newW = Math.max(40, Math.min(newW, wrap.clientWidth || 9999)); selectedImg.setAttribute("width", String(newW)); selectedImg.removeAttribute("height"); selectedImg.style.width = ""; selectedImg.style.height = ""; positionImgTools(); }); document.addEventListener("mouseup", function () { dragging = false; }); document.addEventListener("keydown", function (e) { if (!selectedImg) return; if (e.key !== "Delete" && e.key !== "Backspace") return; const active = document.activeElement; if (active && ["INPUT", "TEXTAREA", "SELECT"].indexOf(active.tagName) >= 0) return; e.preventDefault(); deleteSelectedImage(); }); // 업로드 버튼 클릭 → 파일 선택창이 뜨는 동안 contenteditable의 선택 범위가 // 풀린다 — 마지막으로 박스 안에 있던 커서 위치를 기억해뒀다가 삽입 직전에 // 되살린다(안 그러면 이미지가 항상 맨 끝에 붙어버린다). let lastRange = null; function saveSelection() { const sel = window.getSelection(); if (sel && sel.rangeCount && box.contains(sel.anchorNode)) { lastRange = sel.getRangeAt(0).cloneRange(); } } box.addEventListener("keyup", saveSelection); box.addEventListener("mouseup", saveSelection); box.addEventListener("blur", saveSelection); function setTaskId(id) { taskId = id; const enabled = !!id; if (imgInput) { imgInput.disabled = !enabled; if (imgInput.parentElement) imgInput.parentElement.style.opacity = enabled ? "1" : ".5"; } if (hint) hint.textContent = enabled ? "" : "저장 후 이미지를 추가할 수 있습니다."; } function insertHtmlAtCursor(html) { box.focus(); const sel = window.getSelection(); if (lastRange && (!sel.rangeCount || !box.contains(sel.anchorNode))) { try { sel.removeAllRanges(); sel.addRange(lastRange); } catch (_) { lastRange = null; } } let inserted = false; try { inserted = document.execCommand("insertHTML", false, html); } catch (_) { inserted = false; } if (!inserted) { if (sel && sel.rangeCount && box.contains(sel.anchorNode)) { const range = sel.getRangeAt(0); range.deleteContents(); const frag = range.createContextualFragment(html); range.insertNode(frag); range.collapse(false); } else { box.innerHTML += html; } } saveSelection(); } // 삽입 전 원본 크기를 확인해, 설명란보다 넓으면 90%로 줄여서 넣는다 // (그대로 넣으면 크기조절 핸들이 설명란 밖으로 삐져나간다). function naturalImageSize(file) { return new Promise(function (resolve) { const url = URL.createObjectURL(file); const img = new Image(); img.onload = function () { URL.revokeObjectURL(url); resolve({ width: img.naturalWidth }); }; img.onerror = function () { URL.revokeObjectURL(url); resolve(null); }; img.src = url; }); } // 업로드 중엔 저장을 막는다(안 그러면 업로드가 끝나기 전에 저장을 눌러 // 방금 넣은 이미지가 설명에서 통째로 빠진 채 저장되는 경우가 있었다). let pendingUploads = 0; function beginUpload() { pendingUploads++; if (hint) hint.textContent = "이미지 업로드 중…"; } function endUpload() { pendingUploads = Math.max(0, pendingUploads - 1); if (pendingUploads === 0 && hint) hint.textContent = taskId ? "" : "저장 후 이미지를 추가할 수 있습니다."; } async function insertImageFile(file) { if (!taskId) { alert("먼저 저장한 뒤 이미지를 추가할 수 있습니다."); return; } // 업로드는 비동기라 그 사이 포커스가 파일선택창 등으로 빠져나가면서 // contenteditable 의 선택범위가 사라지거나(브라우저가 엉뚱한 위치로 // 되살리기도 한다) 커서 위치가 틀어진다 — 업로드 시작 "직전"의 커서를 // 무조건 기억해뒀다가 삽입 시 그대로 복원한다(현재 라이브 셀렉션은 // 신뢰하지 않는다). saveSelection(); const insertAt = lastRange ? lastRange.cloneRange() : null; beginUpload(); const fd = new FormData(); fd.append("file", file); try { const results = await Promise.all([ naturalImageSize(file), fetch("/project/api/tasks/" + taskId + "/attachments", { method: "POST", body: fd }), ]); const dims = results[0], res = results[1]; if (!res.ok) { let m = res.statusText; try { m = (await res.json()).detail; } catch (_) {} throw new Error(m); } const data = await res.json(); const boxW = box.clientWidth || 0; const widthAttr = (dims && dims.width && boxW && dims.width > boxW) ? ' width="' + Math.round(boxW * 0.9) + '"' : ""; box.focus(); if (insertAt) { try { const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(insertAt); } catch (_) {} } insertHtmlAtCursor(''); } catch (e) { alert("이미지 업로드 실패: " + e.message); } finally { endUpload(); } } box.addEventListener("paste", function (e) { const items = (e.clipboardData && e.clipboardData.items) || []; for (let i = 0; i < items.length; i++) { if (items[i].type && items[i].type.indexOf("image/") === 0) { e.preventDefault(); const file = items[i].getAsFile(); if (file) insertImageFile(file); return; } } // 이미지가 아니면 서식 없는 텍스트로만 붙여넣기(외부 사이트 서식 유입 방지). e.preventDefault(); const text = (e.clipboardData || window.clipboardData).getData("text/plain"); insertHtmlAtCursor(escapeHtml(text).replace(/\n/g, "
    ")); }); if (imgInput) imgInput.addEventListener("change", function () { if (imgInput.files.length) insertImageFile(imgInput.files[0]); imgInput.value = ""; }); return { setTaskId: setTaskId, setHtml: function (html) { selectImg(null); lastRange = null; box.innerHTML = html || ""; }, getHtml: function () { return box.innerHTML; }, isUploading: function () { return pendingUploads > 0; }, setOnImageRemoved: function (fn) { onImageRemoved = fn; }, }; } // ── 하위 업무(인라인) — 체크박스·제목·담당자·마감일을 한 줄에서 바로 저장 ── function initSubtaskBlock() { const listEl = el("pj-subtask-list"); if (!listEl) return null; const input = el("pj-subtask-input"); const addAssignee = el("pj-subtask-add-assignee"); const addDue = el("pj-subtask-add-due"); const addBtn = el("pj-subtask-add"); let taskId = null, projectId = null, canDeleteFn = function () { return false; }, onChange = null; async function membersOptionsHtml(pid) { try { const res = await api("GET", "/project/api/projects/" + pid + "/members"); return (res.members || []).map(function (m) { return '"; }).join(""); } catch (_) { return ""; } } function rowEl(sub, optionsHtml) { const li = document.createElement("li"); li.className = "pj-subtask-row" + (sub.completed_at ? " is-done" : ""); li.dataset.id = sub.id; li.innerHTML = '" + '' + '" + '' + ''; const titleInp = li.querySelector(".pj-subtask-title"); const doneChk = li.querySelector(".pj-subtask-done"); const assigneeSel = li.querySelector(".pj-subtask-assignee"); const dueInp = li.querySelector(".pj-subtask-due"); const delBtn = li.querySelector(".pj-subtask-del"); titleInp.value = sub.title || ""; assigneeSel.value = sub.assignee_email || ""; dueInp.value = sub.due_date || ""; if (canDeleteFn(sub.created_by)) delBtn.hidden = false; async function patch(fields) { try { await api("PUT", "/project/api/tasks/" + sub.id, fields); if (onChange) onChange(); } catch (e) { alert("저장 실패: " + e.message); } } titleInp.addEventListener("change", function () { const v = titleInp.value.trim(); if (!v) { titleInp.value = sub.title || ""; return; } sub.title = v; patch({ title: v }); }); doneChk.addEventListener("change", function () { li.classList.toggle("is-done", doneChk.checked); patch({ completed: doneChk.checked }); }); assigneeSel.addEventListener("change", function () { const opt = assigneeSel.selectedOptions[0]; patch({ assignee_email: assigneeSel.value, assignee_name: opt ? opt.textContent : "" }); }); dueInp.addEventListener("change", function () { patch({ due_date: dueInp.value || null }); }); delBtn.addEventListener("click", async function () { if (!confirm("이 하위 업무를 삭제할까요?")) return; try { await api("DELETE", "/project/api/tasks/" + sub.id); li.remove(); if (onChange) onChange(); } catch (e) { alert("삭제 실패: " + e.message); } }); return li; } async function render(subtasks) { listEl.innerHTML = subtasks.length ? "" : "
  • 없음
  • "; if (!subtasks.length) return; const optionsHtml = projectId ? await membersOptionsHtml(projectId) : ""; listEl.innerHTML = ""; subtasks.forEach(function (sub) { listEl.appendChild(rowEl(sub, optionsHtml)); }); } if (addBtn) addBtn.addEventListener("click", async function () { const title = (input.value || "").trim(); if (!title) return; if (!taskId) { alert("먼저 업무를 저장한 뒤 하위 업무를 추가할 수 있습니다."); return; } const assigneeOpt = addAssignee ? addAssignee.selectedOptions[0] : null; try { const res = await api("POST", "/project/api/projects/" + projectId + "/tasks", { title: title, parent_task_id: taskId, assignee_email: addAssignee ? addAssignee.value : "", assignee_name: assigneeOpt ? assigneeOpt.textContent : "", due_date: addDue ? (addDue.value || null) : null, }); input.value = ""; if (addAssignee) addAssignee.value = ""; if (addDue) addDue.value = ""; const optionsHtml = await membersOptionsHtml(projectId); const empty = listEl.querySelector(".pj-empty-sm"); if (empty) empty.remove(); listEl.appendChild(rowEl(res.task, optionsHtml)); if (onChange) onChange(); } catch (e) { alert("추가 실패: " + e.message); } }); if (input) input.addEventListener("keydown", function (e) { if (e.key === "Enter") { e.preventDefault(); if (addBtn) addBtn.click(); } }); return { setContext: function (opts) { taskId = opts.taskId; projectId = opts.projectId; canDeleteFn = opts.canDelete || function () { return false; }; onChange = opts.onChange || null; if (addAssignee && projectId) { membersOptionsHtml(projectId).then(function (html) { addAssignee.innerHTML = "" + html; }); } }, render: render, }; } // ── 연결된 프로젝트(멀티호밍, 아사나식) — 업무 하나를 다른 프로젝트의 세션에도 연결 ── function initLinksBlock() { const chipsEl = el("pj-link-chips"); if (!chipsEl) return null; const projSel = el("pj-link-project"); const stageSel = el("pj-link-stage"); const saveBtn = el("pj-link-save"); let taskId = null, homeProjectId = null, onChange = null; function chipEl(link) { const span = document.createElement("span"); span.className = "pj-link-chip"; span.innerHTML = ' ' + escapeHtml(link.project_name || "") + (link.stage_name ? " · " + escapeHtml(link.stage_name) : "") + ' '; span.querySelector(".pj-link-del").addEventListener("click", async function () { if (!confirm("'" + (link.project_name || "") + "' 연결을 해제할까요?")) return; try { await api("DELETE", "/project/api/tasks/" + taskId + "/links/" + link.project_id); span.remove(); if (onChange) onChange(); } catch (e) { alert("해제 실패: " + e.message); } }); return span; } function render(links) { chipsEl.innerHTML = links.length ? "" : "없음"; links.forEach(function (l) { chipsEl.appendChild(chipEl(l)); }); } async function loadProjects() { projSel.innerHTML = ""; stageSel.innerHTML = ""; try { const res = await api("GET", "/project/api/projects"); const list = (res.projects || []).filter(function (p) { return p.id !== homeProjectId && !p.parent_id; }); projSel.innerHTML = "" + list.map(function (p) { return '"; }).join(""); } catch (e) { projSel.innerHTML = ""; } } if (projSel) projSel.addEventListener("change", async function () { const pid = parseInt(projSel.value, 10); if (!pid) { stageSel.innerHTML = ""; return; } stageSel.innerHTML = ""; try { const res = await api("GET", "/project/api/projects/" + pid + "/stages"); stageSel.innerHTML = (res.stages || []).map(function (s) { return '"; }).join(""); } catch (e) { stageSel.innerHTML = ""; } }); if (saveBtn) saveBtn.addEventListener("click", async function () { const pid = parseInt(projSel.value, 10); if (!pid) { alert("프로젝트를 선택하세요."); return; } if (!taskId) { alert("먼저 업무를 저장한 뒤 연결할 수 있습니다."); return; } try { await api("POST", "/project/api/tasks/" + taskId + "/links", { project_id: pid, stage_id: stageSel.value ? parseInt(stageSel.value, 10) : null }); projSel.value = ""; stageSel.innerHTML = ""; if (onChange) onChange(); } catch (e) { alert("추가 실패: " + e.message); } }); return { setContext: function (opts) { taskId = opts.taskId; homeProjectId = opts.projectId; onChange = opts.onChange || null; loadProjects(); }, render: render, }; } 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" }); if (inbox) inbox.querySelectorAll(".pj-inbox-item.is-unread").forEach(function (li) { li.classList.remove("is-unread"); }); } catch (e) { showToast("실패: " + e.message, "error"); } }); async function markRead(li) { 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 (_) {} } if (inbox) inbox.querySelectorAll(".pj-inbox-item").forEach(function (li) { li.addEventListener("click", function (e) { if (e.target.closest("a")) return; // '이동' 링크는 통과 markRead(li); }); li.addEventListener("contextmenu", function (e) { if (!li.classList.contains("is-unread")) return; // 이미 읽었으면 메뉴 없음 e.preventDefault(); showContextMenu(e.clientX, e.clientY, [ { label: "읽음으로 표시", onClick: function () { markRead(li); } }, ]); }); }); } // ── 실시간 새로고침 ── // 다른 사용자가 프로젝트/업무/댓글/멤버/단계를 추가·수정하면, 같은 화면을 열어둔 // 모든 사용자에게 8초 주기로 반영한다. 전체 새로고침 대신 배경 목록만 조용히 // 다시 받아 patch 한다(입력 중인 모달 폼은 건드리지 않음) — initProject() 가 // 끝나면서 softSyncProjectRef 를 채워준다(홈 페이지는 채우지 않아 벨 배지만 // 갱신). initLiveRefresh 는 어느 페이지든(프로젝트/홈) 항상 이 하나로 처리. let softSyncProjectRef = null; function initLiveRefresh() { if (!document.querySelector(".pj-wrap, .pj-project-view")) return; let baseline = null; async function poll() { try { const res = await fetch("/project/api/live-version"); if (res.ok) { const d = await res.json(); if (baseline === null) { baseline = d.ts; } else if (d.ts !== baseline) { baseline = d.ts; initBell(); if (softSyncProjectRef) softSyncProjectRef(); } } } catch (_) {} setTimeout(poll, 8000); } setTimeout(poll, 8000); } // ── "내 업무" 패널 — 프로젝트별 폴더 아이콘 클릭으로 하위 업무 목록 // 펼치기/접기. 홈 화면·프로젝트 상세 화면 둘 다 같은 마크업이라 공용으로 // 한 번만 처리한다(각 initXxx 와 무관, 서버 렌더 마크업 그대로 사용). function wireMyTreeToggles() { const KEY = "pj_mytree_collapsed"; let collapsed; try { collapsed = new Set(JSON.parse(localStorage.getItem(KEY) || "[]")); } catch (_) { collapsed = new Set(); } function save() { try { localStorage.setItem(KEY, JSON.stringify(Array.prototype.slice.call(collapsed))); } catch (_) {} } document.querySelectorAll(".pj-mytree-group").forEach(function (group) { const gid = group.dataset.groupId; const btn = group.querySelector(".pj-mytree-toggle"); const icon = btn ? btn.querySelector(".material-symbols-outlined") : null; function apply() { const isCollapsed = collapsed.has(gid); group.classList.toggle("is-collapsed", isCollapsed); if (icon) icon.textContent = isCollapsed ? "folder" : "folder_open"; } apply(); if (btn) btn.addEventListener("click", function () { if (collapsed.has(gid)) collapsed.delete(gid); else collapsed.add(gid); save(); apply(); }); }); } // ── 항상 보이는 스크롤 막대 ── // 윈도우/엣지가 오버레이(자동숨김) 스크롤바를 쓰면 네이티브 막대는 마우스를 // 올리거나 스크롤 중일 때만 잠깐 보이고, ::-webkit-scrollbar 커스텀도 // 무시된다(DevTools 박스모델에서 두께 0 확인). 그래서 자체 스크롤하는 패널은 // 네이티브 막대를 CSS 로 숨기고(.pj-vbar-* 참고) 여기서 막대를 직접 그린다. // 휠·키보드 스크롤은 그대로 동작하고, 막대 드래그/트랙 클릭도 지원한다. function attachScrollIndicator(scroller) { if (!scroller || scroller.dataset.pjVbar) return; scroller.dataset.pjVbar = "1"; const anchor = document.createElement("div"); anchor.className = "pj-vbar-anchor"; anchor.innerHTML = ''; scroller.insertBefore(anchor, scroller.firstChild); const track = anchor.firstChild; const thumb = track.firstChild; let thumbH = 0, thumbTop = 0; function update() { const sh = scroller.scrollHeight, ch = scroller.clientHeight; if (ch <= 0 || sh <= ch + 1) { if (!track.hidden) track.hidden = true; return; } if (track.hidden) track.hidden = false; thumbH = Math.max(28, Math.round(ch * ch / sh)); thumbTop = Math.round((ch - thumbH) * scroller.scrollTop / (sh - ch)); // 같은 값이면 안 쓴다 — MutationObserver 가 자기 변경에 반응해 도는 걸 막는다. const trackH = ch + "px", th = thumbH + "px", tf = "translateY(" + thumbTop + "px)"; if (track.style.height !== trackH) track.style.height = trackH; if (thumb.style.height !== th) thumb.style.height = th; if (thumb.style.transform !== tf) thumb.style.transform = tf; } scroller.addEventListener("scroll", update, { passive: true }); scroller.addEventListener("load", update, true); // 아바타 이미지 로드로 높이가 바뀔 때 window.addEventListener("resize", update); if (window.ResizeObserver) new ResizeObserver(update).observe(scroller); if (window.MutationObserver) { new MutationObserver(function (records) { for (let i = 0; i < records.length; i++) { if (!anchor.contains(records[i].target)) { update(); return; } } }).observe(scroller, { childList: true, subtree: true, attributes: true, characterData: true }); } let dragging = false, startY = 0, startTop = 0; thumb.addEventListener("mousedown", function (e) { e.preventDefault(); e.stopPropagation(); dragging = true; startY = e.clientY; startTop = scroller.scrollTop; thumb.classList.add("is-dragging"); }); document.addEventListener("mousemove", function (e) { if (!dragging) return; const sh = scroller.scrollHeight, ch = scroller.clientHeight, room = ch - thumbH; if (room <= 0) return; scroller.scrollTop = startTop + (e.clientY - startY) * (sh - ch) / room; }); document.addEventListener("mouseup", function () { if (dragging) { dragging = false; thumb.classList.remove("is-dragging"); } }); track.addEventListener("mousedown", function (e) { if (e.target !== track) return; e.preventDefault(); const y = e.clientY - track.getBoundingClientRect().top; scroller.scrollTop += (y < thumbTop ? -1 : 1) * scroller.clientHeight * 0.9; }); update(); } function initScrollIndicators() { document.querySelectorAll(".pj-home-side, .pj-home-main, .pj-side, .pj-board") .forEach(attachScrollIndicator); } document.addEventListener("DOMContentLoaded", function () { initHome(); initProject(); initBell(); initInbox(); initLiveRefresh(); wireMyTreeToggles(); initScrollIndicators(); }); })();