/* 프로젝트 관리(아사나식) 모듈 — 프론트엔드.
* 두 페이지에서 공용:
* - 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;
}
// "#RRGGBB" → "rgba(r,g,b,alpha)" — 세션 헤더 배경처럼 색은 유지한 채
// 투명도만 입혀야 할 때 쓴다.
function hexToRgba(hex, alpha) {
const h = String(hex || "").replace("#", "");
const full = h.length === 3 ? h.split("").map(function (c) { return c + c; }).join("") : h;
const r = parseInt(full.substr(0, 2), 16);
const g = parseInt(full.substr(2, 2), 16);
const b = parseInt(full.substr(4, 2), 16);
if (isNaN(r) || isNaN(g) || isNaN(b)) return hex;
return "rgba(" + r + "," + g + "," + b + "," + alpha + ")";
}
// 업무 모달(#pj-modal-task) 크기 — 기존 업무 편집은 하위업무/첨부/연결/
// 댓글까지 보여야 해서 800x1100 고정, "새 업무" 작성은 그 칸들이 전부
// 숨는 홑겹 폼이라 480x700 로 작게(홈 카드·프로젝트 화면 양쪽에서 공용).
function setTaskModalCompact(compact) {
const card = document.querySelector("#pj-modal-task .pj-task-modal-card");
if (card) card.classList.toggle("pj-modal-compact", !!compact);
}
// 멤버 선택 후보 카드(아이콘 위 / 이름 가운데 / 체크박스 아래) — 새 프로젝트
// 모달(홈·프로젝트 페이지)과 멤버 배정 모달 3곳이 전부 이 함수 하나를 쓴다.
// /api/assignable-users 응답의 avatar 필드를 그대로 쓰므로 페이지별
// avatars 맵(홈 페이지엔 없음)에 기대지 않는다.
function memberPickHtml(u) {
const icon = u.avatar
? '
'
: 'account_circle';
return '";
}
// ── 색상 팔레트 렌더 ───────────────────────────────────────
// 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 '' +
'' + icon + "" +
'' + escapeHtml(f.name) + "" +
'';
}).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
? ''
: "";
if (isImg) {
return '
";
}
return '' +
'' +
'' + chatFileIcon(a.filename) + '' +
'' + escapeHtml(a.filename) + "" +
delBtn + "";
}
async function renderChat(taskId) {
const me = currentUserEmail();
const list = el("pj-chat-list");
list.innerHTML = "불러오는 중…";
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 = "댓글 없음";
} 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
? '' + atts.map(function (a) { return attachmentHtml(a, me); }).join("") + "
"
: "";
li.innerHTML =
(mine ? "" : '' + escapeHtml(idOf(c.author_email)) + "") +
(c.body ? '' : "") +
attsHtml +
'' +
(mine ? '' : "") +
'' + (c.created_at || "").slice(0, 16).replace("T", " ") + "" +
"";
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 = "불러오기 실패";
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";
// 이름은 이제 색칠된 헤더 안에 있으므로(afterend 로 넣으면 헤더
// 안에 끼어버림), 본문(.pj-project-body) 맨 앞에 넣는다.
const body = card.querySelector(".pj-project-body");
if (body) body.insertBefore(descEl, body.firstChild);
}
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 =
'';
const wrap = document.createElement("div");
wrap.innerHTML = html;
return wrap.firstElementChild;
}
// ── 세션 추가(홈 카드 hover 아이콘) — 프로젝트 화면 보드의 "+ 세션 추가"와
// 같은 폼(이름+색상)을 그대로 재사용. 홈에는 "현재 프로젝트" 개념이
// 없으므로 어느 카드에서 눌렀는지 projectId 를 직접 들고 다닌다.
const stageModal = el("pj-modal-stage");
let getStageColor = null;
let stageModalProjectId = null;
if (stageModal) wireModalClose(stageModal);
function openStageModalForProject(projectId, projectName) {
if (!stageModal) return;
stageModalProjectId = projectId;
el("pj-stage-modal-title").textContent = (projectName ? "\"" + projectName + "\" " : "") + "세션 추가";
el("pj-stg-id").value = "";
el("pj-stg-name").value = "";
getStageColor = renderColorPalette(el("pj-stg-colors"), null, null, window.PJ_STAGE_COLORS);
openModal(stageModal);
}
const saveStageBtn = el("pj-save-stage");
if (saveStageBtn) saveStageBtn.addEventListener("click", async function () {
const name = el("pj-stg-name").value.trim();
if (!name) { alert("세션 이름을 입력하세요."); return; }
if (!stageModalProjectId) return;
const color = getStageColor ? getStageColor() : null;
try {
await api("POST", "/project/api/projects/" + stageModalProjectId + "/stages", { name: name, color: color });
closeModal(stageModal);
// 이 프로젝트에 세션이 처음 생겼으면 "업무 추가" 버튼을 바로 활성화.
const targetCard = document.querySelector('.pj-project-card[data-id="' + stageModalProjectId + '"]');
if (targetCard) {
targetCard.dataset.hasStages = "true";
const addTaskBtn = targetCard.querySelector(".pj-card-addtask");
if (addTaskBtn) { addTaskBtn.disabled = false; addTaskBtn.title = "업무 추가"; }
}
showToast("세션이 추가되었습니다.", "success");
} catch (e) { showToast("추가 실패: " + e.message, "error"); }
});
// 카드 하나에 수정/업무추가/세션추가/우클릭 메뉴를 건다 — 최초 로드된
// 카드·새로 만든 카드 모두 이 함수 하나로 통일(initHome 맨 끝에서 전체
// 카드에 호출).
function wireProjectCard(card) {
if (isAdmin) {
card.draggable = true;
// 는 브라우저 기본값이 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);
});
const addStageBtn = card.querySelector(".pj-card-addstage");
if (addStageBtn) addStageBtn.addEventListener("click", function (e) {
e.preventDefault(); e.stopPropagation();
openStageModalForProject(card.dataset.id, card.dataset.name);
});
card.addEventListener("contextmenu", function (e) {
if (!card.querySelector(".pj-card-edit")) return; // 관리 권한 없는 카드는 메뉴 없음
e.preventDefault();
const items = [
{ label: "열기", onClick: function () { location.href = "/project/p/" + card.dataset.id; } },
];
// 세션이 없으면 업무를 넣을 곳이 없다 — 메뉴에서도 뺀다(아이콘 버튼과 동일 원칙).
if (card.dataset.hasStages !== "false") {
items.push({ label: "업무 추가", onClick: function () { if (openNewTaskModalRef) openNewTaskModalRef(card.dataset.id, card.dataset.name); } });
}
items.push({ label: "세션 추가", onClick: function () { openStageModalForProject(card.dataset.id, card.dataset.name); } });
items.push({ label: "수정", onClick: function () { openCardEditModal(card); } });
items.push(null);
items.push({ label: "삭제", danger: true, onClick: function () { deleteProjectCard(card); } });
showContextMenu(e.clientX, e.clientY, items);
});
}
// ── 홈 카드 안 업무 리스트 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 '' +
'";
}
function updateHomeCardProgress(projectId) {
const card = document.querySelector('.pj-project-card[data-id="' + projectId + '"]');
if (!card) return;
const total = card.querySelectorAll(".pj-project-taskrow").length;
const done = card.querySelectorAll(".pj-project-taskrow.is-done").length;
const meta = card.querySelector(".pj-project-meta");
if (meta) {
let chip = null;
meta.querySelectorAll(".pj-chip").forEach(function (c) { if (c.textContent.indexOf("업무") === 0) chip = c; });
if (total) {
const label = "업무 " + done + "/" + total;
if (chip) chip.textContent = label;
else { chip = document.createElement("span"); chip.className = "pj-chip"; chip.textContent = label; meta.appendChild(chip); }
} else if (chip) chip.remove();
}
const body = card.querySelector(".pj-project-body");
let bar = card.querySelector(".pj-project-progress-bar");
if (total) {
if (!bar && body) {
const w = document.createElement("div");
w.className = "pj-project-progress";
w.innerHTML = '';
body.appendChild(w);
bar = w.firstElementChild;
}
if (bar) bar.style.width = Math.round(done * 100 / total) + "%";
} else if (bar) {
const prog = bar.closest(".pj-project-progress");
if (prog) prog.remove();
}
}
// 업무 우클릭 메뉴 — 홈 카드 안 업무 행·"내 업무" 패널 행 공용(둘 다 같은
// .pj-task-popup-link 를 씀). 열기=프로젝트 페이지로 실제 이동, 수정=이
// 자리에서 팝업 편집 — 서로 다른 동작이라 항목을 나눴다. 카드 자체의
// 우클릭 메뉴와 겹치지 않도록 stopPropagation 필수(안 하면 이벤트가
// 버블링돼 카드 메뉴가 나중에 열려 덮어써 버린다).
function homeTaskContextItems(t) {
return [
{ label: "열기", onClick: function () { location.href = "/project/p/" + t.project_id + "?task=" + t.id; } },
{ label: "업무 추가", onClick: function () { if (openNewTaskModalRef) openNewTaskModalRef(t.project_id, t.project_name); } },
{ label: "수정", onClick: function () { if (openHomeTaskModalRef) openHomeTaskModalRef(t); } },
{
label: t.completed_at ? "완료 취소" : "완료로 표시",
onClick: async function () {
try {
const res = await api("PUT", "/project/api/tasks/" + t.id, { completed: !t.completed_at });
upsertHomeTaskRow(res.task);
} catch (e) { showToast("처리 실패: " + e.message, "error"); }
},
},
null,
{
label: "삭제", danger: true,
onClick: async function () {
if (!confirm("이 업무를 삭제할까요?")) return;
try {
await api("DELETE", "/project/api/tasks/" + t.id);
removeHomeTaskRow(t.id, t.project_id);
} catch (e) { showToast("삭제 실패: " + e.message, "error"); }
},
},
];
}
// .pj-task-popup-link 하나에 클릭(팝업 열기)+우클릭(메뉴) 배선 — 최초
// 로드된 행과 optimistic 하게 새로 그려진 행(upsertHomeTaskRow/
// syncMyTaskPanel) 모두 이 함수 하나로 통일.
function wireHomeTaskPopupLink(a, taskOverride) {
a.addEventListener("click", function (e) {
const task = taskOverride || homeTasks[a.dataset.taskId];
if (!task) return; // 정보 없으면 기존 링크 이동으로 폴백
e.preventDefault();
if (openHomeTaskModalRef) openHomeTaskModalRef(task);
});
a.addEventListener("contextmenu", function (e) {
const task = taskOverride || homeTasks[a.dataset.taskId];
if (!task) return;
e.preventDefault();
e.stopPropagation();
showContextMenu(e.clientX, e.clientY, homeTaskContextItems(task));
});
}
function upsertHomeTaskRow(t) {
const card = document.querySelector('.pj-project-card[data-id="' + t.project_id + '"]');
if (!card) { syncMyTaskPanel(t); return; }
homeTasks[t.id] = t;
let list = card.querySelector(".pj-project-tasklist");
const emptyMsg = card.querySelector(".pj-project-tasks-empty");
if (!list) {
list = document.createElement("ul");
list.className = "pj-project-tasklist";
if (emptyMsg) emptyMsg.replaceWith(list);
else { const body = card.querySelector(".pj-project-body"); if (body) body.appendChild(list); }
}
const wrap = document.createElement("div");
wrap.innerHTML = homeTaskRowHtml(t);
const newLi = wrap.firstElementChild;
wireHomeTaskPopupLink(newLi.querySelector(".pj-task-popup-link"), t);
if (cmt) cmt.wireBadge(newLi.querySelector(".pj-cmt-badge"));
// 서버가 처음 그린 (macro)는 data-task-id 를 자체가 아니라
// 안쪽 .pj-task-popup-link 에만 붙인다 — 그 기준으로 찾아야 최초
// 페이지 로드 때부터 있던 행(아직 한 번도 patch 안 된 행)도 잡힌다.
const existingLink = list.querySelector('.pj-task-popup-link[data-task-id="' + t.id + '"]');
const existing = existingLink ? existingLink.closest("li") : null;
if (existing) existing.replaceWith(newLi); else list.appendChild(newLi);
updateHomeCardProgress(t.project_id);
syncMyTaskPanel(t);
}
function removeHomeTaskRow(id, projectId) {
const link = document.querySelector('.pj-project-taskrow .pj-task-popup-link[data-task-id="' + id + '"]');
const li = link ? link.closest("li") : null;
const pid = projectId || (li && li.closest(".pj-project-card") && li.closest(".pj-project-card").dataset.id);
if (li) {
const list = li.closest(".pj-project-tasklist");
li.remove();
if (list && !list.children.length) {
const empty = document.createElement("div");
empty.className = "pj-project-tasks-empty";
empty.textContent = "진행 중인 업무가 없습니다.";
list.replaceWith(empty);
}
}
delete homeTasks[id];
if (pid) updateHomeCardProgress(pid);
syncMyTaskPanel({ id: id, project_id: pid }, true);
}
// "내 업무" 패널(우측) 동기화 — 이 프로젝트에 내 업무 그룹이 이미 있을
// 때만 patch 한다(그룹 자체가 없던 경우는 드물어 다음 새로고침까지 생략).
function syncMyTaskPanel(t, removed) {
const group = document.querySelector('.pj-mytree-group[data-group-id="' + t.project_id + '"] .pj-mytree-tasks');
if (!group) return;
const isMine = !removed && t.assignee_email && String(t.assignee_email).toLowerCase() === me;
const existingLink = group.querySelector('.pj-task-popup-link[data-task-id="' + t.id + '"]');
const existingLi = existingLink ? existingLink.closest("li") : null;
if (!isMine) { if (existingLi) existingLi.remove(); return; }
const dates = t.start_date || t.due_date
? '' +
(t.start_date && t.due_date ? fmtDateKr(t.start_date) + " ~ " + fmtDateKr(t.due_date)
: t.due_date ? "~ " + fmtDateKr(t.due_date) : fmtDateKr(t.start_date) + " ~") +
""
: "";
const html = '' +
'";
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 = "멤버 배정은 관리자가 나중에 할 수 있습니다.";
} else {
membersBox.innerHTML = "불러오는 중…";
try {
const res = await api("GET", "/project/api/assignable-users");
const users = res.users || [];
membersBox.innerHTML = users.length
? users.map(memberPickHtml).join("")
: "배정 가능한 직원이 없습니다.";
} catch (e) {
membersBox.innerHTML = "불러오기 실패";
}
}
}
});
el("pj-save-project").addEventListener("click", async function () {
const name = el("pj-f-name").value.trim();
if (!name) { alert("이름을 입력하세요."); return; }
const memberEmails = membersBox
? Array.from(membersBox.querySelectorAll("input[type=checkbox]:checked")).map(function (c) { return c.value; })
: [];
try {
const res = await api("POST", "/project/api/projects", {
name: name,
description: el("pj-f-desc").value.trim(),
start_date: el("pj-f-start").value || null,
due_date: el("pj-f-due").value || null,
color: getColor(),
});
const pid = res.project.id;
for (const email of memberEmails) {
try { await api("POST", "/project/api/projects/" + pid + "/members", { user_email: email }); }
catch (_) {}
}
closeModal(modal);
if (grid) {
const card = projectCardHtml(res.project);
grid.insertBefore(card, grid.firstChild);
wireProjectCard(card);
}
} catch (e) { showToast("생성 실패: " + e.message, "error"); }
});
}
// ── 프로젝트 수정 모달 (진행중/완료 두 그리드 모두 대상) ──
if (editModal && grid) {
wireModalClose(editModal);
el("pj-save-edit").addEventListener("click", async function () {
const id = el("pj-e-id").value;
const name = el("pj-e-name").value.trim();
if (!name) { alert("이름을 입력하세요."); return; }
try {
const res = await api("PUT", "/project/api/projects/" + id, {
name: name,
description: el("pj-e-desc").value.trim(),
start_date: el("pj-e-start").value || null,
due_date: el("pj-e-due").value || null,
color: getEditColor ? getEditColor() : undefined,
status: el("pj-e-done").checked ? "completed" : "active",
});
const card = document.querySelector('.pj-project-card[data-id="' + id + '"]');
if (card) patchProjectCardDom(card, res.project);
closeModal(editModal);
} catch (e) { showToast("저장 실패: " + e.message, "error"); }
});
el("pj-delete-project").addEventListener("click", async function () {
const id = el("pj-e-id").value;
const card = document.querySelector('.pj-project-card[data-id="' + id + '"]');
if (card && !canDelete(card.dataset.creator)) { alert("삭제는 만든 사람만 할 수 있습니다."); return; }
if (!confirm("이 프로젝트를 삭제할까요? 안의 업무도 모두 삭제됩니다.")) return;
try {
await api("DELETE", "/project/api/projects/" + id);
if (card) card.remove();
closeModal(editModal);
}
catch (e) { showToast("삭제 실패: " + e.message, "error"); }
});
}
// 카드/내 업무 클릭 팝업 + 댓글 팝업 공용 — 업무 원본 데이터(제목 등).
const homeDataEl = el("pj-home-data");
let homeTasks = {};
if (homeDataEl) {
try { homeTasks = JSON.parse(homeDataEl.textContent) || {}; } catch (_) {}
}
// ── 업무 편집 팝업 (카드/내 업무 클릭 시 페이지 이동 없이) ──
const homeTaskModal = el("pj-modal-task");
if (homeTaskModal) {
const descEditor = initDescEditor();
const subtaskBlock = initSubtaskBlock();
const linksBlock = initLinksBlock();
if (descEditor) {
descEditor.setOnImageRemoved(function () {
const tid = el("pj-t-id").value;
if (tid) loadHomeAttachments(tid);
});
}
// 닫을 때(배경 클릭·Esc·취소 버튼) 수정한 내용이 있으면 저장할지 물어본다.
let homeTaskSnapshot = null;
function homeTaskFormSnapshot() {
return JSON.stringify({
title: el("pj-t-title").value,
assignee: el("pj-t-assignee").value,
stage: el("pj-t-stage").value,
priority: el("pj-t-priority").value,
start: el("pj-t-start").value,
due: el("pj-t-due").value,
usetime: el("pj-t-usetime").checked,
stime: el("pj-t-stime").value,
dtime: el("pj-t-dtime").value,
desc: descEditor ? descEditor.getHtml() : "",
});
}
function isHomeTaskDirty() {
return homeTaskSnapshot !== null && homeTaskFormSnapshot() !== homeTaskSnapshot;
}
let homeTaskClosing = false;
async function attemptCloseHomeTaskModal() {
// erpConfirm 도 Esc 로 닫히는데 stopPropagation 을 안 해서, 확인창이
// 뜬 상태에서 Esc 를 누르면 이 함수가 다시 불릴 수 있다 — 재진입 방지.
if (homeTaskModal.hidden || homeTaskClosing) return;
homeTaskClosing = true;
try {
if (isHomeTaskDirty()) {
const save = await window.erpConfirm(
"수정한 내용이 있습니다. 저장하시겠습니까?",
{ okText: "저장", cancelText: "저장 안 함" }
);
if (save) { el("pj-save-task").click(); return; }
}
homeTaskSnapshot = null;
closeModal(homeTaskModal);
} finally { homeTaskClosing = false; }
}
homeTaskModal.addEventListener("click", function (e) {
if (e.target === homeTaskModal || e.target.hasAttribute("data-close")) attemptCloseHomeTaskModal();
});
document.addEventListener("keydown", function (e) {
if (e.key === "Escape" && !homeTaskModal.hidden) { e.preventDefault(); attemptCloseHomeTaskModal(); }
});
async function loadTaskExtras(task) {
if (!subtaskBlock && !linksBlock) return;
try {
const res = await api("GET", "/project/api/tasks/" + task.id);
const detail = res.task;
if (subtaskBlock) {
subtaskBlock.setContext({ taskId: task.id, projectId: task.project_id, canDelete: canDelete });
subtaskBlock.render(detail.subtasks || []);
}
if (linksBlock) {
linksBlock.setContext({
taskId: task.id, projectId: task.project_id,
onChange: function () { loadTaskExtras(task); },
});
linksBlock.render(detail.links || []);
}
} catch (_) { /* 조용히 무시 — 본편집은 계속 가능해야 한다 */ }
}
async function fillAssignee(pid, currentEmail) {
const sel = el("pj-t-assignee");
let list = [];
try { list = (await api("GET", "/project/api/projects/" + pid + "/members")).members || []; }
catch (_) { list = []; }
sel.innerHTML = '' + list.map(function (m) {
const name = idOf(m.user_email);
return '";
}).join("");
sel.value = currentEmail || "";
}
async function fillStage(pid, currentStageId) {
const sel = el("pj-t-stage");
let list = [];
try { list = (await api("GET", "/project/api/projects/" + pid + "/stages")).stages || []; }
catch (_) { list = []; }
sel.innerHTML = list.map(function (s) {
return '";
}).join("");
sel.value = currentStageId || (list[0] ? list[0].id : "");
}
async function loadHomeAttachments(taskId) {
const ul = el("pj-attach-list");
ul.innerHTML = "불러오는 중…";
try {
const res = await api("GET", "/project/api/tasks/" + taskId + "/attachments");
if (!res.attachments.length) { ul.innerHTML = "없음"; return; }
ul.innerHTML = "";
res.attachments.forEach(function (a) {
const li = document.createElement("li");
li.className = "pj-attach-item";
li.innerHTML =
'' + escapeHtml(a.filename) + "" +
'';
ul.appendChild(li);
});
ul.querySelectorAll(".pj-attach-del").forEach(function (b) {
b.addEventListener("click", async function () {
if (!confirm("이 첨부를 삭제할까요?")) return;
try { await api("DELETE", "/project/api/attachments/" + b.dataset.id); loadHomeAttachments(taskId); }
catch (e) { alert("삭제 실패: " + e.message); }
});
});
} catch (e) { ul.innerHTML = "불러오기 실패"; }
}
const homeAttachInput = el("pj-attach-input");
if (homeAttachInput) homeAttachInput.addEventListener("change", async function () {
const taskId = el("pj-t-id").value;
if (!taskId || !homeAttachInput.files.length) return;
const fd = new FormData();
fd.append("file", homeAttachInput.files[0]);
try {
const res = await fetch("/project/api/tasks/" + taskId + "/attachments", { method: "POST", body: fd });
if (!res.ok) { let m = res.statusText; try { m = (await res.json()).detail; } catch (_) {} throw new Error(m); }
homeAttachInput.value = "";
loadHomeAttachments(taskId);
} catch (e) { alert("업로드 실패: " + e.message); }
});
async function loadHomeComments(taskId) {
const ul = el("pj-comment-list");
ul.innerHTML = "불러오는 중…";
try {
const res = await api("GET", "/project/api/tasks/" + taskId + "/comments");
if (!res.comments.length) { ul.innerHTML = "댓글 없음"; return; }
ul.innerHTML = "";
res.comments.forEach(function (c) {
const li = document.createElement("li");
li.className = "pj-comment-item";
li.innerHTML =
'account_circle' +
'' +
'';
li.querySelector(".pj-comment-body").textContent = c.body;
li.querySelector(".pj-comment-del").addEventListener("click", async function () {
try { await api("DELETE", "/project/api/comments/" + c.id); loadHomeComments(taskId); }
catch (e) { alert("삭제 실패: " + e.message); }
});
ul.appendChild(li);
});
} catch (e) { ul.innerHTML = "불러오기 실패"; }
}
el("pj-comment-send").addEventListener("click", async function () {
const taskId = el("pj-t-id").value;
const inp = el("pj-comment-input");
const body = inp.value.trim();
if (!taskId || !body) return;
try {
await api("POST", "/project/api/tasks/" + taskId + "/comments", { body: body });
inp.value = "";
loadHomeComments(taskId);
} catch (e) { alert("등록 실패: " + e.message); }
});
el("pj-comment-input").addEventListener("keydown", function (e) {
if (e.key === "Enter") { e.preventDefault(); el("pj-comment-send").click(); }
});
async function openHomeTaskModal(task) {
setTaskModalCompact(false);
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) {
setTaskModalCompact(true);
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 '💬' + n + "";
}
const dataEl = el("pj-data");
const data = JSON.parse(dataEl.textContent);
const currentProject = data.project || {};
let stages = data.stages || [];
let tasks = data.tasks || [];
let members = data.members || [];
const priorityLabels = data.priorityLabels || {};
// 전체 프로젝트 집계 데이터 — 4개 뷰는 전 프로젝트를 보여준다.
let projectsMeta = data.projects || [];
const stagesByProject = data.stagesByProject || {};
const boardStages = data.boardStages || [];
const avatars = data.avatars || {};
const myTasksById = {};
(data.myTasks || []).forEach(function (t) { myTasksById[t.id] = t; });
// 댓글 말풍선 더블클릭 팝업 — 홈 화면과 동일 구현(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
? '' +
(t.start_date && t.due_date ? fmtDateKr(t.start_date) + " ~ " + fmtDateKr(t.due_date)
: t.due_date ? "~ " + fmtDateKr(t.due_date) : fmtDateKr(t.start_date) + " ~") +
""
: "";
return '' +
'";
}
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 '
';
return 'account_circle';
}
let timeline = null;
// 마지막으로 보던 뷰를 기억한다 — 달력 이전/다음달 이동은 페이지를 통째로
// 새로고침하므로(?y=&m=), 기억이 없으면 달력을 보다가도 매번 기본값(보드)
// 으로 튕겨나간다.
const VIEW_KEY = "pj_view";
let currentView = "board";
try {
const saved = localStorage.getItem(VIEW_KEY);
if (saved && ["board", "list", "calendar", "timeline", "dashboard"].indexOf(saved) >= 0) currentView = saved;
} catch (_) {}
// ── 프로젝트 스코프 — 아사나처럼 기본은 "이 프로젝트만", "전체 프로젝트"로
// 전환하면 예전처럼 접근 가능한 모든 프로젝트를 합쳐서 보여준다.
// tasks 배열의 각 행은 서버가 어느 프로젝트를 보다가 담았는지를
// context_project_id 로 표시한다(멀티호밍된 업무는 원 소속과 연결된
// 프로젝트 양쪽에서 각각 한 번씩 나온다 — 그래야 각 화면에 다 보인다).
// "전체" 모드는 원 소속(context_project_id === project_id)만 세어 중복을 없앤다.
let viewScope = "project";
function visibleTasks() {
return viewScope === "project"
? tasks.filter(function (t) { return t.context_project_id === projectId; })
: tasks.filter(function (t) { return t.context_project_id === t.project_id; });
}
// ── 뷰 토글 ──
const tabs = el("pj-view-tabs");
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 = '' + escapeHtml(b.t.title || "") + "" + cmtBadge(b.t);
cont.appendChild(div);
});
for (var col = 0; col < 7; col++) {
if (overflow[col] > 0) {
const m = document.createElement("div");
m.className = "pj-bar-more";
m.style.gridColumn = (col + 1) + " / span 1";
m.style.gridRow = (MAX_LANES + 1);
m.dataset.date = dayEls[col].dataset.date;
m.textContent = "+" + overflow[col] + " 더";
cont.appendChild(m);
}
}
});
wireCalendarDrag(cal);
}
// 드래그(포인터 기반). 안 움직이면 클릭=모달, +N 칩 클릭=그날 팝오버.
function wireCalendarDrag(cal) {
cal.querySelectorAll(".pj-bar[data-task-id]").forEach(function (bar) {
if (canManage) bar.addEventListener("contextmenu", function (e) {
e.preventDefault();
const t = tasks.find(function (x) { return String(x.id) === String(bar.dataset.taskId); });
if (t) showContextMenu(e.clientX, e.clientY, taskContextItems(t));
});
bar.addEventListener("mousedown", function (e) {
if (e.button !== 0) return;
e.preventDefault();
const taskId = bar.dataset.taskId;
const sx = e.clientX, sy = e.clientY;
let dragging = false, ghost = null;
function onMove(ev) {
if (!dragging) {
if (Math.abs(ev.clientX - sx) + Math.abs(ev.clientY - sy) < 5) return;
dragging = true;
cal.classList.add("pj-cal-dragging");
if (canManage) {
ghost = document.createElement("div");
ghost.className = "pj-bar-ghost";
ghost.textContent = (bar.textContent || "").trim();
document.body.appendChild(ghost);
}
}
if (ghost) { ghost.style.left = (ev.clientX + 10) + "px"; ghost.style.top = (ev.clientY + 12) + "px"; }
clearDropHL();
const cell = dayCellAt(ev.clientX, ev.clientY);
if (cell) cell.classList.add("pj-drop-over");
}
function onUp(ev) {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
cal.classList.remove("pj-cal-dragging");
if (ghost) ghost.remove();
clearDropHL();
const t = tasks.find(function (x) { return String(x.id) === String(taskId); });
if (!dragging) { if (t && canManage) openTaskModal(t); return; }
if (!canManage) return;
const cell = dayCellAt(ev.clientX, ev.clientY);
if (cell) moveTaskTo(taskId, cell.dataset.date);
}
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
});
});
}
// 낙관적 이동 — 즉시 다시 그리고 백그라운드 저장, 실패 시 롤백.
async function moveTaskTo(taskId, dropDate) {
const t = tasks.find(function (x) { return String(x.id) === String(taskId); });
if (!t) return;
const anchor = t.start_date || t.due_date;
if (!anchor) { alert("날짜가 없는 업무는 달력에서 이동할 수 없습니다."); return; }
const delta = dayDiff(anchor, dropDate);
if (delta === 0) return;
const old = { s: t.start_date, d: t.due_date };
t.start_date = t.start_date ? addDays(t.start_date, delta) : null;
t.due_date = t.due_date ? addDays(t.due_date, delta) : null;
renderCalendar();
try {
await api("PUT", "/project/api/tasks/" + taskId, { start_date: t.start_date, due_date: t.due_date });
} catch (err) {
t.start_date = old.s; t.due_date = old.d; renderCalendar();
alert("이동 실패: " + err.message);
}
}
// ── "+N 더" 그날 업무 팝오버 ──
let dayPop = null;
function closeDayPop() {
if (dayPop) { dayPop.remove(); dayPop = null; document.removeEventListener("mousedown", onDocDown, true); }
}
function onDocDown(e) {
if (dayPop && !dayPop.contains(e.target) && !e.target.closest(".pj-bar-more")) closeDayPop();
}
function openDayPopover(dateStr, anchorEl) {
closeDayPop();
const list = visibleTasks().filter(function (t) {
const sp = taskSpan(t); return sp && sp[0] <= dateStr && dateStr <= sp[1];
});
dayPop = document.createElement("div");
dayPop.className = "pj-daypop";
const head = document.createElement("div");
head.className = "pj-daypop-head"; head.textContent = dateStr;
dayPop.appendChild(head);
list.forEach(function (t) {
const row = document.createElement("div");
row.className = "pj-daypop-item" + (t.completed_at ? " is-done" : "");
row.innerHTML =
'' +
'' + escapeHtml(t.title || "") + "";
row.addEventListener("click", function () { closeDayPop(); if (canManage) openTaskModal(t); });
dayPop.appendChild(row);
});
document.body.appendChild(dayPop);
const r = anchorEl.getBoundingClientRect();
let left = window.scrollX + r.left;
const top = window.scrollY + r.bottom + 4;
left = Math.min(left, window.scrollX + document.documentElement.clientWidth - dayPop.offsetWidth - 8);
dayPop.style.left = Math.max(8, left) + "px";
dayPop.style.top = top + "px";
setTimeout(function () { document.addEventListener("mousedown", onDocDown, true); }, 0);
}
(function () {
const cal = document.querySelector(".pj-cal");
if (cal) cal.addEventListener("click", function (e) {
const more = e.target.closest(".pj-bar-more");
if (more) openDayPopover(more.dataset.date, more);
});
})();
// ── 타임라인 (vis-timeline) — 프로젝트별 행(그룹), 행마다 전체 일정 배경 + 업무 막대 ──
function renderTimeline() {
const node = el("pj-timeline");
if (timeline) { timeline.destroy(); timeline = null; }
const dated = visibleTasks().filter(function (t) { return t.start_date || t.due_date; });
if (!dated.length) { node.innerHTML = '기간이 설정된 업무가 없습니다.
'; return; }
// 프로젝트 메타(시작/마감/색) 조회용
const projById = {};
projectsMeta.forEach(function (p) { projById[p.id] = p; });
// "이 프로젝트" 스코프는 세션별로 행을 나눈다(아사나 타임라인=섹션 스윔레인).
// "전체 프로젝트"는 예전처럼 프로젝트별로 나눈다(세션이 프로젝트마다 달라
// 하나로 묶을 수 없다).
const groupBySession = viewScope === "project";
const byGroup = {};
dated.forEach(function (t) {
const gid = groupBySession ? String(t.stage_id || "none") : String(t.project_id);
(byGroup[gid] = byGroup[gid] || []).push(t);
});
let groupOrder = Object.keys(byGroup);
if (groupBySession) {
const sessionOrder = (stagesByProject[projectId] || []).slice()
.sort(function (a, b) { return a.sort_order - b.sort_order; })
.map(function (s) { return String(s.id); });
groupOrder = sessionOrder.filter(function (id) { return byGroup[id]; });
if (byGroup.none) groupOrder.push("none");
}
const items = [];
const groupRows = [];
groupOrder.forEach(function (gid) {
const list = byGroup[gid];
const grp = (groupBySession ? "S" : "P") + gid;
const meta = groupBySession ? {} : (projById[gid] || {});
const groupColor = list[0].project_color || meta.color || "#4573d2";
const groupName = groupBySession
? (gid === "none" ? "미지정" : (list[0].stage_name || "세션"))
: (list[0].project_name || meta.name || "프로젝트");
groupRows.push({ id: grp, content: escapeHtml(groupName) });
let minMs = Infinity, maxMs = -Infinity;
list.forEach(function (t) {
const s = t.start_date || t.due_date;
const e = t.due_date || t.start_date;
const startISO = s + (t.start_time ? "T" + t.start_time : "");
const endISO = e + (t.due_time ? "T" + t.due_time : "");
const isRange = (e !== s) || (!!t.start_time && !!t.due_time && t.start_time !== t.due_time);
items.push({
// vis 가 HTML 을 정화(sanitize)하므로 아이콘 폰트 대신 이모지로 댓글 표시.
id: t.id, group: grp,
content: (t.title || "") + (t.comment_count ? " 💬" + t.comment_count : ""),
start: startISO, end: isRange ? endISO : undefined,
type: isRange ? "range" : "point",
style: "background-color:" + (t.completed_at ? "#9aa5b1" : groupColor) + ";color:#fff;border:none;",
});
const sm = new Date(startISO).getTime();
const em = new Date(endISO).getTime();
if (sm < minMs) minMs = sm;
if (em > maxMs) maxMs = em;
});
// 배경(전체 일정) = 프로젝트 모드일 때만 프로젝트 자체 시작/마감일 우선,
// 세션 모드는 세션에 자체 기간이 없으므로 업무 최소~최대만 쓴다.
let bgStart = (!groupBySession && meta.start_date) ? new Date(meta.start_date + "T00:00:00").getTime() : minMs;
let bgEnd = (!groupBySession && meta.due_date) ? new Date(meta.due_date + "T00:00:00").getTime() : maxMs;
// 업무가 그 기간 밖에 있어도 가려지지 않게 범위를 넓힌다.
bgStart = Math.min(bgStart, minMs);
bgEnd = Math.max(bgEnd, maxMs);
bgEnd += 86400000; // 마지막 날 하루를 덮도록 +1일
items.push({
id: "bg" + grp, group: grp, type: "background",
start: new Date(bgStart), end: new Date(bgEnd),
style: "background-color:" + groupColor + "22;",
});
});
const groups = new vis.DataSet(groupRows);
const koWd = function (d) { return ["일", "월", "화", "수", "목", "금", "토"][d.getDay()]; };
const pad2 = function (n) { return ("0" + n).slice(-2); };
// 저장된 보던 구간이 있으면 생성 옵션에 start/end 로 넣어 자동맞춤(autofit)을 막는다.
// (setWindow 로 나중에 바꾸면 초기 autofit 이 rangechanged 로 덮어써 초기화됨)
const saved = loadTLWindow();
const opts = {
stack: true, height: "520px",
horizontalScroll: true, // 마우스 휠 = 좌우 이동(패닝)
verticalScroll: false,
zoomKey: "ctrlKey", // 확대/축소는 Ctrl+휠
margin: { item: 8 },
format: {
minorLabels: function (date, scale) {
const d = new Date(date.valueOf());
switch (scale) {
case "millisecond": case "second": case "minute": case "hour":
return pad2(d.getHours()) + ":" + pad2(d.getMinutes());
case "weekday": case "day": return d.getDate() + "일 (" + koWd(d) + ")";
case "week": return d.getDate() + "일";
case "month": return (d.getMonth() + 1) + "월";
case "year": return d.getFullYear() + "년";
default: return d.getDate() + "일";
}
},
majorLabels: function (date, scale) {
const d = new Date(date.valueOf());
switch (scale) {
case "millisecond": case "second": case "minute": case "hour":
return (d.getMonth() + 1) + "월 " + d.getDate() + "일 (" + koWd(d) + ")";
case "weekday": case "day": case "week":
return d.getFullYear() + "년 " + (d.getMonth() + 1) + "월";
case "month": return d.getFullYear() + "년";
default: return "";
}
},
},
};
if (saved) { opts.start = new Date(saved.start); opts.end = new Date(saved.end); }
// ── 드래그로 이동/기간 조절 — 아사나처럼 막대 몸통을 끌면 통째로 이동,
// 양 끝을 끌면 시작·마감일만 조절. 세션(그룹) 간 드래그는 막아둔다(그룹
// 이동은 "다른 세션으로 옮기기"라 보드 드래그의 몫 — 타임라인은 날짜만).
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 =
'' + escapeHtml(name) + "" +
'
' +
'';
const body = col.querySelector(".pj-col-body");
const colTasks = vis.filter(function (t) {
return name === "미지정" ? !t.stage_name : (t.stage_name || "") === name;
});
col.querySelector(".pj-col-count").textContent = colTasks.length;
colTasks.forEach(function (t) { body.appendChild(taskCard(t)); });
if (canManage && name !== "미지정") enableDropByName(body, name);
board.appendChild(col);
});
}
// 보드 드롭(전체보기) — 컬럼 이름(세션명)으로 옮긴다. 업무가 속한 '그
// 프로젝트'의 동일 이름 세션 id 를 찾아 적용(프로젝트마다 세션 id 가 다르다).
function enableDropByName(body, name) {
body.addEventListener("dragover", function (e) {
if (!e.dataTransfer.types.includes("text/plain")) return;
e.preventDefault(); body.classList.add("is-over");
});
body.addEventListener("dragleave", function () { body.classList.remove("is-over"); });
body.addEventListener("drop", async function (e) {
if (!e.dataTransfer.types.includes("text/plain")) return;
e.preventDefault();
body.classList.remove("is-over");
const taskId = parseInt(e.dataTransfer.getData("text/plain"), 10);
const t = tasks.find(function (x) { return x.id === taskId; });
if (!t || (t.stage_name || "") === name) return;
const plist = stagesByProject[t.project_id] || [];
const target = plist.find(function (s) { return s.name === name; });
if (!target) { alert("'" + (t.project_name || "이 프로젝트") + "' 에는 '" + name + "' 세션이 없습니다."); return; }
try {
const res = await api("PUT", "/project/api/tasks/" + taskId, { stage_id: target.id });
mergeTask(res.task);
renderBoard();
} catch (err) { alert("이동 실패: " + err.message); }
});
}
// ── 보드(프로젝트 스코프) — 세션은 이 프로젝트의 실제 행(id)이라 관리 UI를
// 붙일 수 있다: 이름변경·완료토글·삭제(만든 사람만)·추가·드래그 재정렬.
function renderBoardByStage(board, vis) {
// 완료 세션은 끝으로 보낸다 — 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 =
'' +
'' + escapeHtml(s.name) + "" +
(s.is_done_stage ? '완료' : "") +
'' +
(canManage
? '' +
'' +
'" +
'' +
""
: "") +
"
";
const body = col.querySelector(".pj-col-body");
const colTasks = vis.filter(function (t) { return t.stage_id === s.id; });
col.querySelector(".pj-col-count").textContent = colTasks.length;
colTasks.forEach(function (t) { body.appendChild(taskCard(t)); });
if (canManage) {
enableDropByStageId(body, s.id);
wireColumnControls(col, s);
wireColumnDrag(col, s);
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 =
'미지정' +
'
';
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; }
const ok = await window.erpConfirm(
"'" + s.name + "' 세션을 삭제할까요? 이 세션의 업무는 '미지정'이 됩니다.",
{ okText: "삭제", cancelText: "취소" }
);
if (!ok) 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
? '● ' + escapeHtml(t.project_name) + "
"
: "") +
'' + escapeHtml(t.title) + "
" +
'' +
(t.priority && t.priority !== "normal"
? '' + (priorityLabels[t.priority] || t.priority) + ""
: "") +
(t.assignee_email
? '' + avatarHtml(t.assignee_email, "pj-gicon-sm") +
escapeHtml(idOf(t.assignee_email)) + ""
: "") +
(t.subtask_total
? '' + (t.subtask_done || 0) + "/" + t.subtask_total + ""
: "") +
(t.due_date ? '' + t.due_date + "" : "") +
(t.comment_count
? '' + cmtBadge(t) + ""
: "") +
"
";
card.addEventListener("click", function () { if (canManage) openTaskModal(t); });
// 댓글 말풍선 더블클릭 → 채팅형 댓글 팝업(내 업무 패널과 동일 구현 공용).
if (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 =
'' + escapeHtml(col.label) +
' ' + arrow + "" +
'';
th.querySelector(".pj-th-label").addEventListener("click", function () {
if (listSort.key === col.key) listSort.dir = -listSort.dir;
else { listSort.key = col.key; listSort.dir = 1; }
renderList();
});
enableColResize(th, col.key);
trh.appendChild(th);
});
const thead = table.querySelector("thead");
thead.innerHTML = "";
thead.appendChild(trh);
}
function taskRow(t, cols) {
const tr = document.createElement("tr");
if (t.completed_at) tr.className = "is-done";
// 행 배경 = 프로젝트 색 연하게(10%) — 다른 프로젝트에서 온(멀티호밍) 업무를
// 구분하기 위함. 이 프로젝트 고유 업무는 색이 다 같아 사실상 안 보인다.
const pc = t.project_color || "#4573d2";
tr.style.background = pc + "14";
tr.innerHTML = cols.map(function (c) {
let inner = escapeHtml(String(c.text(t)));
if (c.key === "title") inner += cmtBadge(t);
if (c.key === "assignee" && t.assignee_email) {
inner = '' + avatarHtml(t.assignee_email, "pj-gicon-sm") + escapeHtml(idOf(t.assignee_email)) + "";
}
return "" + inner + " | ";
}).join("");
if (canManage) {
tr.style.cursor = "pointer";
tr.addEventListener("click", function () { openTaskModal(t); });
tr.addEventListener("contextmenu", function (e) {
e.preventDefault();
showContextMenu(e.clientX, e.clientY, taskContextItems(t));
});
}
return tr;
}
function listCollapsedSet() {
try { return new Set(JSON.parse(localStorage.getItem("pj_list_collapsed") || "[]")); }
catch (_) { return new Set(); }
}
function toggleListCollapsed(key) {
const s = listCollapsedSet();
if (s.has(key)) s.delete(key); else s.add(key);
try { localStorage.setItem("pj_list_collapsed", JSON.stringify(Array.from(s))); } catch (_) {}
}
function renderList() {
const table = el("pj-list");
table.classList.add("pj-table-fixed");
const cols = listCols();
renderListHead(table, cols);
const tbody = table.querySelector("tbody");
tbody.innerHTML = "";
if (viewScope !== "project") {
sortRows(visibleTasks(), cols).forEach(function (t) { tbody.appendChild(taskRow(t, cols)); });
return;
}
// ── 아사나 리스트 뷰처럼 세션을 그룹 구분선으로 ──
renderListGrouped(tbody, cols);
}
function renderListGrouped(tbody, cols) {
const vis = visibleTasks();
const sessions = (stagesByProject[projectId] || []).slice()
.sort(function (a, b) { return a.sort_order - b.sort_order; });
const collapsed = listCollapsedSet();
const colSpan = cols.length;
function addGroup(key, name, groupTasks) {
const head = document.createElement("tr");
head.className = "pj-list-group-row";
const isCollapsed = collapsed.has(key);
const td = document.createElement("td");
td.colSpan = colSpan;
td.className = "pj-list-group-head";
td.innerHTML =
'' +
(isCollapsed ? "chevron_right" : "expand_more") + "" +
'' + escapeHtml(name) + "" +
'' + groupTasks.length + "";
td.addEventListener("click", function () { toggleListCollapsed(key); renderList(); });
head.appendChild(td);
tbody.appendChild(head);
if (!isCollapsed) sortRows(groupTasks.slice(), cols).forEach(function (t) { tbody.appendChild(taskRow(t, cols)); });
}
sessions.forEach(function (s) {
addGroup("s" + s.id, s.name, vis.filter(function (t) { return t.stage_id === s.id; }));
});
const unstaged = vis.filter(function (t) { return !t.stage_id; });
if (unstaged.length) addGroup("none", "미지정", unstaged);
if (!sessions.length && !unstaged.length) {
const tr = document.createElement("tr");
const td = document.createElement("td");
td.colSpan = colSpan;
td.className = "pj-empty-sm";
td.textContent = "세션이 없습니다.";
tr.appendChild(td);
tbody.appendChild(tr);
}
}
// ════════════════════════════════════════════════════════════
// 대시보드 — 통계 카드 + 차트 3종. 전부 이미 로드된 tasks(visibleTasks())
// 로 클라이언트에서 즉시 집계 — 서버 재조회 없음. 스코프 토글(이
// 프로젝트/전체 프로젝트)도 다른 뷰와 똑같이 따른다.
// ════════════════════════════════════════════════════════════
function animateCount(el, target, duration) {
const start = 0;
const t0 = performance.now();
function step(now) {
const p = Math.min(1, (now - t0) / duration);
const eased = 1 - Math.pow(1 - p, 3); // ease-out cubic
el.textContent = Math.round(start + (target - start) * eased);
if (p < 1) requestAnimationFrame(step);
else el.textContent = String(target);
}
requestAnimationFrame(step);
}
function dashStatTile(label, value) {
const tile = document.createElement("div");
tile.className = "pj-dash-stat";
tile.innerHTML =
'' +
'0
';
tile.querySelector(".pj-dash-stat-label").textContent = label;
const valEl = tile.querySelector(".pj-dash-stat-value");
animateCount(valEl, value, 700);
return tile;
}
// 세로 막대 그래프 — 섹션별/담당자별 공용. data = [{label, value}].
function dashBarChart(data) {
const wrap = document.createElement("div");
wrap.className = "pj-dash-bars";
if (!data.length) {
wrap.innerHTML = "데이터가 없습니다.
";
return wrap;
}
const max = Math.max.apply(null, data.map(function (d) { return d.value; }).concat([1]));
const tooltip = dashTooltip();
data.forEach(function (d) {
const col = document.createElement("div");
col.className = "pj-dash-barcol";
col.tabIndex = 0;
col.innerHTML =
'' + d.value + "
" +
'' +
'';
col.querySelector(".pj-dash-barcol-label").textContent = d.label;
const fill = col.querySelector(".pj-dash-barcol-fill");
const pct = Math.round((d.value / max) * 100);
requestAnimationFrame(function () {
requestAnimationFrame(function () { fill.style.height = pct + "%"; });
});
function showTip() { tooltip.show(col, d.label, d.value); }
col.addEventListener("mouseenter", showTip);
col.addEventListener("focus", showTip);
col.addEventListener("mouseleave", tooltip.hide);
col.addEventListener("blur", tooltip.hide);
wrap.appendChild(col);
});
return wrap;
}
// 우클릭 메뉴와 같은 자리에 하나만 떠 있는 공용 호버 툴팁(값 강조, 카테고리는 보조).
function dashTooltip() {
let el = null;
return {
show: function (anchor, label, value) {
if (!el) {
el = document.createElement("div");
el.className = "pj-dash-tooltip";
document.body.appendChild(el);
}
el.innerHTML = '';
el.querySelector("b").textContent = value;
el.querySelector("span").textContent = label;
const r = anchor.getBoundingClientRect();
el.style.left = (window.scrollX + r.left + r.width / 2) + "px";
el.style.top = (window.scrollY + r.top - 8) + "px";
el.hidden = false;
},
hide: function () { if (el) el.hidden = true; },
};
}
// 도넛(완료 상태별) — SVG stroke-dasharray 로 두 구간을 그린다.
function dashDonutChart(segments) {
const total = segments.reduce(function (s, x) { return s + x.value; }, 0);
const R = 52, C = 2 * Math.PI * R;
const wrap = document.createElement("div");
wrap.className = "pj-dash-donut-wrap";
let svg = '';
wrap.innerHTML = svg;
wrap.querySelector(".pj-dash-donut-center").textContent = total;
const legend = document.createElement("div");
legend.className = "pj-dash-legend";
segments.forEach(function (s) {
const item = document.createElement("div");
item.className = "pj-dash-legend-item";
item.innerHTML = ' ';
item.querySelector(".pj-dash-legend-dot").style.background = s.color;
item.querySelector(".pj-dash-legend-label").textContent = s.label;
item.querySelector("b").textContent = s.value;
legend.appendChild(item);
});
wrap.appendChild(legend);
// 다음 프레임에 목표 길이로 트랜지션(애니메이션이 실제로 재생되도록 한 틱 늦춘다).
requestAnimationFrame(function () {
requestAnimationFrame(function () {
const arcs = wrap.querySelectorAll(".pj-dash-donut-seg");
segments.forEach(function (s, i) {
const frac = total ? s.value / total : 0;
const len = frac * C;
arcs[i].setAttribute("stroke-dasharray", len + " " + C);
});
});
});
return wrap;
}
function renderDashboard() {
const root = el("pj-dash");
if (!root) return;
root.innerHTML = "";
const vis = visibleTasks().filter(function (t) { return !t.parent_task_id; }); // 하위 업무는 보드에도 안 보이므로 제외
const today = new Date().toISOString().slice(0, 10);
const completed = vis.filter(function (t) { return !!t.completed_at; });
const incomplete = vis.filter(function (t) { return !t.completed_at; });
const overdue = incomplete.filter(function (t) { return t.due_date && t.due_date.slice(0, 10) < today; });
const stats = document.createElement("div");
stats.className = "pj-dash-stats";
stats.appendChild(dashStatTile("완료된 작업 합계", completed.length));
stats.appendChild(dashStatTile("미완료 작업 합계", incomplete.length));
stats.appendChild(dashStatTile("마감일이 지난 작업 합계", overdue.length));
stats.appendChild(dashStatTile("작업 합계", vis.length));
root.appendChild(stats);
const charts = document.createElement("div");
charts.className = "pj-dash-charts";
// 섹션별 미완료 — "이 프로젝트" 스코프면 보드 컬럼과 같은 순서로.
const sectionOrder = viewScope === "project"
? (stagesByProject[projectId] || []).slice().sort(function (a, b) { return a.sort_order - b.sort_order; }).map(function (s) { return s.name; })
: boardStages.slice();
const bySection = {};
incomplete.forEach(function (t) { const k = t.stage_name || "미지정"; bySection[k] = (bySection[k] || 0) + 1; });
const sectionNames = sectionOrder.filter(function (n) { return bySection[n]; });
Object.keys(bySection).forEach(function (n) { if (sectionNames.indexOf(n) < 0) sectionNames.push(n); });
const sectionData = sectionNames.map(function (n) { return { label: n, value: bySection[n] }; });
const sectionCard = document.createElement("div");
sectionCard.className = "pj-dash-card";
sectionCard.innerHTML = '미완료 작업 합계(섹션별)
';
sectionCard.appendChild(dashBarChart(sectionData));
charts.appendChild(sectionCard);
// 완료 상태별 — 2구간 도넛(완료=초록, 미완료=포인트 컬러).
const statusCard = document.createElement("div");
statusCard.className = "pj-dash-card";
statusCard.innerHTML = '작업 합계(완료 상태별)
';
statusCard.appendChild(dashDonutChart([
{ label: "완료", value: completed.length, color: "var(--color-success-green, #2e9e5b)" },
{ label: "미완료", value: incomplete.length, color: "var(--pj-accent)" },
]));
charts.appendChild(statusCard);
// 담당자별 예정된 업무 — 미완료 + 일정(시작/마감일) 있는 업무만, 상위 10명.
const byAssignee = {};
incomplete.forEach(function (t) {
if (!t.start_date && !t.due_date) return;
const k = t.assignee_email ? (t.assignee_name || idOf(t.assignee_email)) : "미지정";
byAssignee[k] = (byAssignee[k] || 0) + 1;
});
let assigneeEntries = Object.keys(byAssignee).map(function (k) { return { label: k, value: byAssignee[k] }; })
.sort(function (a, b) { return b.value - a.value; });
if (assigneeEntries.length > 10) {
const rest = assigneeEntries.slice(10).reduce(function (s, x) { return s + x.value; }, 0);
assigneeEntries = assigneeEntries.slice(0, 10).concat([{ label: "기타", value: rest }]);
}
const assigneeCard = document.createElement("div");
assigneeCard.className = "pj-dash-card pj-dash-card-wide";
assigneeCard.innerHTML = '예정된 작업 합계(담당자별)
';
assigneeCard.appendChild(dashBarChart(assigneeEntries));
charts.appendChild(assigneeCard);
root.appendChild(charts);
}
function mergeTask(task) {
const i = tasks.findIndex(function (x) { return x.id === task.id; });
if (i >= 0) tasks[i] = Object.assign({}, tasks[i], task);
else tasks.push(task);
}
// 같은 세션 안 카드 드래그 재정렬 — tasks 배열은 여러 세션이 뒤섞인
// 하나의 배열이라, 이 세션이 차지하던 자리(인덱스)만 그대로 두고 그
// 자리에 들어갈 값만 새 순서대로 바꿔치기한다(다른 세션 업무의 상대
// 위치는 건드리지 않는다).
function applyStageOrder(stageId, orderedIds) {
const orderMap = {};
orderedIds.forEach(function (id, i) { orderMap[id] = i; });
const idxs = [];
tasks.forEach(function (t, i) { if (t.stage_id === stageId) idxs.push(i); });
const sorted = idxs.map(function (i) { return tasks[i]; })
.sort(function (a, b) { return orderMap[a.id] - orderMap[b.id]; });
idxs.forEach(function (i, k) { tasks[i] = sorted[k]; });
}
function refreshActiveView() {
// 모든 뷰 + 좌측 트리 클라이언트 재렌더(새로고침 없음).
renderView(currentView);
if (typeof renderTree === "function") renderTree();
}
// 업무 우클릭 메뉴 공용 항목 — 보드 카드·리스트 행·달력 막대·타임라인
// 아이템 4곳이 전부 이 함수 하나를 쓴다(열기/완료토글/삭제, 각각 기존
// 버튼과 동일한 API 경로·권한 검사를 그대로 재사용).
function taskContextItems(t) {
return [
{ label: "열기", onClick: function () { openTaskModal(t); } },
{
label: t.completed_at ? "완료 취소" : "완료로 표시",
onClick: async function () {
try {
const res = await api("PUT", "/project/api/tasks/" + t.id, { completed: !t.completed_at });
mergeTask(res.task);
refreshActiveView();
} catch (e) { showToast("처리 실패: " + e.message, "error"); }
},
},
null,
{
label: "삭제", danger: true,
onClick: async function () {
if (!canDelete(t.created_by)) { alert(DELETE_DENIED); return; }
if (!confirm("이 업무를 삭제할까요?")) return;
try {
await api("DELETE", "/project/api/tasks/" + t.id);
tasks = tasks.filter(function (x) { return x.id !== t.id; });
refreshActiveView();
} catch (e) { showToast("삭제 실패: " + e.message, "error"); }
},
},
];
}
// ── 업무 모달 ──
const taskModal = el("pj-modal-task");
let currentTaskId = null;
const descEditor = initDescEditor();
const subtaskBlock = initSubtaskBlock();
const linksBlock = initLinksBlock();
if (descEditor) {
descEditor.setOnImageRemoved(function () {
const tid = el("pj-t-id").value;
if (tid) loadAttachments(tid);
});
}
// 닫을 때(배경 클릭·Esc·취소 버튼) 수정한 내용이 있으면 저장할지 물어본다.
// 하위업무/첨부/댓글/연결은 각자 즉시 저장돼서 여기서 추적할 필요 없다 —
// "저장" 버튼이 실제로 보내는 필드만 스냅샷으로 비교.
let taskSnapshot = null;
function taskFormSnapshot() {
return JSON.stringify({
title: el("pj-t-title").value,
assignee: el("pj-t-assignee").value,
stage: el("pj-t-stage").value,
priority: el("pj-t-priority").value,
start: el("pj-t-start").value,
due: el("pj-t-due").value,
usetime: el("pj-t-usetime").checked,
stime: el("pj-t-stime").value,
dtime: el("pj-t-dtime").value,
desc: descEditor ? descEditor.getHtml() : "",
});
}
function isTaskDirty() {
return taskSnapshot !== null && taskFormSnapshot() !== taskSnapshot;
}
let taskClosing = false;
async function attemptCloseTaskModal() {
// erpConfirm 도 Esc 로 닫히는데 stopPropagation 을 안 해서, 확인창이
// 뜬 상태에서 Esc 를 누르면 이 함수가 다시 불릴 수 있다 — 재진입 방지.
if (taskModal.hidden || taskClosing) return;
taskClosing = true;
try {
if (isTaskDirty()) {
const save = await window.erpConfirm(
"수정한 내용이 있습니다. 저장하시겠습니까?",
{ okText: "저장", cancelText: "저장 안 함" }
);
if (save) { el("pj-save-task").click(); return; }
}
taskSnapshot = null;
closeModal(taskModal);
} finally { taskClosing = false; }
}
taskModal.addEventListener("click", function (e) {
if (e.target === taskModal || e.target.hasAttribute("data-close")) attemptCloseTaskModal();
});
document.addEventListener("keydown", function (e) {
if (e.key === "Escape" && !taskModal.hidden) { e.preventDefault(); attemptCloseTaskModal(); }
});
async function loadTaskExtras(task) {
if (!task || (!subtaskBlock && !linksBlock)) return;
try {
const res = await api("GET", "/project/api/tasks/" + task.id);
const detail = res.task;
if (subtaskBlock) {
subtaskBlock.setContext({ taskId: task.id, projectId: task.project_id, canDelete: canDelete });
subtaskBlock.render(detail.subtasks || []);
}
if (linksBlock) {
linksBlock.setContext({
taskId: task.id, projectId: task.project_id,
onChange: function () { loadTaskExtras(task); },
});
linksBlock.render(detail.links || []);
}
} catch (_) { /* 조용히 무시 — 본편집은 계속 가능해야 한다 */ }
}
// 단계 select 를 해당 업무가 속한 프로젝트의 단계로 채운다(전체 뷰에서 타 프로젝트 업무 편집 대비).
function fillStageSelect(projId) {
const sel = el("pj-t-stage");
const list = (projId != null && stagesByProject[projId]) ? stagesByProject[projId] : stages;
sel.innerHTML = list.map(function (s) {
return '";
}).join("");
}
// 담당자 select — 업무가 현재 로드된 프로젝트가 아니면 그 프로젝트 멤버를 새로 불러온다
// (전체 뷰/좌측 트리에서 타 프로젝트 업무를 열 때 담당자 옵션이 비어 보이는 문제 방지).
async function fillAssigneeSelect(projId, currentEmail) {
const sel = el("pj-t-assignee");
let list = members;
if (projId != null && projId !== projectId) {
try { list = (await api("GET", "/project/api/projects/" + projId + "/members")).members || []; }
catch (_) { list = []; }
}
sel.innerHTML = '' + list.map(function (m) {
const name = idOf(m.user_email);
return '";
}).join("");
sel.value = currentEmail || "";
}
function openTaskModal(task, presetStageId) {
setTaskModalCompact(!task);
const projName = task ? (task.project_name || currentProject.name || "") : (currentProject.name || "");
el("pj-task-modal-title").textContent = "[" + projName + "]의 " + (task ? "업무 편집" : "새 업무");
el("pj-t-id").value = task ? task.id : "";
el("pj-t-title").value = task ? task.title : "";
if (descEditor) { descEditor.setTaskId(task ? task.id : null); descEditor.setHtml(task ? (task.description || "") : ""); }
fillAssigneeSelect(task ? task.project_id : projectId, task ? (task.assignee_email || "") : "");
fillStageSelect(task ? task.project_id : projectId);
el("pj-t-stage").value = task && task.stage_id ? task.stage_id
: (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 = "불러오는 중…";
try {
const res = await api("GET", "/project/api/tasks/" + taskId + "/attachments");
if (!res.attachments.length) { ul.innerHTML = "없음"; return; }
ul.innerHTML = "";
res.attachments.forEach(function (a) {
const li = document.createElement("li");
li.className = "pj-attach-item";
li.innerHTML =
'' + escapeHtml(a.filename) + "" +
'' + fmtSize(a.size_bytes) + "" +
(canManage ? '' : "");
ul.appendChild(li);
});
ul.querySelectorAll(".pj-attach-del").forEach(function (b) {
b.addEventListener("click", async function () {
if (!confirm("이 첨부를 삭제할까요?")) return;
try { await api("DELETE", "/project/api/attachments/" + b.dataset.id); loadAttachments(taskId); }
catch (e) { alert("삭제 실패: " + e.message); }
});
});
} catch (e) { ul.innerHTML = "불러오기 실패"; }
}
const attachInput = el("pj-attach-input");
if (attachInput) attachInput.addEventListener("change", async function () {
if (!currentTaskId || !attachInput.files.length) return;
const fd = new FormData();
fd.append("file", attachInput.files[0]);
try {
const res = await fetch("/project/api/tasks/" + currentTaskId + "/attachments", { method: "POST", body: fd });
if (!res.ok) { let m = res.statusText; try { m = (await res.json()).detail; } catch (_) {} throw new Error(m); }
attachInput.value = "";
loadAttachments(currentTaskId);
} catch (e) { alert("업로드 실패: " + e.message); }
});
// ── 댓글 ──
async function loadComments(taskId) {
const ul = el("pj-comment-list");
ul.innerHTML = "불러오는 중…";
try {
const res = await api("GET", "/project/api/tasks/" + taskId + "/comments");
// 메모리 업무의 댓글 수 동기화(아이콘 표시용)
const tt = tasks.find(function (x) { return String(x.id) === String(taskId); });
if (tt) tt.comment_count = res.comments.length;
if (!res.comments.length) { ul.innerHTML = "댓글 없음"; return; }
ul.innerHTML = "";
res.comments.forEach(function (c) {
const li = document.createElement("li");
li.className = "pj-comment-item";
li.innerHTML =
avatarHtml(c.author_email, "pj-gicon-sm") +
'' +
'";
li.querySelector(".pj-comment-body").textContent = c.body; // XSS 안전(textContent)
li.querySelector(".pj-comment-del").addEventListener("click", async function () {
try { await api("DELETE", "/project/api/comments/" + c.id); loadComments(taskId); }
catch (e) { alert("삭제 실패: " + e.message); }
});
li.querySelector(".pj-comment-edit").addEventListener("click", function () {
editComment(li, c, taskId);
});
ul.appendChild(li);
});
} catch (e) { ul.innerHTML = "불러오기 실패"; }
}
// 댓글 인라인 수정 — 본문을 textarea 로 교체, 저장/취소.
function editComment(li, c, taskId) {
const main = li.querySelector(".pj-comment-main");
const bodyEl = li.querySelector(".pj-comment-body");
const acts = li.querySelector(".pj-comment-acts");
if (li.querySelector(".pj-comment-edit-box")) return; // 중복 방지
acts.style.visibility = "hidden";
const box = document.createElement("div");
box.className = "pj-comment-edit-box";
const ta = document.createElement("textarea");
ta.rows = 2; ta.value = c.body;
const bar = document.createElement("div");
bar.className = "pj-comment-edit-actions";
const save = document.createElement("button");
save.type = "button"; save.className = "pj-btn pj-btn-primary"; save.textContent = "저장";
const cancel = document.createElement("button");
cancel.type = "button"; cancel.className = "pj-btn"; cancel.textContent = "취소";
bar.appendChild(cancel); bar.appendChild(save);
box.appendChild(ta); box.appendChild(bar);
bodyEl.style.display = "none";
main.appendChild(box);
ta.focus();
cancel.addEventListener("click", function () {
box.remove(); bodyEl.style.display = ""; acts.style.visibility = "";
});
save.addEventListener("click", async function () {
const text = ta.value.trim();
if (!text) { alert("내용을 입력하세요."); return; }
try {
await api("PUT", "/project/api/comments/" + c.id, { body: text });
loadComments(taskId);
} catch (e) { alert("수정 실패: " + e.message); }
});
}
async function sendComment() {
const inp = el("pj-comment-input");
const body = inp.value.trim();
if (!currentTaskId || !body) return;
try {
await api("POST", "/project/api/tasks/" + currentTaskId + "/comments", { body: body });
inp.value = "";
loadComments(currentTaskId);
} catch (e) { alert("등록 실패: " + e.message); }
}
el("pj-comment-send").addEventListener("click", sendComment);
el("pj-comment-input").addEventListener("keydown", function (e) {
if (e.key === "Enter") { e.preventDefault(); sendComment(); }
});
const newTaskBtn = el("pj-new-task");
if (newTaskBtn) newTaskBtn.addEventListener("click", function () { openTaskModal(null); });
// '시간 지정' 체크 → 시간 입력란 활성/비활성
el("pj-t-usetime").addEventListener("change", function () {
el("pj-t-stime").disabled = !this.checked;
el("pj-t-dtime").disabled = !this.checked;
});
// 시간 입력란 클릭 시 즉시 시간 피커 드롭다운 열기
["pj-t-stime", "pj-t-dtime"].forEach(function (id) {
const inp = el(id);
if (inp) inp.addEventListener("click", function () {
try { this.showPicker(); } catch (_) {}
});
});
el("pj-save-task").addEventListener("click", async function () {
if (descEditor && descEditor.isUploading()) { alert("이미지 업로드가 끝나면 저장해주세요."); return; }
const id = el("pj-t-id").value;
const assigneeSel = el("pj-t-assignee");
const assigneeName = assigneeSel.selectedOptions[0] ? (assigneeSel.selectedOptions[0].dataset.name || "") : "";
const useTime = el("pj-t-usetime").checked;
const payload = {
title: el("pj-t-title").value.trim(),
description: descEditor ? descEditor.getHtml() : "",
assignee_email: assigneeSel.value,
assignee_name: assigneeName,
stage_id: parseInt(el("pj-t-stage").value, 10) || null,
priority: el("pj-t-priority").value,
start_date: el("pj-t-start").value || null,
due_date: el("pj-t-due").value || null,
// 시간 미지정(체크 해제) = 종일 → null 전송
start_time: useTime ? (el("pj-t-stime").value || null) : null,
due_time: useTime ? (el("pj-t-dtime").value || null) : null,
};
if (!payload.title) { alert("제목을 입력하세요."); return; }
try {
let res;
if (id) res = await api("PUT", "/project/api/tasks/" + id, payload);
else res = await api("POST", "/project/api/projects/" + projectId + "/tasks", payload);
mergeTask(res.task);
taskSnapshot = null;
closeModal(taskModal);
refreshActiveView();
} catch (e) { alert("저장 실패: " + e.message); }
});
el("pj-delete-task").addEventListener("click", async function () {
const id = el("pj-t-id").value;
if (!id) return;
const t = tasks.find(function (x) { return String(x.id) === String(id); });
if (t && !canDelete(t.created_by)) { alert(DELETE_DENIED); return; }
if (!confirm("이 업무를 삭제할까요?")) return;
try {
await api("DELETE", "/project/api/tasks/" + id);
tasks = tasks.filter(function (x) { return String(x.id) !== String(id); });
closeModal(taskModal);
refreshActiveView();
} catch (e) { alert("삭제 실패: " + e.message); }
});
// ── 프로젝트 트리(사이드바) — 아사나처럼 프로젝트 이름만 나열한다(업무 목록은
// 안 보여준다 — 업무는 가운데 화면에서만 본다). 색상 원(첫 글자) 아이콘.
function treeProjLi(p) {
let acts = "";
if (isAdmin) {
acts = '' +
'' +
'' +
"";
}
const initial = escapeHtml((p.name || "?").trim().charAt(0).toUpperCase() || "?");
// 드래그로 순서 변경 가능함을 알려주는 손잡이(⣿) — 관리자에게만 보인다.
const handle = isAdmin ? '⣿' : "";
return '";
}
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) {
// 는 기본 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 = "멤버 배정은 관리자가 나중에 할 수 있습니다.";
} else {
newProjMembersBox.innerHTML = "불러오는 중…";
try {
const res = await api("GET", "/project/api/assignable-users");
const users = res.users || [];
newProjMembersBox.innerHTML = users.length
? users.map(memberPickHtml).join("")
: "배정 가능한 직원이 없습니다.";
} catch (e) {
newProjMembersBox.innerHTML = "불러오기 실패";
}
}
}
});
el("pj-save-project").addEventListener("click", async function () {
const name = el("pj-f-name").value.trim();
if (!name) { alert("이름을 입력하세요."); return; }
const memberEmails = newProjMembersBox
? Array.from(newProjMembersBox.querySelectorAll("input[type=checkbox]:checked")).map(function (c) { return c.value; })
: [];
try {
const res = await api("POST", "/project/api/projects", {
name: name,
description: el("pj-f-desc").value.trim(),
start_date: el("pj-f-start").value || null,
due_date: el("pj-f-due").value || null,
color: getNewProjColor(),
});
const pid = res.project.id;
for (const email of memberEmails) {
try { await api("POST", "/project/api/projects/" + pid + "/members", { user_email: email }); }
catch (_) {}
}
stagesByProject[pid] = [];
mergeProject(res.project);
closeModal(newProjModal);
el("pj-f-name").value = ""; el("pj-f-desc").value = "";
el("pj-f-start").value = ""; el("pj-f-due").value = "";
} catch (e) { showToast("생성 실패: " + e.message, "error"); }
});
}
// ── 좌측 프로젝트 목록 ✎ 버튼 — 프로젝트 수정 모달(홈 화면과 동일).
// 트리는 renderTree() 로 매번 다시 그려지므로(라이브 새로고침 포함) 열기
// 로직을 재사용 가능한 함수로 두고, wireTreeHandlers 에서 매번 다시 건다.
const editModal = el("pj-modal-edit");
let getEditColor = null;
function openProjectEditModal(data) {
el("pj-e-id").value = data.id;
el("pj-e-name").value = data.name || "";
el("pj-e-desc").value = data.desc || "";
el("pj-e-start").value = data.start || "";
el("pj-e-due").value = data.due || "";
el("pj-e-done").checked = data.status === "completed";
getEditColor = renderColorPalette(el("pj-e-colors"), null, data.color);
openModal(editModal);
}
if (editModal) {
wireModalClose(editModal);
el("pj-save-edit").addEventListener("click", async function () {
const id = el("pj-e-id").value;
const name = el("pj-e-name").value.trim();
if (!name) { alert("이름을 입력하세요."); return; }
try {
const res = await api("PUT", "/project/api/projects/" + id, {
name: name,
description: el("pj-e-desc").value.trim(),
start_date: el("pj-e-start").value || null,
due_date: el("pj-e-due").value || null,
color: getEditColor ? getEditColor() : undefined,
status: el("pj-e-done").checked ? "completed" : "active",
});
mergeProject(res.project);
closeModal(editModal);
} catch (e) { showToast("저장 실패: " + e.message, "error"); }
});
el("pj-delete-project").addEventListener("click", function () {
const id = el("pj-e-id").value;
const p = projectsMeta.find(function (x) { return String(x.id) === String(id); });
closeModal(editModal);
deleteTreeProject(id, p && p.name, p && p.created_by);
});
}
// ── 멤버 배정/제외 (관리자) ──
const memberModal = el("pj-modal-member");
wireModalClose(memberModal);
function memberRowHtml(m) {
const url = avatars[String(m.user_email).toLowerCase()];
return "" +
(url
? '
'
: 'account_circle') +
'' + escapeHtml(idOf(m.user_email)) + "" +
(m.role === "manager" ? '관리' : "") +
(isAdmin ? '' : "") +
"";
}
async function removeMember(email) {
if (!confirm("이 멤버를 프로젝트에서 제외할까요?")) return;
try {
await api("DELETE", "/project/api/projects/" + projectId + "/members/" + encodeURIComponent(email));
members = members.filter(function (m) { return m.user_email !== email; });
renderMemberList();
} catch (e) { showToast("제외 실패: " + e.message, "error"); }
}
function renderMemberList() {
const ul = el("pj-member-list");
if (!ul) return;
ul.innerHTML = members.length ? members.map(memberRowHtml).join("") : "없음";
ul.querySelectorAll(".pj-del-member").forEach(function (b) {
b.addEventListener("click", function () { removeMember(b.dataset.email); });
});
// 아바타 클릭 = 이 프로젝트 안에서 그 사람이 담당하는 업무 팝업.
ul.querySelectorAll("li[data-email]").forEach(function (li) {
const avatarEl = li.querySelector(".pj-member-avatar-lg");
if (!avatarEl) return;
avatarEl.style.cursor = "pointer";
avatarEl.addEventListener("click", function () {
openMemberTasksModal(li.dataset.email, idOf(li.dataset.email), {
projectId: projectId, projectName: currentProject.name,
});
});
});
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
? '
'
: 'account_circle';
return '";
}
const memberTasksModal = el("pj-modal-member-tasks");
if (memberTasksModal) wireModalClose(memberTasksModal);
let mtItems = [];
let mtSort = { key: null, dir: 1 };
// 더블클릭으로 업무 편집 팝업을 열 때 이 팝업을 완전히 닫지 않고 잠깐
// 숨겨두는 용도의 컨텍스트 — 업무 편집을 마치고 닫으면(taskModal 의
// hidden 이 다시 true 로 바뀌면) 같은 조건으로 다시 열어 목록을 최신
// 상태로 되살린다("기존 멤버 업무리스트는 그대로 유지" 요구사항).
let mtCurrentCtx = null;
let mtReopenCtx = null;
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 '' +
'' + escapeHtml(t.project_name || "-") + "" +
'' + escapeHtml(t.stage_name || "미지정") + "" +
'' + escapeHtml(t.title || "") + "" +
'' + (t.due_date ? fmtDateKr(t.due_date) : "-") + "" +
"";
}
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 = "배정된 업무가 없습니다."; 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;
mtReopenCtx = mtCurrentCtx;
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();
});
// 제목 앞에 붙는 아이콘 — 아바타 있으면 사진, 없으면 기본 사람 아이콘.
function mtTitleAvatarHtml(email) {
const url = avatars[String(email || "").toLowerCase()];
return url
? '
'
: 'account_circle';
}
// opts.projectId 를 주면 "이 프로젝트 안에서" 담당하는 업무만, 제목도
// "[프로젝트 이름]의 [사용자]님의 업무" 형식으로. 안 주면(전체 멤버)
// 전체 프로젝트 대상 + "[사용자]님의 업무".
async function openMemberTasksModal(email, name, opts) {
if (!memberTasksModal) return;
opts = opts || {};
mtCurrentCtx = { email: email, name: name, opts: opts };
const prefix = opts.projectName ? escapeHtml(opts.projectName) + "의 " : "";
el("pj-mt-title").innerHTML = prefix + mtTitleAvatarHtml(email) + escapeHtml(name || email) + "님의 업무";
mtItems = [];
mtSort = { key: null, dir: 1 };
updateMtSortIcons();
const list = el("pj-mt-list");
list.innerHTML = "불러오는 중…";
openModal(memberTasksModal);
try {
const url = "/project/api/users/tasks?email=" + encodeURIComponent(email) +
(opts.projectId ? "&project_id=" + opts.projectId : "");
const res = await api("GET", url);
mtItems = res.tasks || [];
renderMtList();
} catch (e) {
list.innerHTML = "불러오기 실패: " + escapeHtml(e.message) + "";
}
}
// 업무 편집 팝업이 닫히면(hidden 이 다시 true) 대기 중인 컨텍스트가
// 있을 때만 멤버 업무 팝업을 그 자리에 다시 연다.
if (taskModal) {
new MutationObserver(function () {
if (taskModal.hidden && mtReopenCtx) {
const ctx = mtReopenCtx;
mtReopenCtx = null;
openMemberTasksModal(ctx.email, ctx.name, ctx.opts);
}
}).observe(taskModal, { attributes: true, attributeFilter: ["hidden"] });
}
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("")
: "없음";
allMembersGrid.querySelectorAll(".pj-allmember-item").forEach(function (btn) {
btn.addEventListener("click", function () { openMemberTasksModal(btn.dataset.email, btn.dataset.name); });
});
}).catch(function (e) {
allMembersGrid.innerHTML = "불러오기 실패: " + escapeHtml(e.message) + "";
});
}
const addMemberBtn = el("pj-add-member");
if (addMemberBtn) addMemberBtn.addEventListener("click", async function () {
const box = el("pj-m-users");
openModal(memberModal);
box.innerHTML = "불러오는 중…";
try {
const res = await api("GET", "/project/api/assignable-users");
const existing = new Set(members.map(function (m) { return m.user_email; }));
const candidates = res.users.filter(function (u) { return !existing.has(u.email); });
box.innerHTML = candidates.length
? candidates.map(memberPickHtml).join("")
: "배정 가능한 직원이 없습니다(권한 미부여 또는 전원 배정됨)";
} catch (e) {
box.innerHTML = "불러오기 실패: " + escapeHtml(e.message) + "";
}
});
const saveMemberBtn = el("pj-save-member");
if (saveMemberBtn) saveMemberBtn.addEventListener("click", async function () {
const box = el("pj-m-users");
const checked = box ? Array.from(box.querySelectorAll("input[type=checkbox]:checked")) : [];
if (!checked.length) { alert("직원을 선택하세요."); return; }
const role = el("pj-m-role").value;
let failCount = 0;
for (const cb of checked) {
try {
const res = await api("POST", "/project/api/projects/" + projectId + "/members", {
user_email: cb.value, user_name: cb.dataset.name || "", role: role,
});
members.push(res.member);
} catch (e) { failCount++; }
}
renderMemberList();
closeModal(memberModal);
if (failCount) showToast(failCount + "명 배정 실패", "error");
});
// 최초 렌더 — 아사나처럼 프로젝트에 들어오면 보드(세션)가 기본 화면이다
// (달력을 보던 중이었다면 그 기억된 뷰를 그린다, 위 VIEW_KEY 복원 참고).
renderView(currentView);
// 사이드 트리에서 업무 클릭(?task=ID)으로 들어오면 해당 업무 모달 자동 열기.
// 단, 처리 후 URL 에서 task 파라미터를 제거한다 — 같은 페이지에 머무는 동안
// 다시 열리지 않도록.
const params = new URLSearchParams(location.search);
const taskParam = params.get("task");
if (taskParam) {
params.delete("task");
const qs = params.toString();
history.replaceState({}, "", location.pathname + (qs ? "?" + qs : ""));
const t = tasks.find(function (x) { return String(x.id) === String(taskParam); });
if (t) openTaskModal(t);
}
// 8초 주기 실시간 동기화(initLiveRefresh)가 전체 새로고침 대신 부를 함수 —
// 이 프로젝트의 업무·세션·멤버만 재조회해 로컬 상태에 patch(입력 중인
// 모달 폼은 건드리지 않는다). "전체 프로젝트" 스코프에 걸린 다른
// 프로젝트의 변경이나 세션 이름변경/재정렬/삭제·멤버 제외·댓글 수정/삭제는
// live-version 자체가 감지 못하는 기존 사각지대 — 다음 새로고침까지 반영 안 됨.
softSyncProjectRef = async function () {
try {
const [tRes, sRes, mRes, 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 =
'' +
'';
wrap.appendChild(imgTools);
const imgDelBtn = imgTools.querySelector(".pj-img-del");
const imgHandle = imgTools.querySelector(".pj-img-handle");
let selectedImg = null;
let onImageRemoved = null;
// 설명란 이미지는 첨부파일 업로드로 들어간 실제 첨부 레코드다 — 에디터에서
// 지우면 첨부파일 목록에도 안 남게 서버 첨부도 같이 삭제한다.
async function deleteSelectedImage() {
if (!selectedImg) return;
const img = selectedImg;
const m = (img.getAttribute("src") || "").match(/\/attachments\/(\d+)\/inline/);
img.remove();
selectImg(null);
if (!m) return;
try {
await api("DELETE", "/project/api/attachments/" + m[1]);
} catch (_) { /* 이미 지워졌을 수 있음 — 에디터에서는 이미 빠졌으니 조용히 무시 */ }
if (onImageRemoved) onImageRemoved();
}
function positionImgTools() {
if (!selectedImg || !selectedImg.isConnected) { selectedImg = null; imgTools.hidden = true; return; }
imgTools.style.left = selectedImg.offsetLeft + "px";
imgTools.style.top = selectedImg.offsetTop + "px";
imgTools.style.width = selectedImg.offsetWidth + "px";
imgTools.style.height = selectedImg.offsetHeight + "px";
imgTools.hidden = false;
}
function selectImg(img) {
if (selectedImg) selectedImg.classList.remove("pj-img-selected");
selectedImg = img || null;
if (selectedImg) { selectedImg.classList.add("pj-img-selected"); positionImgTools(); }
else { imgTools.hidden = true; }
}
box.addEventListener("click", function (e) {
if (e.target.tagName === "IMG") selectImg(e.target);
else selectImg(null);
});
document.addEventListener("click", function (e) {
if (selectedImg && !wrap.contains(e.target)) selectImg(null);
});
imgDelBtn.addEventListener("click", function () { deleteSelectedImage(); });
let dragging = false, dragStartX = 0, dragStartW = 0;
imgHandle.addEventListener("mousedown", function (e) {
if (!selectedImg) return;
e.preventDefault();
dragging = true;
dragStartX = e.clientX;
dragStartW = selectedImg.offsetWidth;
});
document.addEventListener("mousemove", function (e) {
if (!dragging || !selectedImg) return;
let newW = Math.round(dragStartW + (e.clientX - dragStartX));
newW = Math.max(40, Math.min(newW, wrap.clientWidth || 9999));
selectedImg.setAttribute("width", String(newW));
selectedImg.removeAttribute("height");
selectedImg.style.width = "";
selectedImg.style.height = "";
positionImgTools();
});
document.addEventListener("mouseup", function () { dragging = false; });
document.addEventListener("keydown", function (e) {
if (!selectedImg) return;
if (e.key !== "Delete" && e.key !== "Backspace") return;
const active = document.activeElement;
if (active && ["INPUT", "TEXTAREA", "SELECT"].indexOf(active.tagName) >= 0) return;
e.preventDefault();
deleteSelectedImage();
});
// 업로드 버튼 클릭 → 파일 선택창이 뜨는 동안 contenteditable의 선택 범위가
// 풀린다 — 마지막으로 박스 안에 있던 커서 위치를 기억해뒀다가 삽입 직전에
// 되살린다(안 그러면 이미지가 항상 맨 끝에 붙어버린다).
let lastRange = null;
function saveSelection() {
const sel = window.getSelection();
if (sel && sel.rangeCount && box.contains(sel.anchorNode)) {
lastRange = sel.getRangeAt(0).cloneRange();
}
}
box.addEventListener("keyup", saveSelection);
box.addEventListener("mouseup", saveSelection);
box.addEventListener("blur", saveSelection);
function setTaskId(id) {
taskId = id;
const enabled = !!id;
if (imgInput) {
imgInput.disabled = !enabled;
if (imgInput.parentElement) imgInput.parentElement.style.opacity = enabled ? "1" : ".5";
}
if (hint) hint.textContent = enabled ? "" : "저장 후 이미지를 추가할 수 있습니다.";
}
function insertHtmlAtCursor(html) {
box.focus();
const sel = window.getSelection();
if (lastRange && (!sel.rangeCount || !box.contains(sel.anchorNode))) {
try { sel.removeAllRanges(); sel.addRange(lastRange); } catch (_) { lastRange = null; }
}
let inserted = false;
try { inserted = document.execCommand("insertHTML", false, html); } catch (_) { inserted = false; }
if (!inserted) {
if (sel && sel.rangeCount && box.contains(sel.anchorNode)) {
const range = sel.getRangeAt(0);
range.deleteContents();
const frag = range.createContextualFragment(html);
range.insertNode(frag);
range.collapse(false);
} else {
box.innerHTML += html;
}
}
saveSelection();
}
// 삽입 전 원본 크기를 확인해, 설명란보다 넓으면 90%로 줄여서 넣는다
// (그대로 넣으면 크기조절 핸들이 설명란 밖으로 삐져나간다).
function naturalImageSize(file) {
return new Promise(function (resolve) {
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = function () { URL.revokeObjectURL(url); resolve({ width: img.naturalWidth }); };
img.onerror = function () { URL.revokeObjectURL(url); resolve(null); };
img.src = url;
});
}
// 업로드 중엔 저장을 막는다(안 그러면 업로드가 끝나기 전에 저장을 눌러
// 방금 넣은 이미지가 설명에서 통째로 빠진 채 저장되는 경우가 있었다).
let pendingUploads = 0;
function beginUpload() {
pendingUploads++;
if (hint) hint.textContent = "이미지 업로드 중…";
}
function endUpload() {
pendingUploads = Math.max(0, pendingUploads - 1);
if (pendingUploads === 0 && hint) hint.textContent = taskId ? "" : "저장 후 이미지를 추가할 수 있습니다.";
}
async function insertImageFile(file) {
if (!taskId) { alert("먼저 저장한 뒤 이미지를 추가할 수 있습니다."); return; }
// 업로드는 비동기라 그 사이 포커스가 파일선택창 등으로 빠져나가면서
// contenteditable 의 선택범위가 사라지거나(브라우저가 엉뚱한 위치로
// 되살리기도 한다) 커서 위치가 틀어진다 — 업로드 시작 "직전"의 커서를
// 무조건 기억해뒀다가 삽입 시 그대로 복원한다(현재 라이브 셀렉션은
// 신뢰하지 않는다).
saveSelection();
const insertAt = lastRange ? lastRange.cloneRange() : null;
beginUpload();
const fd = new FormData();
fd.append("file", file);
try {
const results = await Promise.all([
naturalImageSize(file),
fetch("/project/api/tasks/" + taskId + "/attachments", { method: "POST", body: fd }),
]);
const dims = results[0], res = results[1];
if (!res.ok) { let m = res.statusText; try { m = (await res.json()).detail; } catch (_) {} throw new Error(m); }
const data = await res.json();
const boxW = box.clientWidth || 0;
const widthAttr = (dims && dims.width && boxW && dims.width > boxW)
? ' width="' + Math.round(boxW * 0.9) + '"' : "";
box.focus();
if (insertAt) {
try { const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(insertAt); } catch (_) {}
}
insertHtmlAtCursor('
');
} catch (e) { alert("이미지 업로드 실패: " + e.message); }
finally { endUpload(); }
}
box.addEventListener("paste", function (e) {
const items = (e.clipboardData && e.clipboardData.items) || [];
for (let i = 0; i < items.length; i++) {
if (items[i].type && items[i].type.indexOf("image/") === 0) {
e.preventDefault();
const file = items[i].getAsFile();
if (file) insertImageFile(file);
return;
}
}
// 이미지가 아니면 서식 없는 텍스트로만 붙여넣기(외부 사이트 서식 유입 방지).
e.preventDefault();
const text = (e.clipboardData || window.clipboardData).getData("text/plain");
insertHtmlAtCursor(escapeHtml(text).replace(/\n/g, "
"));
});
if (imgInput) imgInput.addEventListener("change", function () {
if (imgInput.files.length) insertImageFile(imgInput.files[0]);
imgInput.value = "";
});
return {
setTaskId: setTaskId,
setHtml: function (html) { selectImg(null); lastRange = null; box.innerHTML = html || ""; },
getHtml: function () { return box.innerHTML; },
isUploading: function () { return pendingUploads > 0; },
setOnImageRemoved: function (fn) { onImageRemoved = fn; },
};
}
// ── 하위 업무(인라인) — 체크박스·제목·담당자·마감일을 한 줄에서 바로 저장 ──
function initSubtaskBlock() {
const listEl = el("pj-subtask-list");
if (!listEl) return null;
const input = el("pj-subtask-input");
const addAssignee = el("pj-subtask-add-assignee");
const addDue = el("pj-subtask-add-due");
const addBtn = el("pj-subtask-add");
let taskId = null, projectId = null, canDeleteFn = function () { return false; }, onChange = null;
async function membersOptionsHtml(pid) {
try {
const res = await api("GET", "/project/api/projects/" + pid + "/members");
return (res.members || []).map(function (m) {
return '";
}).join("");
} catch (_) { return ""; }
}
function rowEl(sub, optionsHtml) {
const li = document.createElement("li");
li.className = "pj-subtask-row" + (sub.completed_at ? " is-done" : "");
li.dataset.id = sub.id;
li.innerHTML =
'" +
'' +
'" +
'' +
'';
const titleInp = li.querySelector(".pj-subtask-title");
const doneChk = li.querySelector(".pj-subtask-done");
const assigneeSel = li.querySelector(".pj-subtask-assignee");
const dueInp = li.querySelector(".pj-subtask-due");
const delBtn = li.querySelector(".pj-subtask-del");
titleInp.value = sub.title || "";
assigneeSel.value = sub.assignee_email || "";
dueInp.value = sub.due_date || "";
if (canDeleteFn(sub.created_by)) delBtn.hidden = false;
async function patch(fields) {
try { await api("PUT", "/project/api/tasks/" + sub.id, fields); if (onChange) onChange(); }
catch (e) { alert("저장 실패: " + e.message); }
}
titleInp.addEventListener("change", function () {
const v = titleInp.value.trim();
if (!v) { titleInp.value = sub.title || ""; return; }
sub.title = v; patch({ title: v });
});
doneChk.addEventListener("change", function () {
li.classList.toggle("is-done", doneChk.checked);
patch({ completed: doneChk.checked });
});
assigneeSel.addEventListener("change", function () {
const opt = assigneeSel.selectedOptions[0];
patch({ assignee_email: assigneeSel.value, assignee_name: opt ? opt.textContent : "" });
});
dueInp.addEventListener("change", function () { patch({ due_date: dueInp.value || null }); });
delBtn.addEventListener("click", async function () {
if (!confirm("이 하위 업무를 삭제할까요?")) return;
try {
await api("DELETE", "/project/api/tasks/" + sub.id);
li.remove();
if (onChange) onChange();
} catch (e) { alert("삭제 실패: " + e.message); }
});
return li;
}
async function render(subtasks) {
listEl.innerHTML = subtasks.length ? "" : "없음";
if (!subtasks.length) return;
const optionsHtml = projectId ? await membersOptionsHtml(projectId) : "";
listEl.innerHTML = "";
subtasks.forEach(function (sub) { listEl.appendChild(rowEl(sub, optionsHtml)); });
}
if (addBtn) addBtn.addEventListener("click", async function () {
const title = (input.value || "").trim();
if (!title) return;
if (!taskId) { alert("먼저 업무를 저장한 뒤 하위 업무를 추가할 수 있습니다."); return; }
const assigneeOpt = addAssignee ? addAssignee.selectedOptions[0] : null;
try {
const res = await api("POST", "/project/api/projects/" + projectId + "/tasks", {
title: title, parent_task_id: taskId,
assignee_email: addAssignee ? addAssignee.value : "",
assignee_name: assigneeOpt ? assigneeOpt.textContent : "",
due_date: addDue ? (addDue.value || null) : null,
});
input.value = "";
if (addAssignee) addAssignee.value = "";
if (addDue) addDue.value = "";
const optionsHtml = await membersOptionsHtml(projectId);
const empty = listEl.querySelector(".pj-empty-sm");
if (empty) empty.remove();
listEl.appendChild(rowEl(res.task, optionsHtml));
if (onChange) onChange();
} catch (e) { alert("추가 실패: " + e.message); }
});
if (input) input.addEventListener("keydown", function (e) {
if (e.key === "Enter") { e.preventDefault(); if (addBtn) addBtn.click(); }
});
return {
setContext: function (opts) {
taskId = opts.taskId; projectId = opts.projectId;
canDeleteFn = opts.canDelete || function () { return false; };
onChange = opts.onChange || null;
if (addAssignee && projectId) {
membersOptionsHtml(projectId).then(function (html) {
addAssignee.innerHTML = "" + html;
});
}
},
render: render,
};
}
// ── 연결된 프로젝트(멀티호밍, 아사나식) — 업무 하나를 다른 프로젝트의 세션에도 연결 ──
function initLinksBlock() {
const chipsEl = el("pj-link-chips");
if (!chipsEl) return null;
const projSel = el("pj-link-project");
const stageSel = el("pj-link-stage");
const saveBtn = el("pj-link-save");
let taskId = null, homeProjectId = null, onChange = null;
function chipEl(link) {
const span = document.createElement("span");
span.className = "pj-link-chip";
span.innerHTML =
'● ' +
escapeHtml(link.project_name || "") +
(link.stage_name ? " · " + escapeHtml(link.stage_name) : "") +
' ';
span.querySelector(".pj-link-del").addEventListener("click", async function () {
if (!confirm("'" + (link.project_name || "") + "' 연결을 해제할까요?")) return;
try {
await api("DELETE", "/project/api/tasks/" + taskId + "/links/" + link.project_id);
span.remove();
if (onChange) onChange();
} catch (e) { alert("해제 실패: " + e.message); }
});
return span;
}
function render(links) {
chipsEl.innerHTML = links.length ? "" : "없음";
links.forEach(function (l) { chipsEl.appendChild(chipEl(l)); });
}
async function loadProjects() {
projSel.innerHTML = "";
stageSel.innerHTML = "";
try {
const res = await api("GET", "/project/api/projects");
const list = (res.projects || []).filter(function (p) {
return p.id !== homeProjectId && !p.parent_id;
});
projSel.innerHTML = "" + list.map(function (p) {
return '";
}).join("");
} catch (e) { projSel.innerHTML = ""; }
}
if (projSel) projSel.addEventListener("change", async function () {
const pid = parseInt(projSel.value, 10);
if (!pid) { stageSel.innerHTML = ""; return; }
stageSel.innerHTML = "";
try {
const res = await api("GET", "/project/api/projects/" + pid + "/stages");
stageSel.innerHTML = (res.stages || []).map(function (s) {
return '";
}).join("");
} catch (e) { stageSel.innerHTML = ""; }
});
if (saveBtn) saveBtn.addEventListener("click", async function () {
const pid = parseInt(projSel.value, 10);
if (!pid) { alert("프로젝트를 선택하세요."); return; }
if (!taskId) { alert("먼저 업무를 저장한 뒤 연결할 수 있습니다."); return; }
try {
await api("POST", "/project/api/tasks/" + taskId + "/links",
{ project_id: pid, stage_id: stageSel.value ? parseInt(stageSel.value, 10) : null });
projSel.value = "";
stageSel.innerHTML = "";
if (onChange) onChange();
} catch (e) { alert("추가 실패: " + e.message); }
});
return {
setContext: function (opts) {
taskId = opts.taskId; homeProjectId = opts.projectId; onChange = opts.onChange || null;
loadProjects();
},
render: render,
};
}
function idOf(email) {
return String(email == null ? "" : email).split("@")[0];
}
function escapeHtml(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&").replace(//g, ">")
.replace(/"/g, """).replace(/'/g, "'");
}
// ── 알림 벨 (미읽음 배지) ──
async function initBell() {
const badge = document.getElementById("pj-bell-badge");
if (!badge) return;
const bell = badge.closest(".pj-bell");
try {
const res = await fetch("/project/api/notifications/unread-count");
if (!res.ok) return;
const d = await res.json();
if (d.unread > 0) {
badge.textContent = d.unread > 99 ? "99+" : d.unread;
badge.hidden = false;
if (bell) bell.classList.add("has-unread"); // 종 흔들림 애니메이션
}
} catch (_) {}
}
// ── 알림센터 페이지 ──
function initInbox() {
const inbox = document.getElementById("pj-inbox");
const readAll = document.getElementById("pj-readall");
if (!inbox && !readAll) return;
if (readAll) readAll.addEventListener("click", async function () {
try {
await fetch("/project/api/notifications/read-all", { method: "POST" });
if (inbox) inbox.querySelectorAll(".pj-inbox-item.is-unread").forEach(function (li) {
li.classList.remove("is-unread");
});
}
catch (e) { showToast("실패: " + e.message, "error"); }
});
async function markRead(li) {
if (!li.classList.contains("is-unread")) return;
try {
await fetch("/project/api/notifications/" + li.dataset.id + "/read", { method: "POST" });
li.classList.remove("is-unread");
} catch (_) {}
}
if (inbox) inbox.querySelectorAll(".pj-inbox-item").forEach(function (li) {
li.addEventListener("click", function (e) {
if (e.target.closest("a")) return; // '이동' 링크는 통과
markRead(li);
});
li.addEventListener("contextmenu", function (e) {
if (!li.classList.contains("is-unread")) return; // 이미 읽었으면 메뉴 없음
e.preventDefault();
showContextMenu(e.clientX, e.clientY, [
{ label: "읽음으로 표시", onClick: function () { markRead(li); } },
]);
});
});
}
// ── 실시간 새로고침 ──
// 다른 사용자가 프로젝트/업무/댓글/멤버/단계를 추가·수정하면, 같은 화면을 열어둔
// 모든 사용자에게 8초 주기로 반영한다. 전체 새로고침 대신 배경 목록만 조용히
// 다시 받아 patch 한다(입력 중인 모달 폼은 건드리지 않음) — 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 = '';
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();
});
})();