Files
dbx-main/app/static/project.js
T
king 1d7fe71b75 feat(project): 세션 헤더 색상 채우기, 보드 댓글팝업, 채팅 첨부파일/이미지
- 세션 색상: 점 대신 세션 헤더 윗부분 배경을 통째로 칠함(글자/카운트 흰색으로)
- 보드 업무 카드의 댓글 말풍선도 더블클릭하면 채팅 팝업 열리게(.pj-cmt-badge 공용)
- 댓글 채팅 팝업에 파일/이미지 첨부 추가: 클립 버튼 업로드 + 클립보드 이미지
  붙여넣기, 이미지는 썸네일, 파일은 확장자 아이콘+파일명으로 표시, 다운로드
  가능, 업로드한 사람만 삭제(즉시 반영). task_attachments.comment_id(마이그
  레이션 005)로 태깅해 업무 "첨부파일" 패널에도 그대로 함께 나온다.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 12:27:44 +09:00

4281 lines
209 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; }
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 + ")";
}
// ── 탭 그룹(뷰 탭/스코프 토글, 둘 다 .pj-view-tabs 마크업 공유)에 슬라이드
// 하이라이트를 붙인다 — is-active 를 단순 배경색 대신, 실제 위치가 움직이는
// 절대배치 요소 하나로 표현한다. 탭 클릭·초기 렌더·리사이즈마다 위치 재계산.
function initTabSlider(container) {
if (!container || container.dataset.pjSlider) return null;
container.dataset.pjSlider = "1";
const hl = document.createElement("div");
hl.className = "pj-tab-highlight";
container.insertBefore(hl, container.firstChild);
function move(animate) {
const active = container.querySelector(".pj-tab.is-active");
if (!active) { hl.style.opacity = "0"; return; }
hl.style.opacity = "1";
if (!animate) hl.classList.add("pj-no-anim");
hl.style.width = active.offsetWidth + "px";
hl.style.height = active.offsetHeight + "px";
hl.style.transform = "translateX(" + active.offsetLeft + "px)";
if (!animate) {
// 강제 리플로우로 즉시 반영시킨 뒤 트랜지션을 다시 켠다(다음 클릭부터 애니메이션).
void hl.offsetWidth;
hl.classList.remove("pj-no-anim");
}
}
move(false);
container.addEventListener("click", function (e) {
if (e.target.closest(".pj-tab")) requestAnimationFrame(function () { move(true); });
});
window.addEventListener("resize", function () { move(false); });
return move;
}
// 멤버 선택 후보 카드(아이콘 위 / 이름 가운데 / 체크박스 아래) — 새 프로젝트
// 모달(홈·프로젝트 페이지)과 멤버 배정 모달 3곳이 전부 이 함수 하나를 쓴다.
// /api/assignable-users 응답의 avatar 필드를 그대로 쓰므로 페이지별
// avatars 맵(홈 페이지엔 없음)에 기대지 않는다.
function memberPickHtml(u) {
const icon = u.avatar
? '<img class="pj-avatar pj-member-pick-icon" src="' + escapeHtml(u.avatar) + '" alt="" referrerpolicy="no-referrer" />'
: '<span class="material-symbols-outlined pj-gicon pj-member-pick-icon">account_circle</span>';
return '<label class="pj-member-pick pj-member-pick-stacked">' +
'<span class="pj-member-pick-avatar">' + icon + "</span>" +
'<span class="pj-member-pick-name">' + escapeHtml(u.id) + "</span>" +
'<input type="checkbox" value="' + u.email + '" data-name="' + escapeHtml(u.name) + '" />' +
"</label>";
}
// ── 색상 팔레트 렌더 ───────────────────────────────────────
// palette 를 생략하면 프로젝트 색(window.PJ_COLORS) — 세션 색상 선택은
// window.PJ_STAGE_COLORS(아사나 10색)를 넘겨 같은 UI를 재사용한다.
function renderColorPalette(container, onPick, initial, palette) {
const colors = palette || 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; };
}
// ════════════════════════════════════════════════════════════
// 댓글만 보는 채팅형 팝업(#pj-modal-comments) — 홈 카드/내 업무, 프로젝트
// 화면 우측 "내 업무" 패널 전부 공용(마크업 id 는 두 페이지가 같은 걸 씀).
// 페이지당 한 번 initCommentsChat() 으로 활성화하고, 이후 동적으로 새로
// 만들어지는 말풍선 배지는 반환된 wireBadge() 로 개별 등록한다(안 하면
// patch 로 새로 그려진 배지는 더블클릭이 죽는다 — 실제 있었던 버그).
// getTaskTitle(taskId) — 채팅창 제목에 쓸 업무 제목을 페이지별 데이터에서
// 찾아 돌려주는 콜백(홈은 homeTasks, 프로젝트 화면은 myTasksById/tasks).
let cmtChat = null;
// 확장자 → 이모지 아이콘(파일 종류 표시용). 이미지는 썸네일로 따로 보여주므로
// 여기 없음.
function chatFileIcon(filename) {
const ext = String(filename || "").split(".").pop().toLowerCase();
const map = {
pdf: "📕", doc: "📄", docx: "📄", hwp: "📄", txt: "📃",
xls: "📊", xlsx: "📊", csv: "📊",
ppt: "📙", pptx: "📙",
zip: "🗜️", rar: "🗜️", "7z": "🗜️",
mp4: "🎬", mov: "🎬", avi: "🎬",
mp3: "🎵", wav: "🎵",
};
return map[ext] || "📎";
}
function initCommentsChat(getTaskTitle) {
const modal = el("pj-modal-comments");
if (!modal) return null;
if (cmtChat) return cmtChat;
wireModalClose(modal);
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 (_) {}
}
function currentUserEmail() {
const wrap = document.querySelector(".pj-wrap");
return ((wrap && wrap.dataset.me) || "").toLowerCase();
}
// ── 보내기 전 대기 중인 파일(파일 선택/붙여넣기로 담은 것들) ──
let pendingFiles = [];
function renderPending() {
const box = el("pj-cm-pending");
if (!box) return;
if (!pendingFiles.length) { box.hidden = true; box.innerHTML = ""; return; }
box.hidden = false;
box.innerHTML = pendingFiles.map(function (f, i) {
const icon = f.type && f.type.indexOf("image/") === 0 ? "🖼️" : chatFileIcon(f.name);
return '<span class="pj-chat-pending-chip">' +
'<span class="pj-chat-att-icon">' + icon + "</span>" +
'<span>' + escapeHtml(f.name) + "</span>" +
'<button type="button" data-idx="' + i + '" title="빼기">×</button></span>';
}).join("");
box.querySelectorAll("button[data-idx]").forEach(function (btn) {
btn.addEventListener("click", function () {
pendingFiles.splice(parseInt(btn.dataset.idx, 10), 1);
renderPending();
});
});
}
const fileInput = el("pj-cm-file-input");
if (fileInput) fileInput.addEventListener("change", function () {
Array.prototype.slice.call(fileInput.files).forEach(function (f) { pendingFiles.push(f); });
fileInput.value = "";
renderPending();
});
const cmInput = el("pj-cm-input");
if (cmInput) cmInput.addEventListener("paste", function (e) {
const items = (e.clipboardData || window.clipboardData || {}).items || [];
let handled = false;
for (let i = 0; i < items.length; i++) {
if (items[i].type && items[i].type.indexOf("image/") === 0) {
const file = items[i].getAsFile();
if (file) { pendingFiles.push(file); handled = true; }
}
}
if (handled) { e.preventDefault(); renderPending(); }
});
// ── 댓글에 딸린 첨부(이미지/파일) 렌더 — 이미지는 썸네일, 그 외는 아이콘+파일명 ──
function attachmentHtml(a, me) {
const mine = (a.uploaded_by || "").toLowerCase() === me;
const isImg = (a.content_type || "").indexOf("image/") === 0;
const delBtn = mine
? '<button type="button" class="pj-chat-att-del" data-id="' + a.id + '" title="삭제">' +
'<span class="material-symbols-outlined">close</span></button>'
: "";
if (isImg) {
return '<div class="pj-chat-att pj-chat-att-img" data-id="' + a.id + '">' +
'<a href="/project/api/attachments/' + a.id + '/download" target="_blank" rel="noopener">' +
'<img src="/project/api/attachments/' + a.id + '/inline" alt="' + escapeHtml(a.filename) + '" /></a>' +
delBtn + "</div>";
}
return '<span class="pj-chat-att pj-chat-att-file" data-id="' + a.id + '">' +
'<a href="/project/api/attachments/' + a.id + '/download">' +
'<span class="pj-chat-att-icon">' + chatFileIcon(a.filename) + '</span>' +
'<span class="pj-chat-att-name">' + escapeHtml(a.filename) + "</span></a>" +
delBtn + "</span>";
}
async function renderChat(taskId) {
const me = currentUserEmail();
const list = el("pj-chat-list");
list.innerHTML = "<li class='pj-empty-sm'>불러오는 중…</li>";
try {
const [cRes, aRes] = await Promise.all([
api("GET", "/project/api/tasks/" + taskId + "/comments"),
api("GET", "/project/api/tasks/" + taskId + "/attachments"),
]);
const comments = cRes.comments || [];
// 댓글에 딸린 첨부만 채팅 말풍선에 표시(comment_id NULL = 설명란
// 이미지/"첨부파일" 패널 전용이라 여기선 무시).
const attByComment = {};
(aRes.attachments || []).forEach(function (a) {
if (a.comment_id == null) return;
(attByComment[a.comment_id] = attByComment[a.comment_id] || []).push(a);
});
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" : "");
const atts = attByComment[c.id] || [];
const attsHtml = atts.length
? '<div class="pj-chat-atts">' + atts.map(function (a) { return attachmentHtml(a, me); }).join("") + "</div>"
: "";
li.innerHTML =
(mine ? "" : '<span class="pj-chat-name">' + escapeHtml(idOf(c.author_email)) + "</span>") +
(c.body ? '<div class="pj-chat-bubble"></div>' : "") +
attsHtml +
'<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>";
const bubble = li.querySelector(".pj-chat-bubble");
if (bubble) 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); }
});
}
li.querySelectorAll(".pj-chat-att-del").forEach(function (btn) {
btn.addEventListener("click", async function (e) {
e.preventDefault(); e.stopPropagation();
if (!confirm("이 파일을 삭제할까요?")) return;
try {
await api("DELETE", "/project/api/attachments/" + btn.dataset.id);
await renderChat(taskId);
} catch (err) { alert("삭제 실패: " + err.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 open(taskId) {
el("pj-cm-task-id").value = taskId;
el("pj-cm-title").textContent = (getTaskTitle && getTaskTitle(taskId)) || "댓글";
pendingFiles = [];
renderPending();
openModal(modal);
const n = await renderChat(taskId);
if (n !== null) { markSeen(taskId, n); syncBadge(taskId, n); }
}
function wireBadge(b) {
if (!b || b.dataset.pjCmtWired) return;
b.dataset.pjCmtWired = "1";
const n = parseInt(b.dataset.count, 10) || 0;
const seenN = loadSeenComments()[b.dataset.taskId] || 0;
if (n > seenN) b.classList.add("is-unread");
b.addEventListener("click", function (e) { e.preventDefault(); e.stopPropagation(); });
b.addEventListener("dblclick", function (e) {
e.preventDefault(); e.stopPropagation();
open(b.dataset.taskId);
});
}
document.querySelectorAll(".pj-cmt-badge").forEach(wireBadge);
el("pj-cm-send").addEventListener("click", async function () {
const taskId = el("pj-cm-task-id").value;
const inp = el("pj-cm-input");
const bodyText = inp.value.trim();
if (!taskId || (!bodyText && !pendingFiles.length)) return;
const filesToSend = pendingFiles.slice();
try {
const res = await api("POST", "/project/api/tasks/" + taskId + "/comments",
{ body: bodyText, has_attachment: filesToSend.length > 0 });
const commentId = res.comment.id;
for (let i = 0; i < filesToSend.length; i++) {
const fd = new FormData();
fd.append("file", filesToSend[i]);
fd.append("comment_id", String(commentId));
try {
const r = await fetch("/project/api/tasks/" + taskId + "/attachments", { method: "POST", body: fd });
if (!r.ok) {
let m = r.statusText;
try { m = (await r.json()).detail || m; } catch (_) {}
showToast("파일 업로드 실패(" + filesToSend[i].name + "): " + m, "error");
}
} catch (_) { showToast("파일 업로드 실패: " + filesToSend[i].name, "error"); }
}
pendingFiles = [];
renderPending();
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(); }
});
cmtChat = { open: open, wireBadge: wireBadge };
return cmtChat;
}
// ════════════════════════════════════════════════════════════
// 홈 페이지 — 새 프로젝트
// ════════════════════════════════════════════════════════════
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;
// ── 프로젝트 카드 수정/삭제 (카드 ✎ 버튼·우클릭 메뉴 공용) ──
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 =
'<div class="pj-project-card" style="--pj-color: ' + color + '"' +
' data-id="' + p.id + '" data-name="' + escapeHtml(p.name || "") + '"' +
' data-desc="' + escapeHtml(p.description || "") + '"' +
' data-start="' + (p.start_date || "") + '" data-due="' + (p.due_date || "") + '"' +
' data-color="' + color + '" data-status="active" data-creator="' + escapeHtml(p.created_by || "") + '">' +
'<a class="pj-project-cardlink" href="/project/p/' + p.id + '">' +
'<span class="pj-project-dot"></span>' +
'<div class="pj-project-body">' +
'<div class="pj-project-name">' + escapeHtml(p.name || "") + "</div>" +
(p.description ? '<div class="pj-project-desc">' + escapeHtml(p.description) + "</div>" : "") +
'<div class="pj-project-meta">' +
(p.due_date ? '<span class="pj-chip">마감 ' + fmtDateKr(p.due_date) + "</span>" : "") +
"</div></div></a>" +
'<div class="pj-card-actions">' +
'<button type="button" class="pj-icon-btn pj-card-addtask" data-project-id="' + p.id + '" title="업무 추가">' +
'<span class="material-symbols-outlined">add_task</span></button>' +
'<button type="button" class="pj-icon-btn pj-card-edit" title="프로젝트 수정">' +
'<span class="material-symbols-outlined">edit</span></button></div>' +
'<div class="pj-project-tasks-empty">진행 중인 업무가 없습니다.</div></div>';
const wrap = document.createElement("div");
wrap.innerHTML = html;
return wrap.firstElementChild;
}
// 카드 하나에 수정/업무추가/우클릭 메뉴를 건다 — 최초 로드된 카드·새로
// 만든 카드 모두 이 함수 하나로 통일(initHome 맨 끝에서 전체 카드에 호출).
function wireProjectCard(card) {
if (isAdmin) {
card.draggable = true;
// <a> 는 브라우저 기본값이 draggable="true" — 그대로 두면 카드 대신
// 링크(URL) 드래그가 시작돼 우리 dragstart 핸들러가 무력화된다.
const link = card.querySelector(".pj-project-cardlink");
if (link) link.draggable = false;
}
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 '<li class="pj-project-taskrow' + (t.completed_at ? " is-done" : "") + '" data-task-id="' + t.id + '">' +
'<a href="/project/p/' + t.project_id + '?task=' + t.id + '" data-task-id="' + t.id + '" class="pj-task-popup-link">' +
'<span class="pj-project-task-lead">' +
(t.comment_count
? '<span class="pj-cmt-badge" data-task-id="' + t.id + '" data-count="' + t.comment_count + '" title="댓글 ' + t.comment_count + '개">' +
'<span class="pj-cmt-ic">💬</span><span class="pj-cmt-n">' + t.comment_count + "</span></span>"
: "") +
"</span>" +
'<span class="pj-project-task-title">' + escapeHtml(t.title || "") + "</span>" +
(t.stage_name ? '<span class="pj-project-task-stage">' + escapeHtml(t.stage_name) + "</span>" : "") +
(assigneeDisplay
? '<span class="pj-project-task-assignee"><span class="material-symbols-outlined pj-gicon pj-gicon-sm">account_circle</span>' +
escapeHtml(assigneeDisplay) + "</span>"
: "") +
(t.due_date ? '<span class="pj-project-task-due' + (overdue ? " is-overdue" : "") + '">' + fmtDateKr(t.due_date) + "</span>" : "") +
"</a></li>";
}
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 = '<div class="pj-project-progress-bar"></div>';
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);
if (cmt) cmt.wireBadge(newLi.querySelector(".pj-cmt-badge"));
// 서버가 처음 그린 <li>(macro)는 data-task-id 를 <li> 자체가 아니라
// 안쪽 .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
? '<span class="pj-mytree-dates">' +
(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) + " ~") +
"</span>"
: "";
const html = '<li class="pj-mytree-task' + (t.completed_at ? " is-done" : "") + '" data-task-id="' + t.id + '">' +
'<a href="/project/p/' + t.project_id + '?task=' + t.id + '" data-task-id="' + t.id + '" class="pj-task-popup-link">' +
'<span class="pj-mytree-lead">' +
(t.comment_count
? '<span class="pj-cmt-badge" data-task-id="' + t.id + '" data-count="' + t.comment_count + '" title="댓글 ' + t.comment_count + '개">' +
'<span class="pj-cmt-ic">💬</span><span class="pj-cmt-n">' + t.comment_count + "</span></span>"
: "") +
"</span>" +
'<span class="pj-mytree-body"><span class="pj-mytree-title">' + escapeHtml(t.title || "") + "</span>" + dates + "</span>" +
"</a></li>";
const wrap = document.createElement("div");
wrap.innerHTML = html;
const newLi = wrap.firstElementChild;
wireHomeTaskPopupLink(newLi.querySelector(".pj-task-popup-link"), t);
if (cmt) cmt.wireBadge(newLi.querySelector(".pj-cmt-badge"));
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 = "<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(memberPickHtml).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 (_) {}
}
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 = '<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 = "[" + (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); });
}
// ── 댓글 말풍선(카드/내 업무) — 더블클릭 시 댓글만 보는 채팅형 팝업.
// 홈/프로젝트 화면 공용 구현은 initCommentsChat() 하나(위쪽 정의) — 여기서는
// 제목 조회 콜백만 홈 데이터(homeTasks)로 넘긴다.
const cmt = initCommentsChat(function (taskId) {
const t = homeTasks[taskId];
return t && t.title;
});
// 최초 로드된 카드 + 이후 새로 만든 카드 모두 여기서 한 번에 배선.
document.querySelectorAll(".pj-project-card").forEach(wireProjectCard);
// 프로젝트 카드 드래그로 순서 변경 — 정렬은 화면을 보는 모두에게 공유되므로
// (세션 재정렬과 같은 원칙) 관리자만 드래그할 수 있다(wireProjectCard 가
// isAdmin 일 때만 draggable 을 켠다).
if (isAdmin) wireProjectGridDrag();
// ── 실시간 동기화: "내 업무" 패널만 8초 안에 반영(카드 미리보기 목록은
// 기존처럼 다음 새로고침까지 유지 — initLiveRefresh 주석 참고). 다른
// 사용자가 내 업무를 수정/완료/재배정해도 여기서 바로 patch 된다.
softSyncProjectRef = async function () {
try {
const res = await api("GET", "/project/api/my-tasks");
const fresh = res.tasks || [];
const freshIds = new Set(fresh.map(function (t) { return t.id; }));
document.querySelectorAll(".pj-mytree-group .pj-mytree-tasks .pj-task-popup-link[data-task-id]").forEach(function (a) {
const id = Number(a.dataset.taskId);
if (freshIds.has(id)) return;
const group = a.closest(".pj-mytree-group");
const pid = group ? Number(group.dataset.groupId) : null;
syncMyTaskPanel({ id: id, project_id: pid }, true);
});
fresh.forEach(function (t) { homeTasks[t.id] = t; syncMyTaskPanel(t); });
} catch (_) {}
};
}
// 프로젝트 카드 드래그 순서 변경 — 진행중/완료 그리드 각각 안에서만
// 재정렬(그리드 사이 드래그로 옮기면 상태가 바뀐 것처럼 보이니 막는다).
function wireProjectGridDrag() {
const grids = [el("pj-project-grid"), el("pj-project-grid-done")].filter(Boolean);
if (!grids.length) return;
let dragCard = null, sourceGrid = null;
grids.forEach(function (grid) {
grid.addEventListener("dragstart", function (e) {
const card = e.target.closest(".pj-project-card");
if (!card || card.draggable !== true) return;
dragCard = card;
sourceGrid = grid;
e.dataTransfer.setData("application/x-pj-project", String(card.dataset.id));
e.dataTransfer.effectAllowed = "move";
card.classList.add("is-dragging");
});
grid.addEventListener("dragend", function () {
if (dragCard) dragCard.classList.remove("is-dragging");
dragCard = null; sourceGrid = null;
});
grid.addEventListener("dragover", function (e) {
if (!dragCard || grid !== sourceGrid) return;
const target = e.target.closest(".pj-project-card");
if (!target || target === dragCard) { e.preventDefault(); return; }
e.preventDefault();
const r = target.getBoundingClientRect();
const before = (e.clientX - r.left) < r.width / 2;
grid.insertBefore(dragCard, before ? target : target.nextSibling);
});
grid.addEventListener("drop", async function (e) {
if (!dragCard || grid !== sourceGrid) return;
e.preventDefault();
const ids = Array.prototype.slice.call(grid.querySelectorAll(".pj-project-card"))
.map(function (c) { return parseInt(c.dataset.id, 10); });
try {
await api("PUT", "/project/api/projects/order", { ordered_ids: ids });
} catch (err) {
showToast("순서 변경 실패: " + err.message, "error");
location.reload();
}
});
});
}
// ════════════════════════════════════════════════════════════
// 프로젝트 페이지
// ════════════════════════════════════════════════════════════
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);
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; });
// 댓글 말풍선 더블클릭 팝업 — 홈 화면과 동일 구현(initCommentsChat, 위쪽
// 정의)을 공용으로 쓴다. project.html 에 모달 마크업이 없었던 게 원인이라,
// 이 화면 "내 업무" 패널의 말풍선이 그동안 안 눌렸다(수정된 버그).
const cmt = initCommentsChat(function (taskId) {
const t = myTasksById[taskId];
return t && t.title;
});
// 우측 "내 업무" 패널 — openTaskModal/mergeTask/refreshActiveView 는 이
// 함수 뒤쪽에서 선언되지만 function 선언은 호이스팅되어 여기서 참조해도
// 문제없다. 이 패널의 업무는 전부 "내가 담당자"인 게 보장돼 있어(서버가
// assignee_email 로 조회) 홈 페이지 syncMyTaskPanel 과 달리 재배정 시
// 사라지는 경우를 신경 쓸 필요 없다.
function myTaskRowHtml(t) {
const dates = t.start_date || t.due_date
? '<span class="pj-mytree-dates">' +
(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) + " ~") +
"</span>"
: "";
return '<li class="pj-mytree-task' + (t.completed_at ? " is-done" : "") + '" data-task-id="' + t.id + '">' +
'<a href="/project/p/' + t.project_id + '?task=' + t.id + '" data-task-id="' + t.id + '" class="pj-task-popup-link">' +
'<span class="pj-mytree-lead">' +
(t.comment_count
? '<span class="pj-cmt-badge" data-task-id="' + t.id + '" data-count="' + t.comment_count + '" title="댓글 ' + t.comment_count + '개">' +
'<span class="pj-cmt-ic">💬</span><span class="pj-cmt-n">' + t.comment_count + "</span></span>"
: "") +
"</span>" +
'<span class="pj-mytree-body"><span class="pj-mytree-title">' + escapeHtml(t.title || "") + "</span>" + dates + "</span>" +
"</a></li>";
}
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 (cmt) cmt.wireBadge(newLi.querySelector(".pj-cmt-badge"));
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 '<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", "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");
const moveViewHighlight = initTabSlider(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;
});
if (moveViewHighlight) moveViewHighlight(false);
}
// ── 프로젝트 스코프 토글 ──
const scopeToggle = el("pj-scope-toggle");
const moveScopeHighlight = initTabSlider(scopeToggle);
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);
});
}
if (moveScopeHighlight) moveScopeHighlight(false);
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 = '<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) {
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 =
'<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 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); }
// ── 드래그로 이동/기간 조절 — 아사나처럼 막대 몸통을 끌면 통째로 이동,
// 양 끝을 끌면 시작·마감일만 조절. 세션(그룹) 간 드래그는 막아둔다(그룹
// 이동은 "다른 세션으로 옮기기"라 보드 드래그의 몫 — 타임라인은 날짜만).
opts.editable = { add: false, remove: false, updateGroup: false, updateTime: canManage };
opts.onMove = function (item, callback) {
const t = tasks.find(function (x) { return x.id === item.id; });
if (!t) { callback(null); return; }
const old = { start_date: t.start_date, due_date: t.due_date, start_time: t.start_time, due_time: t.due_time };
const useTime = !!(t.start_time || t.due_time);
function toParts(d) {
const dt = new Date(d);
const date = dt.getFullYear() + "-" + String(dt.getMonth() + 1).padStart(2, "0") + "-" + String(dt.getDate()).padStart(2, "0");
const time = String(dt.getHours()).padStart(2, "0") + ":" + String(dt.getMinutes()).padStart(2, "0");
return { date: date, time: time };
}
const payload = {};
if (item.type === "point") {
// 단일 날짜 항목 — 원래 채워져 있던 필드(시작 또는 마감, 혹은 둘 다
// 같은 날짜)를 이동한 날짜로 같이 옮긴다.
const p = toParts(item.start);
if (t.start_date) { payload.start_date = p.date; if (useTime) payload.start_time = p.time; }
if (t.due_date) { payload.due_date = p.date; if (useTime) payload.due_time = p.time; }
} else {
const ps = toParts(item.start), pe = toParts(item.end);
payload.start_date = ps.date;
payload.due_date = pe.date;
if (useTime) { payload.start_time = ps.time; payload.due_time = pe.time; }
}
Object.assign(t, payload);
callback(item); // vis 쪽 위치는 즉시 반영(낙관적) — 저장은 백그라운드.
api("PUT", "/project/api/tasks/" + t.id, payload).then(function (res) {
mergeTask(res.task);
}).catch(function (err) {
Object.assign(t, old);
showToast("일정 변경 실패: " + err.message, "error");
// vis 표시만 원래 자리로 되돌린다(전체 재렌더 없이 — 보던 화면 유지).
itemsDS.update({
id: item.id,
start: old.start_date + (old.start_time ? "T" + old.start_time : ""),
end: item.type === "point" ? undefined : (old.due_date || old.start_date) + (old.due_time ? "T" + old.due_time : ""),
});
});
};
const itemsDS = new vis.DataSet(items);
timeline = new vis.Timeline(node, itemsDS, groups, opts);
// 단일 클릭은 선택만(드래그로 기간 조절할 때 양 끝 손잡이가 나타나려면
// 먼저 선택 상태여야 한다 — vis 의 자체 동작) — 여기서 모달을 열면
// 손잡이가 나타나자마자 모달이 덮어써서 드래그를 할 수가 없다. 편집은
// 더블클릭이나 우클릭 메뉴의 "열기"로.
timeline.on("doubleClick", function (props) {
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) 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));
});
// 마우스 오버 시 그 업무의 날짜 구간을 전체 높이 배경 밴드로 살짝 강조
// (아사나에서 막대에 마우스를 올리면 헤더 날짜 부분이 진하게 표시되는 것과
// 같은 목적 — vis 의 날짜 헤더 DOM을 직접 건드리는 대신, 같은 자리에
// 반투명 background 아이템을 하나 얹었다 뺐다 하는 방식으로 구현).
const HOVER_ID = "hover-highlight";
timeline.on("itemover", function (props) {
if (props.item == null || String(props.item).indexOf("bg") === 0 || props.item === HOVER_ID) return;
const it = itemsDS.get(props.item);
if (!it) return;
const start = it.start;
const end = it.end || new Date(new Date(it.start).getTime() + 86400000);
try { itemsDS.remove(HOVER_ID); } catch (_) {}
itemsDS.add({ id: HOVER_ID, type: "background", start: start, end: end, className: "pj-tl-hover-band" });
});
timeline.on("itemout", function (props) {
if (props.item === HOVER_ID) return;
try { itemsDS.remove(HOVER_ID); } catch (_) {}
});
// 사용자가 직접 확대/이동했을 때만 저장(autofit 등 프로그램 변경은 무시).
timeline.on("rangechanged", function (p) { if (p.byUser) saveTLWindow(p.start, p.end); });
const todayBtn = el("pj-tl-today");
if (todayBtn) todayBtn.onclick = function () { timeline.moveTo(new Date()); };
}
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, 표시 여부만 매
// 렌더마다 토글.
// ── 세션 추가/편집 팝업 — 이름 + 색상(아사나 10색). "+ 세션 추가" 버튼과
// 세션 헤더 ✎(세션 편집) 아이콘·컨텍스트 메뉴가 전부 이 모달 하나를 쓴다.
const stageModal = el("pj-modal-stage");
let getStageColor = null;
if (stageModal) wireModalClose(stageModal);
function openStageModal(stage) {
if (!stageModal) return;
el("pj-stage-modal-title").textContent = stage ? "\"" + stage.name + "\" 세션 편집" : "새 세션";
el("pj-stg-id").value = stage ? stage.id : "";
el("pj-stg-name").value = stage ? stage.name : "";
getStageColor = renderColorPalette(
el("pj-stg-colors"), null, stage ? stage.color : null, window.PJ_STAGE_COLORS
);
openModal(stageModal);
}
const saveStageBtn = el("pj-save-stage");
if (saveStageBtn) saveStageBtn.addEventListener("click", async function () {
const id = el("pj-stg-id").value;
const name = el("pj-stg-name").value.trim();
if (!name) { alert("세션 이름을 입력하세요."); return; }
const color = getStageColor ? getStageColor() : null;
try {
if (id) {
await api("PUT", "/project/api/projects/" + projectId + "/stages/" + id, { name: name, color: color });
} else {
await api("POST", "/project/api/projects/" + projectId + "/stages", { name: name, color: color });
}
await reloadStagesForCurrentProject();
closeModal(stageModal);
renderBoard();
} catch (e) { showToast("저장 실패: " + e.message, "error"); }
});
const boardAddStageBtn = el("pj-board-addstage");
if (boardAddStageBtn) {
boardAddStageBtn.addEventListener("click", function () { openStageModal(null); });
}
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 =
'<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) {
// 완료 세션은 끝으로 보낸다 — sort_order 자체는 안 건드리고 정렬
// 기준에서만 뒤로 미루므로, 완료 체크를 해제하면 sort_order 가 그대로라
// 원래 있던 자리로 자동 복귀한다(별도 "이전 위치" 저장 불필요).
const list = (stagesByProject[projectId] || []).slice()
.sort(function (a, b) {
if (!!a.is_done_stage !== !!b.is_done_stage) return a.is_done_stage ? 1 : -1;
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" + (s.is_done_stage ? " pj-col-done" : "");
col.dataset.stageId = s.id;
col.innerHTML =
'<div class="pj-col-head' + (s.color ? " pj-col-head-colored" : "") + '"' +
(canManage ? ' draggable="true"' : "") +
(s.color ? ' style="background:' + s.color + '"' : "") + '>' +
'<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);
wireStageAddTask(body, 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);
}
}
async function reloadStagesForCurrentProject() {
const res = await api("GET", "/project/api/projects/" + projectId + "/stages");
stages = res.stages || [];
stagesByProject[projectId] = stages;
}
function wireColumnControls(col, s) {
function editStage() { openStageModal(s); }
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(); editStage(); });
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: function () { openTaskModal(null, s.id); } },
{ label: "세션 편집", onClick: editStage },
{ label: s.is_done_stage ? "완료 세션 해제" : "완료 세션으로 지정", onClick: toggleDoneStage },
null,
{ label: "삭제", danger: true, onClick: deleteStage },
]);
});
}
// 세션 본문(카드 사이 빈 공간) 우클릭 — "업무 추가"만. 카드 위에서 우클릭한
// 경우는 taskCard() 쪽 컨텍스트 메뉴(수정/완료/삭제 등)가 대신 뜨도록 건너뛴다.
function wireStageAddTask(body, s) {
body.addEventListener("contextmenu", function (e) {
if (e.target.closest(".pj-card")) return;
e.preventDefault();
showContextMenu(e.clientX, e.clientY, [
{ label: "업무 추가", onClick: function () { openTaskModal(null, s.id); } },
]);
});
}
// 세션(컬럼) 드래그 재정렬 — 카드 드래그와 같은 네이티브 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
? '<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 pj-cmt-badge" data-task-id="' + t.id + '" data-count="' + t.comment_count +
'" title="댓글 ' + t.comment_count + '개">' + cmtBadge(t) + "</span>"
: "") +
"</div>";
card.addEventListener("click", function () { if (canManage) openTaskModal(t); });
// 댓글 말풍선 더블클릭 → 채팅형 댓글 팝업(내 업무 패널과 동일 구현 공용).
if (cmt) cmt.wireBadge(card.querySelector(".pj-cmt-badge"));
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 =
'<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);
}
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 = '<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); });
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 =
'<span class="pj-list-group-caret material-symbols-outlined">' +
(isCollapsed ? "chevron_right" : "expand_more") + "</span>" +
'<span class="pj-list-group-name">' + escapeHtml(name) + "</span>" +
'<span class="pj-list-group-count">' + groupTasks.length + "</span>";
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 =
'<div class="pj-dash-stat-label"></div>' +
'<div class="pj-dash-stat-value">0</div>';
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 = "<div class='pj-empty-sm'>데이터가 없습니다.</div>";
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 =
'<div class="pj-dash-barcol-value">' + d.value + "</div>" +
'<div class="pj-dash-barcol-track"><div class="pj-dash-barcol-fill"></div></div>' +
'<div class="pj-dash-barcol-label"></div>';
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 = '<b></b><span></span>';
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 = '<svg viewBox="0 0 120 120" class="pj-dash-donut">' +
'<circle class="pj-dash-donut-track" cx="60" cy="60" r="' + R + '"></circle>';
let offsetAcc = 0;
segments.forEach(function (s, i) {
const frac = total ? s.value / total : 0;
const len = frac * C;
svg += '<circle class="pj-dash-donut-seg" data-i="' + i + '" cx="60" cy="60" r="' + R + '" ' +
'stroke="' + s.color + '" stroke-dasharray="0 ' + C + '" stroke-dashoffset="' + (-offsetAcc) + '" ' +
'transform="rotate(-90 60 60)"></circle>';
offsetAcc += len;
});
svg += '<text x="60" y="65" class="pj-dash-donut-center" text-anchor="middle"></text></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 = '<span class="pj-dash-legend-dot"></span><span class="pj-dash-legend-label"></span> <b></b>';
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 = '<div class="pj-dash-card-title">미완료 작업 합계(섹션별)</div>';
sectionCard.appendChild(dashBarChart(sectionData));
charts.appendChild(sectionCard);
// 완료 상태별 — 2구간 도넛(완료=초록, 미완료=포인트 컬러).
const statusCard = document.createElement("div");
statusCard.className = "pj-dash-card";
statusCard.innerHTML = '<div class="pj-dash-card-title">작업 합계(완료 상태별)</div>';
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 = '<div class="pj-dash-card-title">예정된 작업 합계(담당자별)</div>';
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 '<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, presetStageId) {
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
: (presetStageId != null ? presetStageId : (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 = "<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 () {
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 = '<span class="pj-tree-acts">' +
'<button type="button" class="pj-icon-btn pj-tree-edit" data-id="' + p.id +
'" data-name="' + escapeHtml(p.name) + '" data-desc="' + escapeHtml(p.description || "") +
'" data-start="' + (p.start_date || "") + '" data-due="' + (p.due_date || "") +
'" data-color="' + (p.color || "#4573d2") + '" data-status="' + (p.status || "active") +
'" title="프로젝트 수정">✎</button>' +
'<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>";
}
const initial = escapeHtml((p.name || "?").trim().charAt(0).toUpperCase() || "?");
// 드래그로 순서 변경 가능함을 알려주는 손잡이(⣿) — 관리자에게만 보인다.
const handle = isAdmin ? '<span class="pj-tree-handle" title="드래그로 순서 변경">⣿</span>' : "";
return '<li class="pj-tree-li"' + (isAdmin ? ' draggable="true"' : "") + ' data-id="' + p.id + '"><div class="pj-tree-row ' +
(p.id === projectId ? "is-active" : "") + '">' +
handle +
'<a class="pj-tree-link" href="/project/p/' + p.id + '">' +
'<span class="pj-tree-avatar" style="background:' + (p.color || "#4573d2") + '">' + initial + "</span>" +
'<span class="pj-tree-name">' + escapeHtml(p.name) + "</span></a>" + acts + "</div></li>";
}
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) {
// <a> 는 기본 draggable="true" — 그대로 두면 li 대신 링크(URL) 드래그가
// 시작돼 아래 wireTreeDrag 의 dragstart 핸들러가 무력화된다.
if (isAdmin) rootUl.querySelectorAll(".pj-tree-link").forEach(function (a) { a.draggable = false; });
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);
}
// 좌측 프로젝트 목록 드래그 순서 변경 — 홈 화면 카드 정렬과 같은 API/원칙
// (관리자 전용, 최상위 프로젝트만). rootUl 자체는 renderTree() 가 innerHTML
// 만 바꾸고 element 를 갈아치우지 않으므로 리스너는 한 번만 붙이면 된다.
function wireTreeDrag(rootUl) {
if (!isAdmin || !rootUl) return;
let dragLi = null;
rootUl.addEventListener("dragstart", function (e) {
const li = e.target.closest(".pj-tree-li");
if (!li) return;
dragLi = li;
e.dataTransfer.setData("application/x-pj-tree", String(li.dataset.id));
e.dataTransfer.effectAllowed = "move";
li.classList.add("is-dragging");
});
rootUl.addEventListener("dragend", function () {
if (dragLi) dragLi.classList.remove("is-dragging");
dragLi = null;
});
rootUl.addEventListener("dragover", function (e) {
if (!dragLi) return;
const li = e.target.closest(".pj-tree-li");
if (!li || li === dragLi) { e.preventDefault(); return; }
e.preventDefault();
const r = li.getBoundingClientRect();
const before = (e.clientY - r.top) < r.height / 2;
rootUl.insertBefore(dragLi, before ? li : li.nextSibling);
});
rootUl.addEventListener("drop", async function (e) {
if (!dragLi) return;
e.preventDefault();
const ids = Array.prototype.slice.call(rootUl.querySelectorAll(".pj-tree-li"))
.map(function (li) { return parseInt(li.dataset.id, 10); });
try {
await api("PUT", "/project/api/projects/order", { ordered_ids: ids });
projectsMeta.sort(function (a, b) { return ids.indexOf(a.id) - ids.indexOf(b.id); });
} catch (err) {
showToast("순서 변경 실패: " + err.message, "error");
renderTree();
}
});
}
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 pillEl = document.querySelector(".pj-project-title-pill");
if (pillEl) {
pillEl.textContent = currentProject.name || "";
pillEl.style.setProperty("--pj-color", currentProject.color || "#4573d2");
}
}
}
renderTree();
wireTreeDrag(el("pj-tree-root"));
// 새 최상위 프로젝트 (관리자)
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 = "<span class='pj-empty-sm'>멤버 배정은 관리자가 나중에 할 수 있습니다.</span>";
} 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("")
: "<span class='pj-empty-sm'>배정 가능한 직원이 없습니다.</span>";
} catch (e) {
newProjMembersBox.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 = 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 "<li data-email=\"" + escapeHtml(m.user_email) + "\">" +
(url
? '<img class="pj-avatar pj-gicon-sm" src="' + escapeHtml(url) + '" alt="" referrerpolicy="no-referrer" />'
: '<span class="material-symbols-outlined pj-gicon">account_circle</span>') +
'<span class="pj-member-name">' + escapeHtml(idOf(m.user_email)) + "</span>" +
(m.role === "manager" ? '<span class="pj-chip pj-chip-sm">관리</span>' : "") +
(isAdmin ? '<button type="button" class="pj-icon-btn pj-del-member" data-email="' +
escapeHtml(m.user_email) + '" title="제외">×</button>' : "") +
"</li>";
}
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("") : "<li class='pj-empty-sm'>없음</li>";
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();
// ── 전체 멤버(프로젝트 권한 보유 등록 사용자 전원) — 아이콘 클릭 시
// 그 사람이 담당자인 업무를 팝업으로 보여준다(프로젝트/세션/마감일).
function allMemberItemHtml(u) {
const icon = u.avatar
? '<img class="pj-avatar pj-member-pick-icon" src="' + escapeHtml(u.avatar) + '" alt="" referrerpolicy="no-referrer" />'
: '<span class="material-symbols-outlined pj-gicon pj-member-pick-icon">account_circle</span>';
return '<button type="button" class="pj-allmember-item" data-email="' + escapeHtml(u.email) + '" data-name="' + escapeHtml(u.id) + '">' +
icon + '<span class="pj-allmember-name">' + escapeHtml(u.id) + "</span></button>";
}
const memberTasksModal = el("pj-modal-member-tasks");
if (memberTasksModal) wireModalClose(memberTasksModal);
let mtItems = [];
let mtSort = { key: null, dir: 1 };
function mtSortValue(t, key) {
if (key === "project") return (t.project_name || "").toLowerCase();
if (key === "stage") return (t.stage_name || "").toLowerCase();
if (key === "title") return (t.title || "").toLowerCase();
if (key === "due") return t.due_date || "9999-99-99";
return "";
}
function mtRowHtml(t) {
return '<li class="pj-mt-row' + (t.completed_at ? " is-done" : "") + '" data-task-id="' + t.id + '">' +
'<span class="pj-mt-proj" style="color:' + (t.project_color || "#4573d2") + '">' + escapeHtml(t.project_name || "-") + "</span>" +
'<span class="pj-mt-stage">' + escapeHtml(t.stage_name || "미지정") + "</span>" +
'<span class="pj-mt-title2">' + escapeHtml(t.title || "") + "</span>" +
'<span class="pj-mt-due">' + (t.due_date ? fmtDateKr(t.due_date) : "-") + "</span>" +
"</li>";
}
const mtHead = el("pj-mt-head");
function updateMtSortIcons() {
if (!mtHead) return;
mtHead.querySelectorAll(".pj-mt-h").forEach(function (h) {
const icon = h.querySelector(".pj-mt-sort-icon");
if (!icon) return;
icon.textContent = h.dataset.sort === mtSort.key ? (mtSort.dir === 1 ? "▲" : "▼") : "";
});
}
function renderMtList() {
const list = el("pj-mt-list");
if (!mtItems.length) { list.innerHTML = "<li class='pj-empty-sm'>배정된 업무가 없습니다.</li>"; return; }
const sorted = mtItems.slice();
if (mtSort.key) {
sorted.sort(function (a, b) {
const va = mtSortValue(a, mtSort.key), vb = mtSortValue(b, mtSort.key);
if (va < vb) return -1 * mtSort.dir;
if (va > vb) return 1 * mtSort.dir;
return 0;
});
}
list.innerHTML = sorted.map(mtRowHtml).join("");
// 더블클릭 = 업무 편집 팝업(현재 프로젝트가 아니어도 openTaskModal 은
// task.project_id 기준으로 담당자/세션 옵션을 다시 채우므로 그대로 동작).
list.querySelectorAll("li[data-task-id]").forEach(function (li) {
li.addEventListener("dblclick", function () {
const t = mtItems.find(function (x) { return String(x.id) === li.dataset.taskId; });
if (!t) return;
closeModal(memberTasksModal);
openTaskModal(t);
});
});
}
if (mtHead) mtHead.addEventListener("click", function (e) {
const h = e.target.closest(".pj-mt-h");
if (!h) return;
const key = h.dataset.sort;
if (mtSort.key === key) mtSort.dir *= -1; else { mtSort.key = key; mtSort.dir = 1; }
updateMtSortIcons();
renderMtList();
});
async function openMemberTasksModal(email, name) {
if (!memberTasksModal) return;
el("pj-mt-title").textContent = (name || email) + "님의 업무";
mtItems = [];
mtSort = { key: null, dir: 1 };
updateMtSortIcons();
const list = el("pj-mt-list");
list.innerHTML = "<li class='pj-empty-sm'>불러오는 중…</li>";
openModal(memberTasksModal);
try {
const res = await api("GET", "/project/api/users/tasks?email=" + encodeURIComponent(email));
mtItems = res.tasks || [];
renderMtList();
} catch (e) {
list.innerHTML = "<li class='pj-empty-sm'>불러오기 실패: " + escapeHtml(e.message) + "</li>";
}
}
const allMembersGrid = el("pj-allmembers-grid");
if (allMembersGrid) {
api("GET", "/project/api/all-users").then(function (res) {
const users = res.users || [];
allMembersGrid.innerHTML = users.length
? users.map(allMemberItemHtml).join("")
: "<span class='pj-empty-sm'>없음</span>";
allMembersGrid.querySelectorAll(".pj-allmember-item").forEach(function (btn) {
btn.addEventListener("click", function () { openMemberTasksModal(btn.dataset.email, btn.dataset.name); });
});
}).catch(function (e) {
allMembersGrid.innerHTML = "<span class='pj-empty-sm'>불러오기 실패: " + escapeHtml(e.message) + "</span>";
});
}
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("")
: "<span class='pj-empty-sm'>배정 가능한 직원이 없습니다(권한 미부여 또는 전원 배정됨)</span>";
} catch (e) {
box.innerHTML = "<span class='pj-empty-sm'>불러오기 실패: " + escapeHtml(e.message) + "</span>";
}
});
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, myRes] = await Promise.all([
api("GET", "/project/api/projects/" + projectId + "/tasks"),
api("GET", "/project/api/projects/" + projectId + "/stages"),
api("GET", "/project/api/projects/" + projectId + "/members"),
api("GET", "/project/api/my-tasks"),
]);
// 서버 페이지 렌더 시엔 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();
// 우측 "내 업무" 패널 — 다른 사람이 내 업무를 수정/완료/재배정해도
// 다음 전체 새로고침까지 기다리지 않고 8초 안에 반영되게 patch.
const freshMy = myRes.tasks || [];
const freshIds = new Set(freshMy.map(function (t) { return t.id; }));
Object.keys(myTasksById).forEach(function (idStr) {
const id = Number(idStr);
if (freshIds.has(id)) return;
const old = myTasksById[id];
delete myTasksById[id];
syncProjectMyTaskPanel(old, true);
});
freshMy.forEach(function (t) {
myTasksById[t.id] = t;
syncProjectMyTaskPanel(t);
});
} 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 =
'<button type="button" class="pj-img-del" title="이미지 삭제">×</button>' +
'<span class="pj-img-handle" title="드래그로 크기 조절"></span>';
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('<img src="/project/api/attachments/' + data.attachment.id + '/inline" alt=""' + widthAttr + '>');
} 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, "<br>"));
});
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 '<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; }
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 = "<option value=''>(미지정)</option>" + 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 =
'<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)); });
}
async function loadProjects() {
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 (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 });
projSel.value = "";
stageSel.innerHTML = "<option value=''>프로젝트를 먼저 선택하세요</option>";
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, "&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" });
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 한다(입력 중인 모달 폼은 건드리지 않음) — initHome()/
// 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 = '<div class="pj-vbar-track" hidden><div class="pj-vbar-thumb"></div></div>';
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();
});
})();