Files
dbx-main/app/static/project.js
T
king 3ca0b1de87 fix(project): 프로젝트 생성 시 세션 자동 추가 제거 (아사나처럼 직접 추가)
create_project 의 seed_stages 기본값을 True→False 로. 새 프로젝트는 이제
세션이 0개로 시작하고, 보드에서 "+ 세션 추가"로 직접 만든다. 세션이 하나도
없는 새 프로젝트의 보드 화면에는 안내 문구를 띄운다.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 19:55:54 +09:00

2226 lines
106 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 프로젝트 관리(아사나식) 모듈 — 프론트엔드.
* 두 페이지에서 공용:
* - index.html (홈): 새 프로젝트 모달
* - project.html: 달력/타임라인/보드/리스트 뷰 + 업무 CRUD + 서브/멤버 관리
* 의존: FullCalendar(전역), vis(전역). 둘 다 project.html 에서만 로드.
*/
(function () {
"use strict";
// ── 공용 fetch ─────────────────────────────────────────────
async function api(method, url, body) {
const opts = { method, headers: { "Content-Type": "application/json" } };
if (body !== undefined) opts.body = JSON.stringify(body);
const res = await fetch(url, opts);
if (!res.ok) {
let msg = res.statusText;
try { msg = (await res.json()).detail || msg; } catch (_) {}
throw new Error(msg);
}
if (res.status === 204) return null;
return res.json();
}
function el(id) { return document.getElementById(id); }
function openModal(m) { if (m) m.hidden = false; }
// 실시간 새로고침 대기 중이던 변경사항 — 모든 모달이 닫힌 시점에 반영(입력 중 방해 방지).
let pjPendingReload = false;
function anyModalOpen() { return !!document.querySelector(".pj-modal:not([hidden])"); }
function closeModal(m) {
if (m) m.hidden = true;
if (pjPendingReload && !anyModalOpen()) location.reload();
}
function wireModalClose(modal) {
if (!modal) return;
modal.addEventListener("click", function (e) {
if (e.target === modal || e.target.hasAttribute("data-close")) closeModal(modal);
});
}
// ── 색상 팔레트 렌더 ───────────────────────────────────────
function renderColorPalette(container, onPick, initial) {
const colors = window.PJ_COLORS || ["#4573d2"];
let selected = (initial && colors.indexOf(initial) >= 0) ? initial : colors[0];
container.innerHTML = "";
colors.forEach(function (c) {
const b = document.createElement("button");
b.type = "button";
b.className = "pj-color-swatch";
b.style.background = c;
if (c === selected) b.classList.add("is-active");
b.addEventListener("click", function () {
selected = c;
container.querySelectorAll(".pj-color-swatch").forEach(function (x) {
x.classList.remove("is-active");
});
b.classList.add("is-active");
if (onPick) onPick(c);
});
container.appendChild(b);
});
return function () { return selected; };
}
// ════════════════════════════════════════════════════════════
// 홈 페이지 — 새 프로젝트
// ════════════════════════════════════════════════════════════
function initHome() {
const grid = el("pj-project-grid");
if (!grid && !el("pj-toggle-done")) return; // 홈 아님
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;
}
// ── 완료 프로젝트 섹션 접기/펼치기 (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 = "<span class='pj-empty-sm'>멤버 배정은 관리자가 나중에 할 수 있습니다.</span>";
} else {
membersBox.innerHTML = "불러오는 중…";
try {
const res = await api("GET", "/project/api/assignable-users");
const users = res.users || [];
membersBox.innerHTML = users.length ? users.map(function (u) {
return '<label class="pj-member-pick"><input type="checkbox" value="' + u.email + '" /> ' +
escapeHtml(u.id) + "</label>";
}).join("") : "<span class='pj-empty-sm'>배정 가능한 직원이 없습니다.</span>";
} catch (e) {
membersBox.innerHTML = "<span class='pj-empty-sm'>불러오기 실패</span>";
}
}
}
});
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 (_) {}
}
location.reload();
} catch (e) { alert("생성 실패: " + e.message); }
});
}
// ── 프로젝트 수정 모달 (진행중/완료 두 그리드 모두 대상) ──
const editModal = el("pj-modal-edit");
if (editModal && grid) {
wireModalClose(editModal);
let getEditColor = null;
document.querySelectorAll(".pj-project-card .pj-card-edit").forEach(function (btn) {
btn.addEventListener("click", function (e) {
e.preventDefault(); e.stopPropagation();
const card = btn.closest(".pj-project-card");
el("pj-e-id").value = card.dataset.id;
el("pj-e-name").value = card.dataset.name || "";
el("pj-e-desc").value = card.dataset.desc || "";
el("pj-e-start").value = card.dataset.start || "";
el("pj-e-due").value = card.dataset.due || "";
el("pj-e-done").checked = card.dataset.status === "completed";
getEditColor = renderColorPalette(el("pj-e-colors"), null, card.dataset.color);
openModal(editModal);
});
});
el("pj-save-edit").addEventListener("click", async function () {
const id = el("pj-e-id").value;
const name = el("pj-e-name").value.trim();
if (!name) { alert("이름을 입력하세요."); return; }
try {
await api("PUT", "/project/api/projects/" + id, {
name: name,
description: el("pj-e-desc").value.trim(),
start_date: el("pj-e-start").value || null,
due_date: el("pj-e-due").value || null,
color: getEditColor ? getEditColor() : undefined,
status: el("pj-e-done").checked ? "completed" : "active",
});
location.reload();
} catch (e) { alert("저장 실패: " + e.message); }
});
el("pj-delete-project").addEventListener("click", async function () {
const id = el("pj-e-id").value;
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); location.reload(); }
catch (e) { alert("삭제 실패: " + e.message); }
});
}
// 카드/내 업무 클릭 팝업 + 댓글 팝업 공용 — 업무 원본 데이터(제목 등).
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) {
wireModalClose(homeTaskModal);
const descEditor = initDescEditor();
const subtaskBlock = initSubtaskBlock();
const linksBlock = initLinksBlock();
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 = '<option value="">(미지정)</option>' + list.map(function (m) {
const name = idOf(m.user_email);
return '<option value="' + m.user_email + '" data-name="' + escapeHtml(name) + '">' + escapeHtml(name) + "</option>";
}).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 '<option value="' + s.id + '">' + escapeHtml(s.name) + "</option>";
}).join("");
sel.value = currentStageId || (list[0] ? list[0].id : "");
}
async function loadHomeAttachments(taskId) {
const ul = el("pj-attach-list");
ul.innerHTML = "<li class='pj-empty-sm'>불러오는 중…</li>";
try {
const res = await api("GET", "/project/api/tasks/" + taskId + "/attachments");
if (!res.attachments.length) { ul.innerHTML = "<li class='pj-empty-sm'>없음</li>"; return; }
ul.innerHTML = "";
res.attachments.forEach(function (a) {
const li = document.createElement("li");
li.className = "pj-attach-item";
li.innerHTML =
'<a href="/project/api/attachments/' + a.id + '/download">' + escapeHtml(a.filename) + "</a>" +
'<button type="button" class="pj-icon-btn pj-attach-del" data-id="' + a.id + '">×</button>';
ul.appendChild(li);
});
ul.querySelectorAll(".pj-attach-del").forEach(function (b) {
b.addEventListener("click", async function () {
if (!confirm("이 첨부를 삭제할까요?")) return;
try { await api("DELETE", "/project/api/attachments/" + b.dataset.id); loadHomeAttachments(taskId); }
catch (e) { alert("삭제 실패: " + e.message); }
});
});
} catch (e) { ul.innerHTML = "<li class='pj-empty-sm'>불러오기 실패</li>"; }
}
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 = "<li class='pj-empty-sm'>불러오는 중…</li>";
try {
const res = await api("GET", "/project/api/tasks/" + taskId + "/comments");
if (!res.comments.length) { ul.innerHTML = "<li class='pj-empty-sm'>댓글 없음</li>"; return; }
ul.innerHTML = "";
res.comments.forEach(function (c) {
const li = document.createElement("li");
li.className = "pj-comment-item";
li.innerHTML =
'<span class="material-symbols-outlined pj-gicon pj-gicon-sm">account_circle</span>' +
'<div class="pj-comment-main"><div class="pj-comment-head">' +
escapeHtml(idOf(c.author_email)) + ' <span class="pj-comment-time">' +
(c.created_at || "").slice(0, 16).replace("T", " ") + "</span></div>" +
'<div class="pj-comment-body"></div></div>' +
'<span class="pj-comment-acts"><button type="button" class="pj-icon-btn pj-comment-del" title="삭제">×</button></span>';
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 = "<li class='pj-empty-sm'>불러오기 실패</li>"; }
}
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 = "업무 편집";
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-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);
}
// 카드 이름 옆 "업무 추가" 버튼 — 같은 모달을 새 업무 작성용으로 초기화해 연다.
async function openHomeNewTaskModal(projectId) {
el("pj-task-modal-title").textContent = "새 업무 추가";
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-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)]);
}
document.querySelectorAll(".pj-card-addtask").forEach(function (btn) {
btn.addEventListener("click", function (e) {
e.preventDefault(); e.stopPropagation();
openHomeNewTaskModal(btn.dataset.projectId);
});
});
el("pj-t-usetime").addEventListener("change", function () {
el("pj-t-stime").disabled = !this.checked;
el("pj-t-dtime").disabled = !this.checked;
});
["pj-t-stime", "pj-t-dtime"].forEach(function (id) {
const inp = el(id);
if (inp) inp.addEventListener("click", function () { try { this.showPicker(); } catch (_) {} });
});
el("pj-save-task").addEventListener("click", async function () {
const id = el("pj-t-id").value;
const 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 {
if (id) {
await api("PUT", "/project/api/tasks/" + id, payload);
} else {
if (!pid) return;
await api("POST", "/project/api/projects/" + pid + "/tasks", payload);
}
location.reload();
} catch (e) { alert("저장 실패: " + e.message); }
});
el("pj-delete-task").addEventListener("click", async function () {
const id = el("pj-t-id").value;
if (!id) return;
if (!confirm("이 업무를 삭제할까요?")) return;
try { await api("DELETE", "/project/api/tasks/" + id); location.reload(); }
catch (e) { alert("삭제 실패: " + e.message); }
});
document.querySelectorAll(".pj-task-popup-link").forEach(function (a) {
a.addEventListener("click", function (e) {
const tid = a.dataset.taskId;
const task = homeTasks[tid];
if (!task) return; // 정보 없으면 기존 링크 이동으로 폴백
e.preventDefault();
openHomeTaskModal(task);
});
});
}
// ── 댓글 말풍선(카드/내 업무) — 더블클릭 시 댓글만 보는 채팅형 팝업 ──
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 = "<li class='pj-empty-sm'>불러오는 중…</li>";
try {
const res = await api("GET", "/project/api/tasks/" + taskId + "/comments");
const comments = res.comments || [];
if (!comments.length) {
list.innerHTML = "<li class='pj-empty-sm'>댓글 없음</li>";
} 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 ? "" : '<span class="pj-chat-name">' + escapeHtml(idOf(c.author_email)) + "</span>") +
'<div class="pj-chat-bubble"></div>' +
'<span class="pj-chat-meta">' +
(mine ? '<button type="button" class="pj-icon-btn pj-chat-del" title="삭제"><span class="material-symbols-outlined">close</span></button>' : "") +
'<span class="pj-chat-time">' + (c.created_at || "").slice(0, 16).replace("T", " ") + "</span>" +
"</span>";
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 = "<li class='pj-empty-sm'>불러오기 실패</li>";
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); }
}
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(); }
});
}
}
// ════════════════════════════════════════════════════════════
// 프로젝트 페이지
// ════════════════════════════════════════════════════════════
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 '<span class="pj-cmt-ic ' + (cls || "") +
'" title="댓글 ' + n + '개">💬</span><span class="pj-cmt-n">' + n + "</span>";
}
const dataEl = el("pj-data");
const data = JSON.parse(dataEl.textContent);
let stages = data.stages || [];
let tasks = data.tasks || [];
const members = data.members || [];
const priorityLabels = data.priorityLabels || {};
// 전체 프로젝트 집계 데이터 — 4개 뷰는 전 프로젝트를 보여준다.
const projectsMeta = data.projects || [];
const stagesByProject = data.stagesByProject || {};
const boardStages = data.boardStages || [];
const avatars = data.avatars || {};
// 담당자/작성자 아이콘 = 구글 프로필 이미지(원형). 없으면 기본 아이콘.
function avatarHtml(email, cls) {
const url = email ? avatars[String(email).toLowerCase()] : "";
const c = cls || "";
if (url) return '<img class="pj-avatar ' + c + '" src="' + escapeHtml(url) + '" alt="" referrerpolicy="no-referrer">';
return '<span class="material-symbols-outlined pj-gicon ' + c + '">account_circle</span>';
}
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"].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();
}
// ════════════════════════════════════════════════════════════
// 달력 — 구글 캘린더식. 막대(칩)는 클라이언트가 그린다.
// · 한 주에 표시 행 수 제한(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 = '<span class="pj-bar-label">' + escapeHtml(b.t.title || "") + "</span>" + 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) {
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 =
'<span class="pj-daypop-dot" style="background:' +
(t.completed_at ? "#9aa5b1" : (t.project_color || "#4573d2")) + '"></span>' +
'<span class="pj-daypop-title">' + escapeHtml(t.title || "") + "</span>";
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 = '<div class="pj-empty">기간이 설정된 업무가 없습니다.</div>'; return; }
// 프로젝트 메타(시작/마감/색) 조회용
const projById = {};
projectsMeta.forEach(function (p) { projById[p.id] = p; });
// 프로젝트별로 업무를 묶어 그룹(행) 생성
const byProj = {};
dated.forEach(function (t) { (byProj[t.project_id] = byProj[t.project_id] || []).push(t); });
const items = [];
const groupRows = [];
Object.keys(byProj).forEach(function (pid) {
const list = byProj[pid];
const grp = "P" + pid;
const meta = projById[pid] || {};
const projColor = list[0].project_color || meta.color || "#4573d2";
groupRows.push({ id: grp, content: list[0].project_name || meta.name || "프로젝트" });
let minMs = Infinity, maxMs = -Infinity;
list.forEach(function (t) {
const s = t.start_date || t.due_date;
const e = t.due_date || t.start_date;
const startISO = s + (t.start_time ? "T" + t.start_time : "");
const endISO = e + (t.due_time ? "T" + t.due_time : "");
const isRange = (e !== s) || (!!t.start_time && !!t.due_time && t.start_time !== t.due_time);
items.push({
// 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" : projColor) + ";color:#fff;border:none;",
});
const sm = new Date(startISO).getTime();
const em = new Date(endISO).getTime();
if (sm < minMs) minMs = sm;
if (em > maxMs) maxMs = em;
});
// 배경(프로젝트 전체 일정) = 프로젝트 자체 시작/마감일 우선, 없으면 업무 최소~최대.
let bgStart = meta.start_date ? new Date(meta.start_date + "T00:00:00").getTime() : minMs;
let bgEnd = 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" + pid, group: grp, type: "background",
start: new Date(bgStart), end: new Date(bgEnd),
style: "background-color:" + projColor + "22;",
});
});
const groups = new vis.DataSet(groupRows);
const koWd = function (d) { return ["일", "월", "화", "수", "목", "금", "토"][d.getDay()]; };
const pad2 = function (n) { return ("0" + n).slice(-2); };
// 저장된 보던 구간이 있으면 생성 옵션에 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);
});
// 사용자가 직접 확대/이동했을 때만 저장(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" → 여러 프로젝트를 세션 '이름' 기준으로 합쳐 보여준다(기존
// 동작 그대로 — 세션 하나를 특정할 수 없어 관리 컨트롤은 없다).
function renderBoard() {
const board = el("pj-board");
board.innerHTML = "";
const vis = visibleTasks();
if (viewScope === "project") renderBoardByStage(board, vis);
else renderBoardByName(board, vis);
}
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 =
'<div class="pj-col-head"><span class="pj-col-name">' + escapeHtml(name) + "</span>" +
'<span class="pj-col-count"></span></div>' +
'<div class="pj-col-body"></div>';
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.flex = "1 1 auto";
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 =
'<div class="pj-col-head"' + (canManage ? ' draggable="true"' : "") + '>' +
'<span class="pj-col-name">' + escapeHtml(s.name) + "</span>" +
(s.is_done_stage ? '<span class="pj-chip pj-chip-sm">완료</span>' : "") +
'<span class="pj-col-count"></span>' +
(canManage
? '<span class="pj-col-ctrls">' +
'<button type="button" class="pj-icon-btn pj-col-rename" title="이름 변경">✎</button>' +
'<button type="button" class="pj-icon-btn pj-col-toggledone" title="완료 세션 지정/해제">' +
(s.is_done_stage ? "☑" : "☐") + "</button>" +
'<button type="button" class="pj-icon-btn pj-col-del" title="세션 삭제">×</button>' +
"</span>"
: "") +
"</div><div class=\"pj-col-body\"></div>";
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 =
'<div class="pj-col-head"><span class="pj-col-name">미지정</span>' +
'<span class="pj-col-count"></span></div><div class="pj-col-body"></div>';
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);
}
if (canManage) {
const addCol = document.createElement("div");
addCol.className = "pj-col pj-col-add";
addCol.innerHTML = '<button type="button" class="pj-add-stage">+ 세션 추가</button>';
addCol.querySelector(".pj-add-stage").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); }
});
board.appendChild(addCol);
}
}
async function reloadStagesForCurrentProject() {
const res = await api("GET", "/project/api/projects/" + projectId + "/stages");
stages = res.stages || [];
stagesByProject[projectId] = stages;
}
function wireColumnControls(col, s) {
const renameBtn = col.querySelector(".pj-col-rename");
if (renameBtn) renameBtn.addEventListener("click", async function (e) {
e.stopPropagation();
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) { alert("이름 변경 실패: " + err.message); }
});
const doneBtn = col.querySelector(".pj-col-toggledone");
if (doneBtn) doneBtn.addEventListener("click", async function (e) {
e.stopPropagation();
try {
await api("PUT", "/project/api/projects/" + projectId + "/stages/" + s.id,
{ is_done_stage: !s.is_done_stage });
await reloadStagesForCurrentProject();
renderBoard();
} catch (err) { alert("변경 실패: " + err.message); }
});
const delBtn = col.querySelector(".pj-col-del");
if (delBtn) delBtn.addEventListener("click", async function (e) {
e.stopPropagation();
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) { alert("삭제 실패: " + err.message); }
});
}
// 세션(컬럼) 드래그 재정렬 — 카드 드래그와 같은 네이티브 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)로 바꾼다.
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 || t.stage_id === stageId) 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) { alert("이동 실패: " + err.message); }
});
}
function taskCard(t) {
const card = document.createElement("div");
card.className = "pj-card" + (t.completed_at ? " is-done" : "");
card.draggable = canManage;
card.dataset.taskId = t.id;
// 전체보기는 항상 원 프로젝트 배지를 보여준다. 프로젝트 스코프에서는
// "이 프로젝트가 원 소속"인 카드는 굳이 표시하지 않고, 멀티호밍으로
// 들어온(다른 프로젝트가 원 소속인) 카드만 아사나처럼 배지를 붙인다.
const showProjTag = viewScope === "all" || t.project_id !== projectId;
card.innerHTML =
(showProjTag && t.project_name
? '<div class="pj-card-proj" style="color:' + (t.project_color || "#4573d2") + '">● ' + escapeHtml(t.project_name) + "</div>"
: "") +
'<div class="pj-card-title">' + escapeHtml(t.title) + "</div>" +
'<div class="pj-card-meta">' +
(t.priority && t.priority !== "normal"
? '<span class="pj-pri pj-pri-' + t.priority + '">' + (priorityLabels[t.priority] || t.priority) + "</span>"
: "") +
(t.assignee_email
? '<span class="pj-assignee">' + avatarHtml(t.assignee_email, "pj-gicon-sm") +
escapeHtml(idOf(t.assignee_email)) + "</span>"
: "") +
(t.subtask_total
? '<span class="pj-card-subtasks" title="하위 업무">' + (t.subtask_done || 0) + "/" + t.subtask_total + "</span>"
: "") +
(t.due_date ? '<span class="pj-card-due">' + t.due_date + "</span>" : "") +
(t.comment_count ? '<span class="pj-card-cmt">' + cmtBadge(t) + "</span>" : "") +
"</div>";
card.addEventListener("click", function () { if (canManage) openTaskModal(t); });
if (canManage) {
card.addEventListener("dragstart", function (e) {
e.dataTransfer.setData("text/plain", String(t.id));
card.classList.add("is-dragging");
});
card.addEventListener("dragend", function () { card.classList.remove("is-dragging"); });
}
return card;
}
// ── 리스트 (정렬·컬럼 너비 조절) ──
const LIST_COLS = [
{ key: "project", label: "프로젝트", text: function (t) { return t.project_name || "-"; }, sort: function (t) { return (t.project_name || "").toLowerCase(); } },
{ key: "title", label: "업무", text: function (t) { return t.title; }, sort: function (t) { return (t.title || "").toLowerCase(); } },
{ key: "assignee", label: "담당자", text: function (t) { return t.assignee_email ? idOf(t.assignee_email) : "-"; }, sort: function (t) { return idOf(t.assignee_email || ""); } },
{ key: "stage", label: "세션", text: function (t) { return t.stage_name || "-"; }, sort: function (t) { return t.stage_name || ""; } },
{ key: "priority", label: "우선순위", text: function (t) { return priorityLabels[t.priority] || t.priority || "-"; }, sort: function (t) { return ({ low: 0, normal: 1, high: 2 })[t.priority]; } },
{ key: "start", label: "시작", text: function (t) { return t.start_date || "-"; }, sort: function (t) { return t.start_date || ""; } },
{ key: "due", label: "마감", text: function (t) { return t.due_date || "-"; }, sort: function (t) { return t.due_date || ""; } },
];
let listSort = { key: null, dir: 1 };
function listWidths() { try { return JSON.parse(localStorage.getItem("pj_list_widths") || "{}"); } catch (_) { return {}; } }
function saveListWidth(key, w) { const m = listWidths(); m[key] = w; try { localStorage.setItem("pj_list_widths", JSON.stringify(m)); } catch (_) {} }
function enableColResize(th, key) {
const handle = th.querySelector(".pj-th-resize");
if (!handle) return;
handle.addEventListener("mousedown", function (e) {
e.preventDefault(); e.stopPropagation();
const startX = e.clientX, startW = th.offsetWidth;
function mv(ev) { th.style.width = Math.max(60, startW + (ev.clientX - startX)) + "px"; }
function up() { document.removeEventListener("mousemove", mv); document.removeEventListener("mouseup", up); saveListWidth(key, th.offsetWidth); }
document.addEventListener("mousemove", mv);
document.addEventListener("mouseup", up);
});
}
function renderList() {
const table = el("pj-list");
table.classList.add("pj-table-fixed");
const widths = listWidths();
const trh = document.createElement("tr");
LIST_COLS.forEach(function (col) {
const th = document.createElement("th");
th.dataset.key = col.key;
if (widths[col.key]) th.style.width = widths[col.key] + "px";
const arrow = listSort.key === col.key ? (listSort.dir > 0 ? "▲" : "▼") : "↕";
th.innerHTML =
'<span class="pj-th-label">' + escapeHtml(col.label) +
' <span class="pj-th-sort">' + arrow + "</span></span>" +
'<span class="pj-th-resize"></span>';
th.querySelector(".pj-th-label").addEventListener("click", function () {
if (listSort.key === col.key) listSort.dir = -listSort.dir;
else { listSort.key = col.key; listSort.dir = 1; }
renderList();
});
enableColResize(th, col.key);
trh.appendChild(th);
});
const thead = table.querySelector("thead");
thead.innerHTML = "";
thead.appendChild(trh);
let rows = visibleTasks();
if (listSort.key) {
const col = LIST_COLS.find(function (c) { return c.key === listSort.key; });
rows.sort(function (a, b) {
let va = col.sort(a), vb = col.sort(b);
if (va == null) va = -Infinity;
if (vb == null) vb = -Infinity;
if (va < vb) return -listSort.dir;
if (va > vb) return listSort.dir;
return 0;
});
}
const tbody = table.querySelector("tbody");
tbody.innerHTML = "";
rows.forEach(function (t) {
const tr = document.createElement("tr");
if (t.completed_at) tr.className = "is-done";
// 행 배경 = 프로젝트 색 연하게(10%)
const pc = t.project_color || "#4573d2";
tr.style.background = pc + "14";
tr.innerHTML = LIST_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 = '<span class="pj-assignee">' + avatarHtml(t.assignee_email, "pj-gicon-sm") + escapeHtml(idOf(t.assignee_email)) + "</span>";
}
return "<td>" + inner + "</td>";
}).join("");
if (canManage) { tr.style.cursor = "pointer"; tr.addEventListener("click", function () { openTaskModal(t); }); }
tbody.appendChild(tr);
});
}
function mergeTask(task) {
const i = tasks.findIndex(function (x) { return x.id === task.id; });
if (i >= 0) tasks[i] = Object.assign({}, tasks[i], task);
else tasks.push(task);
}
function refreshActiveView() {
// 모든 뷰 + 좌측 트리 클라이언트 재렌더(새로고침 없음).
renderView(currentView);
if (typeof renderTree === "function") renderTree();
}
// ── 업무 모달 ──
const taskModal = el("pj-modal-task");
wireModalClose(taskModal);
let currentTaskId = null;
const descEditor = initDescEditor();
const subtaskBlock = initSubtaskBlock();
const linksBlock = initLinksBlock();
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 '<option value="' + s.id + '">' + escapeHtml(s.name) + "</option>";
}).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 = '<option value="">(미지정)</option>' + list.map(function (m) {
const name = idOf(m.user_email);
return '<option value="' + m.user_email + '" data-name="' + escapeHtml(name) + '">' + escapeHtml(name) + "</option>";
}).join("");
sel.value = currentEmail || "";
}
function openTaskModal(task) {
el("pj-task-modal-title").textContent = task ? "업무 편집" : "새 업무";
el("pj-t-id").value = task ? task.id : "";
el("pj-t-title").value = task ? task.title : "";
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;
// 댓글·첨부·하위업무·연결은 기존 업무 편집 시에만
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);
}
// ── 첨부파일 ──
function fmtSize(n) {
if (n < 1024) return n + " B";
if (n < 1048576) return (n / 1024).toFixed(1) + " KB";
return (n / 1048576).toFixed(1) + " MB";
}
async function loadAttachments(taskId) {
const ul = el("pj-attach-list");
ul.innerHTML = "<li class='pj-empty-sm'>불러오는 중…</li>";
try {
const res = await api("GET", "/project/api/tasks/" + taskId + "/attachments");
if (!res.attachments.length) { ul.innerHTML = "<li class='pj-empty-sm'>없음</li>"; return; }
ul.innerHTML = "";
res.attachments.forEach(function (a) {
const li = document.createElement("li");
li.className = "pj-attach-item";
li.innerHTML =
'<a href="/project/api/attachments/' + a.id + '/download">' + escapeHtml(a.filename) + "</a>" +
'<span class="pj-attach-size">' + fmtSize(a.size_bytes) + "</span>" +
(canManage ? '<button type="button" class="pj-icon-btn pj-attach-del" data-id="' + a.id + '">×</button>' : "");
ul.appendChild(li);
});
ul.querySelectorAll(".pj-attach-del").forEach(function (b) {
b.addEventListener("click", async function () {
if (!confirm("이 첨부를 삭제할까요?")) return;
try { await api("DELETE", "/project/api/attachments/" + b.dataset.id); loadAttachments(taskId); }
catch (e) { alert("삭제 실패: " + e.message); }
});
});
} catch (e) { ul.innerHTML = "<li class='pj-empty-sm'>불러오기 실패</li>"; }
}
const attachInput = el("pj-attach-input");
if (attachInput) attachInput.addEventListener("change", async function () {
if (!currentTaskId || !attachInput.files.length) return;
const fd = new FormData();
fd.append("file", attachInput.files[0]);
try {
const res = await fetch("/project/api/tasks/" + currentTaskId + "/attachments", { method: "POST", body: fd });
if (!res.ok) { let m = res.statusText; try { m = (await res.json()).detail; } catch (_) {} throw new Error(m); }
attachInput.value = "";
loadAttachments(currentTaskId);
} catch (e) { alert("업로드 실패: " + e.message); }
});
// ── 댓글 ──
async function loadComments(taskId) {
const ul = el("pj-comment-list");
ul.innerHTML = "<li class='pj-empty-sm'>불러오는 중…</li>";
try {
const res = await api("GET", "/project/api/tasks/" + taskId + "/comments");
// 메모리 업무의 댓글 수 동기화(아이콘 표시용)
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 = "<li class='pj-empty-sm'>댓글 없음</li>"; return; }
ul.innerHTML = "";
res.comments.forEach(function (c) {
const li = document.createElement("li");
li.className = "pj-comment-item";
li.innerHTML =
avatarHtml(c.author_email, "pj-gicon-sm") +
'<div class="pj-comment-main"><div class="pj-comment-head">' +
escapeHtml(idOf(c.author_email)) + ' <span class="pj-comment-time">' +
(c.created_at || "").slice(0, 16).replace("T", " ") + "</span></div>" +
'<div class="pj-comment-body"></div></div>' +
'<span class="pj-comment-acts">' +
'<button type="button" class="pj-icon-btn pj-comment-edit" title="수정">✎</button>' +
'<button type="button" class="pj-icon-btn pj-comment-del" title="삭제">×</button>' +
"</span>";
li.querySelector(".pj-comment-body").textContent = c.body; // XSS 안전(textContent)
li.querySelector(".pj-comment-del").addEventListener("click", async function () {
try { await api("DELETE", "/project/api/comments/" + c.id); loadComments(taskId); }
catch (e) { alert("삭제 실패: " + e.message); }
});
li.querySelector(".pj-comment-edit").addEventListener("click", function () {
editComment(li, c, taskId);
});
ul.appendChild(li);
});
} catch (e) { ul.innerHTML = "<li class='pj-empty-sm'>불러오기 실패</li>"; }
}
// 댓글 인라인 수정 — 본문을 textarea 로 교체, 저장/취소.
function editComment(li, c, taskId) {
const main = li.querySelector(".pj-comment-main");
const bodyEl = li.querySelector(".pj-comment-body");
const acts = li.querySelector(".pj-comment-acts");
if (li.querySelector(".pj-comment-edit-box")) return; // 중복 방지
acts.style.visibility = "hidden";
const box = document.createElement("div");
box.className = "pj-comment-edit-box";
const ta = document.createElement("textarea");
ta.rows = 2; ta.value = c.body;
const bar = document.createElement("div");
bar.className = "pj-comment-edit-actions";
const save = document.createElement("button");
save.type = "button"; save.className = "pj-btn pj-btn-primary"; save.textContent = "저장";
const cancel = document.createElement("button");
cancel.type = "button"; cancel.className = "pj-btn"; cancel.textContent = "취소";
bar.appendChild(cancel); bar.appendChild(save);
box.appendChild(ta); box.appendChild(bar);
bodyEl.style.display = "none";
main.appendChild(box);
ta.focus();
cancel.addEventListener("click", function () {
box.remove(); bodyEl.style.display = ""; acts.style.visibility = "";
});
save.addEventListener("click", async function () {
const text = ta.value.trim();
if (!text) { alert("내용을 입력하세요."); return; }
try {
await api("PUT", "/project/api/comments/" + c.id, { body: text });
loadComments(taskId);
} catch (e) { alert("수정 실패: " + e.message); }
});
}
async function sendComment() {
const inp = el("pj-comment-input");
const body = inp.value.trim();
if (!currentTaskId || !body) return;
try {
await api("POST", "/project/api/tasks/" + currentTaskId + "/comments", { body: body });
inp.value = "";
loadComments(currentTaskId);
} catch (e) { alert("등록 실패: " + e.message); }
}
el("pj-comment-send").addEventListener("click", sendComment);
el("pj-comment-input").addEventListener("keydown", function (e) {
if (e.key === "Enter") { e.preventDefault(); sendComment(); }
});
const newTaskBtn = el("pj-new-task");
if (newTaskBtn) newTaskBtn.addEventListener("click", function () { openTaskModal(null); });
// '시간 지정' 체크 → 시간 입력란 활성/비활성
el("pj-t-usetime").addEventListener("change", function () {
el("pj-t-stime").disabled = !this.checked;
el("pj-t-dtime").disabled = !this.checked;
});
// 시간 입력란 클릭 시 즉시 시간 피커 드롭다운 열기
["pj-t-stime", "pj-t-dtime"].forEach(function (id) {
const inp = el(id);
if (inp) inp.addEventListener("click", function () {
try { this.showPicker(); } catch (_) {}
});
});
el("pj-save-task").addEventListener("click", async function () {
const id = el("pj-t-id").value;
const assigneeSel = el("pj-t-assignee");
const assigneeName = assigneeSel.selectedOptions[0] ? (assigneeSel.selectedOptions[0].dataset.name || "") : "";
const useTime = el("pj-t-usetime").checked;
const payload = {
title: el("pj-t-title").value.trim(),
description: 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);
// 새 업무는 해당 프로젝트 트리를 펼쳐 바로 보이게.
if (!id) treeSaveOpen((res.task && res.task.project_id) || projectId, true);
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); }
});
// ── 프로젝트 트리 (아사나식) — tasks 로부터 클라이언트 렌더(업무 추가 즉시 반영) ──
function setTreeOpen(li, open) {
const children = li.querySelector(":scope > .pj-tree-children");
const caret = li.querySelector(":scope > .pj-tree-row > .pj-tree-caret");
if (!children) return;
children.hidden = !open;
if (caret) { caret.setAttribute("aria-expanded", open ? "true" : "false"); caret.classList.toggle("is-open", open); }
}
function treeOpenSet() {
try { return new Set(JSON.parse(localStorage.getItem("pj_tree_open") || "[]")); }
catch (_) { return new Set(); }
}
function treeSaveOpen(id, open) {
const s = treeOpenSet();
if (open) s.add(String(id)); else s.delete(String(id));
try { localStorage.setItem("pj_tree_open", JSON.stringify(Array.from(s))); } catch (_) {}
}
function treeTaskLi(t, pid) {
return '<li class="pj-tree-li pj-tree-task"><div class="pj-tree-row" style="--pj-indent: 1">' +
'<span class="pj-tree-caret-spacer"></span>' +
'<a class="pj-tree-link pj-tree-tasklink" href="/project/p/' + pid + '?task=' + t.id +
'" data-project="' + pid + '" data-task="' + t.id + '">' +
'<span class="pj-tree-icon material-symbols-outlined" style="color:' + (t.project_color || "#4573d2") + '">' +
(t.completed_at ? "task_alt" : "radio_button_unchecked") + "</span>" +
'<span class="pj-tree-name ' + (t.completed_at ? "pj-tree-done" : "") + '">' + escapeHtml(t.title || "") + "</span>" +
"</a></div></li>";
}
function treeProjLi(p) {
const ptasks = tasks.filter(function (t) { return t.project_id === p.id; });
const has = ptasks.length > 0;
const caret = has
? '<button type="button" class="pj-tree-caret" aria-expanded="false" title="펼치기/접기"><span class="material-symbols-outlined">chevron_right</span></button>'
: '<span class="pj-tree-caret-spacer"></span>';
const del = isAdmin
? '<span class="pj-tree-acts"><button type="button" class="pj-icon-btn pj-tree-del" data-id="' + p.id +
'" data-name="' + escapeHtml(p.name) + '" data-creator="' + escapeHtml(p.created_by || "") + '" title="프로젝트 삭제">×</button></span>'
: "";
let h = '<li class="pj-tree-li" data-id="' + p.id + '"><div class="pj-tree-row ' + (p.id === projectId ? "is-active" : "") + '">' +
caret + '<a class="pj-tree-link" href="/project/p/' + p.id + '">' +
'<span class="pj-tree-icon material-symbols-outlined" style="color:' + (p.color || "#4573d2") + '">folder</span>' +
'<span class="pj-tree-name">' + escapeHtml(p.name) + "</span></a>" + del + "</div>";
if (has) h += '<div class="pj-tree-children" hidden><ul class="pj-tree">' +
ptasks.map(function (t) { return treeTaskLi(t, p.id); }).join("") + "</ul></div>";
return h + "</li>";
}
function wireTreeHandlers(rootUl) {
// 좌측 트리의 업무 클릭 — 이미 로드된 tasks 배열에서 바로 찾아 팝업(페이지 이동 없음).
rootUl.querySelectorAll(".pj-tree-tasklink").forEach(function (a) {
a.addEventListener("click", function (e) {
const tid = a.dataset.task;
const t = tasks.find(function (x) { return String(x.id) === String(tid); });
if (!t) return; // 못 찾으면 기존 링크 이동으로 폴백
e.preventDefault();
openTaskModal(t);
});
});
rootUl.querySelectorAll(".pj-tree-caret").forEach(function (caret) {
caret.addEventListener("click", function (e) {
e.preventDefault(); e.stopPropagation();
const li = caret.closest(".pj-tree-li");
const children = li.querySelector(":scope > .pj-tree-children");
const open = children.hidden;
setTreeOpen(li, open);
treeSaveOpen(li.dataset.id, open);
});
});
rootUl.querySelectorAll(".pj-tree-del").forEach(function (b) {
b.addEventListener("click", async function (e) {
e.preventDefault(); e.stopPropagation();
if (!canDelete(b.dataset.creator)) { alert(DELETE_DENIED); return; }
if (!confirm("'" + (b.dataset.name || "") + "' 프로젝트를 삭제할까요? 안의 업무도 모두 삭제됩니다.")) return;
try {
await api("DELETE", "/project/api/projects/" + b.dataset.id);
if (String(b.dataset.id) === String(projectId)) location.href = "/project/";
else location.reload();
} catch (err) { alert("삭제 실패: " + err.message); }
});
});
}
function restoreTreeState() {
const openSet = treeOpenSet();
document.querySelectorAll(".pj-tree-li").forEach(function (li) {
if (openSet.has(String(li.dataset.id))) setTreeOpen(li, true);
});
const active = document.querySelector(".pj-tree-row.is-active");
let box = active ? active.closest(".pj-tree-children") : null;
while (box) {
box.hidden = false;
const li = box.closest(".pj-tree-li");
if (li) setTreeOpen(li, true);
box = li ? (li.parentElement ? li.parentElement.closest(".pj-tree-children") : null) : null;
}
}
function renderTree() {
const rootUl = el("pj-tree-root");
if (!rootUl) return;
rootUl.innerHTML = projectsMeta.map(treeProjLi).join("");
wireTreeHandlers(rootUl);
restoreTreeState();
}
renderTree();
// 새 최상위 프로젝트 (관리자)
const newProjSide = el("pj-new-project-side");
if (newProjSide) newProjSide.addEventListener("click", async function () {
const name = prompt("새 프로젝트 이름");
if (!name || !name.trim()) return;
const due = prompt("마감일 (YYYY-MM-DD, 없으면 취소 없이 비워두고 확인)") || "";
try {
await api("POST", "/project/api/projects", { name: name.trim(), due_date: due.trim() || null });
location.reload();
} catch (e) { alert("생성 실패: " + e.message); }
});
// ── 멤버 배정/제외 (관리자) ──
const memberModal = el("pj-modal-member");
wireModalClose(memberModal);
const addMemberBtn = el("pj-add-member");
if (addMemberBtn) addMemberBtn.addEventListener("click", async function () {
const sel = el("pj-m-user");
openModal(memberModal);
sel.innerHTML = '<option value="">불러오는 중…</option>';
try {
const res = await api("GET", "/project/api/assignable-users");
const existing = new Set(members.map(function (m) { return m.user_email; }));
const opts = res.users
.filter(function (u) { return !existing.has(u.email); })
.map(function (u) {
return '<option value="' + u.email + '" data-name="' + escapeHtml(u.name) + '">' +
escapeHtml(u.id) + " (" + escapeHtml(u.email) + ")</option>";
});
sel.innerHTML = opts.length
? opts.join("")
: '<option value="">배정 가능한 직원이 없습니다(권한 미부여 또는 전원 배정됨)</option>';
} catch (e) {
sel.innerHTML = '<option value="">불러오기 실패: ' + escapeHtml(e.message) + "</option>";
}
});
const saveMemberBtn = el("pj-save-member");
if (saveMemberBtn) saveMemberBtn.addEventListener("click", async function () {
const sel = el("pj-m-user");
if (!sel.value) { alert("직원을 선택하세요."); return; }
const name = sel.selectedOptions[0] ? (sel.selectedOptions[0].dataset.name || "") : "";
try {
await api("POST", "/project/api/projects/" + projectId + "/members", {
user_email: sel.value, user_name: name, role: el("pj-m-role").value,
});
location.reload();
} catch (e) { alert("배정 실패: " + e.message); }
});
document.querySelectorAll(".pj-del-member").forEach(function (b) {
b.addEventListener("click", async function () {
if (!confirm("이 멤버를 프로젝트에서 제외할까요?")) return;
try {
await api("DELETE", "/project/api/projects/" + projectId + "/members/" + encodeURIComponent(b.dataset.email));
location.reload();
} catch (e) { alert("제외 실패: " + e.message); }
});
});
// 최초 렌더 — 아사나처럼 프로젝트에 들어오면 보드(세션)가 기본 화면이다
// (달력을 보던 중이었다면 그 기억된 뷰를 그린다, 위 VIEW_KEY 복원 참고).
renderView(currentView);
// 사이드 트리에서 업무 클릭(?task=ID)으로 들어오면 해당 업무 모달 자동 열기.
// 단, 처리 후 URL 에서 task 파라미터를 제거한다 — 드래그 이동 후 location.reload()
// 시 이 파라미터가 남아 모달이 다시 뜨는 것을 방지.
const params = new URLSearchParams(location.search);
const taskParam = params.get("task");
if (taskParam) {
params.delete("task");
const qs = params.toString();
history.replaceState({}, "", location.pathname + (qs ? "?" + qs : ""));
const t = tasks.find(function (x) { return String(x.id) === String(taskParam); });
if (t) openTaskModal(t);
}
}
// ════════════════════════════════════════════════════════════
// 업무 모달 공용 확장 — 하위 업무 · 연결된 프로젝트(멀티호밍) · 설명 이미지 편집기
// 홈(index.html)과 프로젝트(project.html) 페이지가 업무 모달의 요소 id 를
// 그대로 공유하므로(각자 모달을 따로 갖지만 id는 같다) 여기 한 번만 만들어
// 양쪽 initHome()/initProject() 에서 그대로 쓴다.
// ════════════════════════════════════════════════════════════
// ── 설명(contenteditable) — 붙여넣기/업로드로 이미지를 끼워 넣는다 ──
function initDescEditor() {
const box = el("pj-t-desc");
if (!box) return null;
const imgInput = el("pj-desc-img-input");
const hint = el("pj-desc-hint");
let taskId = null;
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();
let inserted = false;
try { inserted = document.execCommand("insertHTML", false, html); } catch (_) { inserted = false; }
if (!inserted) {
const sel = window.getSelection();
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;
}
}
}
async function insertImageFile(file) {
if (!taskId) { alert("먼저 저장한 뒤 이미지를 추가할 수 있습니다."); return; }
const fd = new FormData();
fd.append("file", file);
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); }
const data = await res.json();
insertHtmlAtCursor('<img src="/project/api/attachments/' + data.attachment.id + '/inline" alt="">');
} catch (e) { alert("이미지 업로드 실패: " + e.message); }
}
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, "<br>"));
});
if (imgInput) imgInput.addEventListener("change", function () {
if (imgInput.files.length) insertImageFile(imgInput.files[0]);
imgInput.value = "";
});
return {
setTaskId: setTaskId,
setHtml: function (html) { box.innerHTML = html || ""; },
getHtml: function () { return box.innerHTML; },
};
}
// ── 하위 업무(인라인) — 체크박스·제목·담당자·마감일을 한 줄에서 바로 저장 ──
function initSubtaskBlock() {
const listEl = el("pj-subtask-list");
if (!listEl) return null;
const input = el("pj-subtask-input");
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 '<option value="' + m.user_email + '">' + escapeHtml(idOf(m.user_email)) + "</option>";
}).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 =
'<input type="checkbox" class="pj-subtask-done"' + (sub.completed_at ? " checked" : "") + " />" +
'<input type="text" class="pj-subtask-title" />' +
'<select class="pj-subtask-assignee"><option value="">(미지정)</option>' + optionsHtml + "</select>" +
'<input type="date" class="pj-subtask-due" />' +
'<button type="button" class="pj-icon-btn pj-subtask-del" title="삭제" hidden>×</button>';
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 ? "" : "<li class='pj-empty-sm'>없음</li>";
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; }
try {
const res = await api("POST", "/project/api/projects/" + projectId + "/tasks",
{ title: title, parent_task_id: taskId });
input.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;
},
render: render,
};
}
// ── 연결된 프로젝트(멀티호밍, 아사나식) — 업무 하나를 다른 프로젝트의 세션에도 연결 ──
function initLinksBlock() {
const chipsEl = el("pj-link-chips");
if (!chipsEl) return null;
const addBtn = el("pj-link-add-btn");
const form = el("pj-link-form");
const projSel = el("pj-link-project");
const stageSel = el("pj-link-stage");
const saveBtn = el("pj-link-save");
const cancelBtn = el("pj-link-cancel");
let taskId = null, homeProjectId = null, onChange = null;
function chipEl(link) {
const span = document.createElement("span");
span.className = "pj-link-chip";
span.innerHTML =
'<span style="color:' + (link.project_color || "#4573d2") + '">●</span> ' +
escapeHtml(link.project_name || "") +
(link.stage_name ? " · " + escapeHtml(link.stage_name) : "") +
' <button type="button" class="pj-link-del" title="연결 해제">×</button>';
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 ? "" : "<span class='pj-empty-sm'>없음</span>";
links.forEach(function (l) { chipsEl.appendChild(chipEl(l)); });
}
if (addBtn) addBtn.addEventListener("click", async function () {
form.hidden = false;
projSel.innerHTML = "<option value=''>불러오는 중…</option>";
stageSel.innerHTML = "<option value=''>프로젝트를 먼저 선택하세요</option>";
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 = "<option value=''>프로젝트 선택…</option>" + list.map(function (p) {
return '<option value="' + p.id + '">' + escapeHtml(p.name) + "</option>";
}).join("");
} catch (e) { projSel.innerHTML = "<option value=''>불러오기 실패</option>"; }
});
if (cancelBtn) cancelBtn.addEventListener("click", function () { form.hidden = true; });
if (projSel) projSel.addEventListener("change", async function () {
const pid = parseInt(projSel.value, 10);
if (!pid) { stageSel.innerHTML = "<option value=''>프로젝트를 먼저 선택하세요</option>"; return; }
stageSel.innerHTML = "<option value=''>불러오는 중…</option>";
try {
const res = await api("GET", "/project/api/projects/" + pid + "/stages");
stageSel.innerHTML = (res.stages || []).map(function (s) {
return '<option value="' + s.id + '">' + escapeHtml(s.name) + "</option>";
}).join("");
} catch (e) { stageSel.innerHTML = "<option value=''>불러오기 실패</option>"; }
});
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 });
form.hidden = true;
if (onChange) onChange();
} catch (e) { alert("추가 실패: " + e.message); }
});
return {
setContext: function (opts) {
taskId = opts.taskId; homeProjectId = opts.projectId; onChange = opts.onChange || null;
form.hidden = true;
},
render: render,
};
}
function idOf(email) {
return String(email == null ? "" : email).split("@")[0];
}
function escapeHtml(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/"/g, "&quot;").replace(/'/g, "&#39;");
}
// ── 알림 벨 (미읽음 배지) ──
async function initBell() {
const badge = document.getElementById("pj-bell-badge");
if (!badge) return;
const bell = badge.closest(".pj-bell");
try {
const res = await fetch("/project/api/notifications/unread-count");
if (!res.ok) return;
const d = await res.json();
if (d.unread > 0) {
badge.textContent = d.unread > 99 ? "99+" : d.unread;
badge.hidden = false;
if (bell) bell.classList.add("has-unread"); // 종 흔들림 애니메이션
}
} catch (_) {}
}
// ── 알림센터 페이지 ──
function initInbox() {
const inbox = document.getElementById("pj-inbox");
const readAll = document.getElementById("pj-readall");
if (!inbox && !readAll) return;
if (readAll) readAll.addEventListener("click", async function () {
try { await fetch("/project/api/notifications/read-all", { method: "POST" }); location.reload(); }
catch (e) { alert("실패: " + e.message); }
});
if (inbox) inbox.querySelectorAll(".pj-inbox-item").forEach(function (li) {
li.addEventListener("click", async function (e) {
if (e.target.closest("a")) return; // '이동' 링크는 통과
if (!li.classList.contains("is-unread")) return;
try {
await fetch("/project/api/notifications/" + li.dataset.id + "/read", { method: "POST" });
li.classList.remove("is-unread");
} catch (_) {}
});
});
}
// ── 실시간 새로고침 ──
// 다른 사용자가 프로젝트/업무/댓글/멤버/단계를 추가·수정하면, 같은 화면을 열어둔
// 모든 사용자가 자동으로 새로고침되도록 주기적으로 서버 버전을 확인한다.
// 모달을 열어 입력 중이면 방해하지 않고, 모달이 모두 닫힐 때(closeModal) 반영한다.
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) {
if (anyModalOpen()) { pjPendingReload = true; }
else { location.reload(); return; }
}
}
} catch (_) {}
setTimeout(poll, 8000);
}
setTimeout(poll, 8000);
}
document.addEventListener("DOMContentLoaded", function () {
initHome();
initProject();
initBell();
initInbox();
initLiveRefresh();
});
})();