Files
dbx-main/app/static/project.js
T
king 33b6bd2201 fix(project): 설명란 이미지 삭제 시 첨부파일 목록에서도 같이 삭제
설명에 붙여넣기/업로드로 넣은 이미지는 실제로는 task_attachments 레코드라,
에디터에서 이미지만 지우면(× 버튼 또는 Delete/Backspace) 서버 첨부는
그대로 남아 첨부파일 목록에 계속 보였다. src 의 attachment id 를 뽑아
DELETE 호출까지 같이 하고, 첨부파일 목록도 새로고침한다.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 01:12:08 +09:00

2730 lines
130 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 프로젝트 관리(아사나식) 모듈 — 프론트엔드.
* 두 페이지에서 공용:
* - index.html (홈): 새 프로젝트 모달
* - project.html: 달력/타임라인/보드/리스트 뷰 + 업무 CRUD + 서브/멤버 관리
* 의존: FullCalendar(전역), vis(전역). 둘 다 project.html 에서만 로드.
*/
(function () {
"use strict";
// ── 공용 fetch ─────────────────────────────────────────────
async function api(method, url, body) {
const opts = { method, headers: { "Content-Type": "application/json" } };
if (body !== undefined) opts.body = JSON.stringify(body);
const res = await fetch(url, opts);
if (!res.ok) {
let msg = res.statusText;
try { msg = (await res.json()).detail || msg; } catch (_) {}
throw new Error(msg);
}
if (res.status === 204) return null;
return res.json();
}
function el(id) { return document.getElementById(id); }
function openModal(m) { if (m) m.hidden = false; }
// 실시간 새로고침 대기 중이던 변경사항 — 모든 모달이 닫힌 시점에 반영(입력 중 방해 방지).
let pjPendingReload = false;
function anyModalOpen() { return !!document.querySelector(".pj-modal:not([hidden])"); }
function closeModal(m) {
if (m) m.hidden = true;
if (pjPendingReload && !anyModalOpen()) location.reload();
}
function wireModalClose(modal) {
if (!modal) return;
modal.addEventListener("click", function (e) {
if (e.target === modal || e.target.hasAttribute("data-close")) closeModal(modal);
});
}
// ── 색상 팔레트 렌더 ───────────────────────────────────────
function renderColorPalette(container, onPick, initial) {
const colors = window.PJ_COLORS || ["#4573d2"];
let selected = (initial && colors.indexOf(initial) >= 0) ? initial : colors[0];
container.innerHTML = "";
colors.forEach(function (c) {
const b = document.createElement("button");
b.type = "button";
b.className = "pj-color-swatch";
b.style.background = c;
if (c === selected) b.classList.add("is-active");
b.addEventListener("click", function () {
selected = c;
container.querySelectorAll(".pj-color-swatch").forEach(function (x) {
x.classList.remove("is-active");
});
b.classList.add("is-active");
if (onPick) onPick(c);
});
container.appendChild(b);
});
return function () { return selected; };
}
// ════════════════════════════════════════════════════════════
// 홈 페이지 — 새 프로젝트
// ════════════════════════════════════════════════════════════
function initHome() {
const grid = el("pj-project-grid");
if (!grid && !el("pj-toggle-done")) return; // 홈 아님
const wrap = document.querySelector(".pj-wrap");
const me = ((wrap && wrap.dataset.me) || "").toLowerCase();
const isSuper = !!(wrap && wrap.dataset.isSuper === "true");
const isAdmin = !!(wrap && wrap.dataset.isAdmin === "true");
function canDelete(createdBy) {
if (isSuper) return true;
const cb = (createdBy || "").toLowerCase();
return !!cb && cb === me;
}
// ── 완료 프로젝트 섹션 접기/펼치기 (localStorage 기억) ──
// display 를 인라인 스타일로 직접 제어 — 외부 CSS 규칙과의 특이도/우선순위
// 문제를 원천 차단(어떤 스타일시트보다 인라인 style 이 항상 우선한다).
const toggleBtn = el("pj-toggle-done");
const doneGrid = el("pj-project-grid-done");
if (toggleBtn && doneGrid) {
const KEY = "pj_hide_done";
let hideDone = localStorage.getItem(KEY) === "1";
function apply() {
doneGrid.style.display = hideDone ? "none" : "";
const n = toggleBtn.dataset.count;
toggleBtn.textContent = hideDone ? "완료 " + n + "개 보기" : "완료 " + n + "개 숨기기";
}
apply();
toggleBtn.addEventListener("click", function () {
hideDone = !hideDone;
try { localStorage.setItem(KEY, hideDone ? "1" : "0"); } catch (_) {}
apply();
});
}
// ── 새 프로젝트 모달 ──
const newBtn = el("pj-new-project");
const modal = el("pj-modal-project");
if (newBtn && modal) {
wireModalClose(modal);
const getColor = renderColorPalette(el("pj-f-colors"));
const membersBox = el("pj-f-members");
newBtn.addEventListener("click", async function () {
openModal(modal);
if (membersBox) {
// 멤버 배정은 관리자 전용 API(/assignable-users)라 관리자가 아니면
// 호출해도 403 이 난다 — 아예 시도하지 않고 안내만 보여준다.
// (프로젝트 생성 자체는 이제 누구나 가능 — 멤버는 나중에 관리자가 배정)
if (!isAdmin) {
membersBox.innerHTML = "<span class='pj-empty-sm'>멤버 배정은 관리자가 나중에 할 수 있습니다.</span>";
} else {
membersBox.innerHTML = "불러오는 중…";
try {
const res = await api("GET", "/project/api/assignable-users");
const users = res.users || [];
membersBox.innerHTML = users.length ? users.map(function (u) {
return '<label class="pj-member-pick"><input type="checkbox" value="' + u.email + '" /> ' +
escapeHtml(u.id) + "</label>";
}).join("") : "<span class='pj-empty-sm'>배정 가능한 직원이 없습니다.</span>";
} catch (e) {
membersBox.innerHTML = "<span class='pj-empty-sm'>불러오기 실패</span>";
}
}
}
});
el("pj-save-project").addEventListener("click", async function () {
const name = el("pj-f-name").value.trim();
if (!name) { alert("이름을 입력하세요."); return; }
const memberEmails = membersBox
? Array.from(membersBox.querySelectorAll("input[type=checkbox]:checked")).map(function (c) { return c.value; })
: [];
try {
const res = await api("POST", "/project/api/projects", {
name: name,
description: el("pj-f-desc").value.trim(),
start_date: el("pj-f-start").value || null,
due_date: el("pj-f-due").value || null,
color: getColor(),
});
const pid = res.project.id;
for (const email of memberEmails) {
try { await api("POST", "/project/api/projects/" + pid + "/members", { user_email: email }); }
catch (_) {}
}
location.reload();
} catch (e) { alert("생성 실패: " + e.message); }
});
}
// ── 프로젝트 수정 모달 (진행중/완료 두 그리드 모두 대상) ──
const editModal = el("pj-modal-edit");
if (editModal && grid) {
wireModalClose(editModal);
let getEditColor = null;
document.querySelectorAll(".pj-project-card .pj-card-edit").forEach(function (btn) {
btn.addEventListener("click", function (e) {
e.preventDefault(); e.stopPropagation();
const card = btn.closest(".pj-project-card");
el("pj-e-id").value = card.dataset.id;
el("pj-e-name").value = card.dataset.name || "";
el("pj-e-desc").value = card.dataset.desc || "";
el("pj-e-start").value = card.dataset.start || "";
el("pj-e-due").value = card.dataset.due || "";
el("pj-e-done").checked = card.dataset.status === "completed";
getEditColor = renderColorPalette(el("pj-e-colors"), null, card.dataset.color);
openModal(editModal);
});
});
el("pj-save-edit").addEventListener("click", async function () {
const id = el("pj-e-id").value;
const name = el("pj-e-name").value.trim();
if (!name) { alert("이름을 입력하세요."); return; }
try {
await api("PUT", "/project/api/projects/" + id, {
name: name,
description: el("pj-e-desc").value.trim(),
start_date: el("pj-e-start").value || null,
due_date: el("pj-e-due").value || null,
color: getEditColor ? getEditColor() : undefined,
status: el("pj-e-done").checked ? "completed" : "active",
});
location.reload();
} catch (e) { alert("저장 실패: " + e.message); }
});
el("pj-delete-project").addEventListener("click", async function () {
const id = el("pj-e-id").value;
const card = document.querySelector('.pj-project-card[data-id="' + id + '"]');
if (card && !canDelete(card.dataset.creator)) { alert("삭제는 만든 사람만 할 수 있습니다."); return; }
if (!confirm("이 프로젝트를 삭제할까요? 안의 업무도 모두 삭제됩니다.")) return;
try { await api("DELETE", "/project/api/projects/" + id); location.reload(); }
catch (e) { alert("삭제 실패: " + e.message); }
});
}
// 카드/내 업무 클릭 팝업 + 댓글 팝업 공용 — 업무 원본 데이터(제목 등).
const homeDataEl = el("pj-home-data");
let homeTasks = {};
if (homeDataEl) {
try { homeTasks = JSON.parse(homeDataEl.textContent) || {}; } catch (_) {}
}
// ── 업무 편집 팝업 (카드/내 업무 클릭 시 페이지 이동 없이) ──
const homeTaskModal = el("pj-modal-task");
if (homeTaskModal) {
const descEditor = initDescEditor();
const subtaskBlock = initSubtaskBlock();
const linksBlock = initLinksBlock();
if (descEditor) {
descEditor.setOnImageRemoved(function () {
const tid = el("pj-t-id").value;
if (tid) loadHomeAttachments(tid);
});
}
// 닫을 때(배경 클릭·Esc·취소 버튼) 수정한 내용이 있으면 저장할지 물어본다.
let homeTaskSnapshot = null;
function homeTaskFormSnapshot() {
return JSON.stringify({
title: el("pj-t-title").value,
assignee: el("pj-t-assignee").value,
stage: el("pj-t-stage").value,
priority: el("pj-t-priority").value,
start: el("pj-t-start").value,
due: el("pj-t-due").value,
usetime: el("pj-t-usetime").checked,
stime: el("pj-t-stime").value,
dtime: el("pj-t-dtime").value,
desc: descEditor ? descEditor.getHtml() : "",
});
}
function isHomeTaskDirty() {
return homeTaskSnapshot !== null && homeTaskFormSnapshot() !== homeTaskSnapshot;
}
let homeTaskClosing = false;
async function attemptCloseHomeTaskModal() {
// erpConfirm 도 Esc 로 닫히는데 stopPropagation 을 안 해서, 확인창이
// 뜬 상태에서 Esc 를 누르면 이 함수가 다시 불릴 수 있다 — 재진입 방지.
if (homeTaskModal.hidden || homeTaskClosing) return;
homeTaskClosing = true;
try {
if (isHomeTaskDirty()) {
const save = await window.erpConfirm(
"수정한 내용이 있습니다. 저장하시겠습니까?",
{ okText: "저장", cancelText: "저장 안 함" }
);
if (save) { el("pj-save-task").click(); return; }
}
homeTaskSnapshot = null;
closeModal(homeTaskModal);
} finally { homeTaskClosing = false; }
}
homeTaskModal.addEventListener("click", function (e) {
if (e.target === homeTaskModal || e.target.hasAttribute("data-close")) attemptCloseHomeTaskModal();
});
document.addEventListener("keydown", function (e) {
if (e.key === "Escape" && !homeTaskModal.hidden) { e.preventDefault(); attemptCloseHomeTaskModal(); }
});
async function loadTaskExtras(task) {
if (!subtaskBlock && !linksBlock) return;
try {
const res = await api("GET", "/project/api/tasks/" + task.id);
const detail = res.task;
if (subtaskBlock) {
subtaskBlock.setContext({ taskId: task.id, projectId: task.project_id, canDelete: canDelete });
subtaskBlock.render(detail.subtasks || []);
}
if (linksBlock) {
linksBlock.setContext({
taskId: task.id, projectId: task.project_id,
onChange: function () { loadTaskExtras(task); },
});
linksBlock.render(detail.links || []);
}
} catch (_) { /* 조용히 무시 — 본편집은 계속 가능해야 한다 */ }
}
async function fillAssignee(pid, currentEmail) {
const sel = el("pj-t-assignee");
let list = [];
try { list = (await api("GET", "/project/api/projects/" + pid + "/members")).members || []; }
catch (_) { list = []; }
sel.innerHTML = '<option value="">(미지정)</option>' + list.map(function (m) {
const name = idOf(m.user_email);
return '<option value="' + m.user_email + '" data-name="' + escapeHtml(name) + '">' + escapeHtml(name) + "</option>";
}).join("");
sel.value = currentEmail || "";
}
async function fillStage(pid, currentStageId) {
const sel = el("pj-t-stage");
let list = [];
try { list = (await api("GET", "/project/api/projects/" + pid + "/stages")).stages || []; }
catch (_) { list = []; }
sel.innerHTML = list.map(function (s) {
return '<option value="' + s.id + '">' + escapeHtml(s.name) + "</option>";
}).join("");
sel.value = currentStageId || (list[0] ? list[0].id : "");
}
async function loadHomeAttachments(taskId) {
const ul = el("pj-attach-list");
ul.innerHTML = "<li class='pj-empty-sm'>불러오는 중…</li>";
try {
const res = await api("GET", "/project/api/tasks/" + taskId + "/attachments");
if (!res.attachments.length) { ul.innerHTML = "<li class='pj-empty-sm'>없음</li>"; return; }
ul.innerHTML = "";
res.attachments.forEach(function (a) {
const li = document.createElement("li");
li.className = "pj-attach-item";
li.innerHTML =
'<a href="/project/api/attachments/' + a.id + '/download">' + escapeHtml(a.filename) + "</a>" +
'<button type="button" class="pj-icon-btn pj-attach-del" data-id="' + a.id + '">×</button>';
ul.appendChild(li);
});
ul.querySelectorAll(".pj-attach-del").forEach(function (b) {
b.addEventListener("click", async function () {
if (!confirm("이 첨부를 삭제할까요?")) return;
try { await api("DELETE", "/project/api/attachments/" + b.dataset.id); loadHomeAttachments(taskId); }
catch (e) { alert("삭제 실패: " + e.message); }
});
});
} catch (e) { ul.innerHTML = "<li class='pj-empty-sm'>불러오기 실패</li>"; }
}
const homeAttachInput = el("pj-attach-input");
if (homeAttachInput) homeAttachInput.addEventListener("change", async function () {
const taskId = el("pj-t-id").value;
if (!taskId || !homeAttachInput.files.length) return;
const fd = new FormData();
fd.append("file", homeAttachInput.files[0]);
try {
const res = await fetch("/project/api/tasks/" + taskId + "/attachments", { method: "POST", body: fd });
if (!res.ok) { let m = res.statusText; try { m = (await res.json()).detail; } catch (_) {} throw new Error(m); }
homeAttachInput.value = "";
loadHomeAttachments(taskId);
} catch (e) { alert("업로드 실패: " + e.message); }
});
async function loadHomeComments(taskId) {
const ul = el("pj-comment-list");
ul.innerHTML = "<li class='pj-empty-sm'>불러오는 중…</li>";
try {
const res = await api("GET", "/project/api/tasks/" + taskId + "/comments");
if (!res.comments.length) { ul.innerHTML = "<li class='pj-empty-sm'>댓글 없음</li>"; return; }
ul.innerHTML = "";
res.comments.forEach(function (c) {
const li = document.createElement("li");
li.className = "pj-comment-item";
li.innerHTML =
'<span class="material-symbols-outlined pj-gicon pj-gicon-sm">account_circle</span>' +
'<div class="pj-comment-main"><div class="pj-comment-head">' +
escapeHtml(idOf(c.author_email)) + ' <span class="pj-comment-time">' +
(c.created_at || "").slice(0, 16).replace("T", " ") + "</span></div>" +
'<div class="pj-comment-body"></div></div>' +
'<span class="pj-comment-acts"><button type="button" class="pj-icon-btn pj-comment-del" title="삭제">×</button></span>';
li.querySelector(".pj-comment-body").textContent = c.body;
li.querySelector(".pj-comment-del").addEventListener("click", async function () {
try { await api("DELETE", "/project/api/comments/" + c.id); loadHomeComments(taskId); }
catch (e) { alert("삭제 실패: " + e.message); }
});
ul.appendChild(li);
});
} catch (e) { ul.innerHTML = "<li class='pj-empty-sm'>불러오기 실패</li>"; }
}
el("pj-comment-send").addEventListener("click", async function () {
const taskId = el("pj-t-id").value;
const inp = el("pj-comment-input");
const body = inp.value.trim();
if (!taskId || !body) return;
try {
await api("POST", "/project/api/tasks/" + taskId + "/comments", { body: body });
inp.value = "";
loadHomeComments(taskId);
} catch (e) { alert("등록 실패: " + e.message); }
});
el("pj-comment-input").addEventListener("keydown", function (e) {
if (e.key === "Enter") { e.preventDefault(); el("pj-comment-send").click(); }
});
async function openHomeTaskModal(task) {
el("pj-task-modal-title").textContent = "[" + (task.project_name || "") + "]의 업무 편집";
el("pj-t-id").value = task.id;
el("pj-t-project-id").value = task.project_id;
el("pj-t-title").value = task.title || "";
if (descEditor) { descEditor.setTaskId(task.id); descEditor.setHtml(task.description || ""); }
el("pj-t-priority").value = task.priority || "normal";
el("pj-t-start").value = task.start_date || "";
el("pj-t-due").value = task.due_date || "";
const useTime = !!(task.start_time || task.due_time);
el("pj-t-usetime").checked = useTime;
el("pj-t-stime").value = task.start_time || "";
el("pj-t-dtime").value = task.due_time || "";
el("pj-t-stime").disabled = !useTime;
el("pj-t-dtime").disabled = !useTime;
el("pj-t-open-project").href = "/project/p/" + task.project_id + "?task=" + task.id;
el("pj-delete-task").hidden = false;
el("pj-toggle-complete").hidden = false;
setHomeCompleteBtn(!!task.completed_at);
el("pj-t-open-project").hidden = false;
el("pj-attach-block").hidden = false;
el("pj-comment-block").hidden = false;
if (subtaskBlock) el("pj-subtask-block").hidden = false;
if (linksBlock) el("pj-links-block").hidden = false;
openModal(homeTaskModal);
await Promise.all([
fillAssignee(task.project_id, task.assignee_email || ""),
fillStage(task.project_id, task.stage_id),
]);
loadHomeAttachments(task.id);
loadHomeComments(task.id);
loadTaskExtras(task);
homeTaskSnapshot = homeTaskFormSnapshot();
}
// 카드 이름 옆 "업무 추가" 버튼 — 같은 모달을 새 업무 작성용으로 초기화해 연다.
async function openHomeNewTaskModal(projectId, projectName) {
el("pj-task-modal-title").textContent = "[" + (projectName || "") + "]의 새 업무";
el("pj-t-id").value = "";
el("pj-t-project-id").value = projectId;
el("pj-t-title").value = "";
if (descEditor) { descEditor.setTaskId(null); descEditor.setHtml(""); }
el("pj-t-priority").value = "normal";
el("pj-t-start").value = "";
el("pj-t-due").value = "";
el("pj-t-usetime").checked = false;
el("pj-t-stime").value = ""; el("pj-t-stime").disabled = true;
el("pj-t-dtime").value = ""; el("pj-t-dtime").disabled = true;
el("pj-delete-task").hidden = true;
el("pj-toggle-complete").hidden = true;
el("pj-t-open-project").hidden = true;
el("pj-attach-block").hidden = true;
el("pj-comment-block").hidden = true;
if (subtaskBlock) el("pj-subtask-block").hidden = true;
if (linksBlock) el("pj-links-block").hidden = true;
openModal(homeTaskModal);
await Promise.all([fillAssignee(projectId, ""), fillStage(projectId, null)]);
homeTaskSnapshot = homeTaskFormSnapshot();
}
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 {
await api("PUT", "/project/api/tasks/" + id, { completed: !done });
location.reload();
} catch (e) { alert("처리 실패: " + e.message); }
});
document.querySelectorAll(".pj-card-addtask").forEach(function (btn) {
btn.addEventListener("click", function (e) {
e.preventDefault(); e.stopPropagation();
const card = btn.closest(".pj-project-card");
openHomeNewTaskModal(btn.dataset.projectId, card ? card.dataset.name : "");
});
});
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 {
if (id) {
await api("PUT", "/project/api/tasks/" + id, payload);
} else {
if (!pid) return;
await api("POST", "/project/api/projects/" + pid + "/tasks", payload);
}
location.reload();
} catch (e) { alert("저장 실패: " + e.message); }
});
el("pj-delete-task").addEventListener("click", async function () {
const id = el("pj-t-id").value;
if (!id) return;
if (!confirm("이 업무를 삭제할까요?")) return;
try { await api("DELETE", "/project/api/tasks/" + id); location.reload(); }
catch (e) { alert("삭제 실패: " + e.message); }
});
document.querySelectorAll(".pj-task-popup-link").forEach(function (a) {
a.addEventListener("click", function (e) {
const tid = a.dataset.taskId;
const task = homeTasks[tid];
if (!task) return; // 정보 없으면 기존 링크 이동으로 폴백
e.preventDefault();
openHomeTaskModal(task);
});
});
}
// ── 댓글 말풍선(카드/내 업무) — 더블클릭 시 댓글만 보는 채팅형 팝업 ──
const commentsModal = el("pj-modal-comments");
if (commentsModal) {
wireModalClose(commentsModal);
function loadSeenComments() {
try { return JSON.parse(localStorage.getItem("pj_seen_comments") || "{}"); }
catch (_) { return {}; }
}
function markSeen(taskId, count) {
const m = loadSeenComments();
m[taskId] = count;
try { localStorage.setItem("pj_seen_comments", JSON.stringify(m)); } catch (_) {}
}
// 페이지 로드 시 — 마지막으로 확인한 개수보다 늘었으면 반짝임(is-unread) 표시.
const seen = loadSeenComments();
document.querySelectorAll(".pj-cmt-badge").forEach(function (b) {
const n = parseInt(b.dataset.count, 10) || 0;
const seenN = seen[b.dataset.taskId] || 0;
if (n > seenN) b.classList.add("is-unread");
});
async function renderChat(taskId) {
const list = el("pj-chat-list");
list.innerHTML = "<li class='pj-empty-sm'>불러오는 중…</li>";
try {
const res = await api("GET", "/project/api/tasks/" + taskId + "/comments");
const comments = res.comments || [];
if (!comments.length) {
list.innerHTML = "<li class='pj-empty-sm'>댓글 없음</li>";
} else {
list.innerHTML = "";
comments.forEach(function (c) {
const mine = (c.author_email || "").toLowerCase() === me;
const li = document.createElement("li");
li.className = "pj-chat-msg" + (mine ? " mine" : "");
li.innerHTML =
(mine ? "" : '<span class="pj-chat-name">' + escapeHtml(idOf(c.author_email)) + "</span>") +
'<div class="pj-chat-bubble"></div>' +
'<span class="pj-chat-meta">' +
(mine ? '<button type="button" class="pj-icon-btn pj-chat-del" title="삭제"><span class="material-symbols-outlined">close</span></button>' : "") +
'<span class="pj-chat-time">' + (c.created_at || "").slice(0, 16).replace("T", " ") + "</span>" +
"</span>";
li.querySelector(".pj-chat-bubble").textContent = c.body;
if (mine) {
li.querySelector(".pj-chat-del").addEventListener("click", async function () {
if (!confirm("이 댓글을 삭제할까요?")) return;
try {
await api("DELETE", "/project/api/comments/" + c.id);
const n = await renderChat(taskId);
if (n !== null) { markSeen(taskId, n); syncBadge(taskId, n); }
} catch (e) { alert("삭제 실패: " + e.message); }
});
}
list.appendChild(li);
});
list.scrollTop = list.scrollHeight;
}
return comments.length;
} catch (e) {
list.innerHTML = "<li class='pj-empty-sm'>불러오기 실패</li>";
return null;
}
}
function syncBadge(taskId, n) {
const badge = document.querySelector('.pj-cmt-badge[data-task-id="' + taskId + '"]');
if (!badge) return;
badge.dataset.count = n;
badge.title = "댓글 " + n + "개";
const numEl = badge.querySelector(".pj-cmt-n");
if (numEl) numEl.textContent = n;
badge.classList.remove("is-unread");
}
async function openCommentsModal(taskId) {
el("pj-cm-task-id").value = taskId;
const task = homeTasks[taskId];
el("pj-cm-title").textContent = (task && task.title) || "댓글";
openModal(commentsModal);
const n = await renderChat(taskId);
if (n !== null) { markSeen(taskId, n); syncBadge(taskId, n); }
}
document.querySelectorAll(".pj-cmt-badge").forEach(function (b) {
b.addEventListener("click", function (e) { e.preventDefault(); e.stopPropagation(); });
b.addEventListener("dblclick", function (e) {
e.preventDefault(); e.stopPropagation();
openCommentsModal(b.dataset.taskId);
});
});
el("pj-cm-send").addEventListener("click", async function () {
const taskId = el("pj-cm-task-id").value;
const inp = el("pj-cm-input");
const body = inp.value.trim();
if (!taskId || !body) return;
try {
await api("POST", "/project/api/tasks/" + taskId + "/comments", { body: body });
inp.value = "";
const n = await renderChat(taskId);
if (n !== null) { markSeen(taskId, n); syncBadge(taskId, n); }
} catch (e) { alert("등록 실패: " + e.message); }
});
el("pj-cm-input").addEventListener("keydown", function (e) {
if (e.key === "Enter") { e.preventDefault(); el("pj-cm-send").click(); }
});
}
}
// ════════════════════════════════════════════════════════════
// 프로젝트 페이지
// ════════════════════════════════════════════════════════════
function initProject() {
const root = document.querySelector(".pj-project-view");
if (!root) return;
const projectId = parseInt(root.dataset.projectId, 10);
const canManage = root.dataset.canManage === "true";
const isAdmin = root.dataset.isAdmin === "true";
const me = (root.dataset.me || "").toLowerCase();
const isSuper = root.dataset.isSuper === "true";
// 삭제는 만든 사람만(슈퍼관리자 예외). created_by 없으면 서버가 최종 판정.
function canDelete(createdBy) {
if (isSuper) return true;
const cb = (createdBy || "").toLowerCase();
return !!cb && cb === me;
}
const DELETE_DENIED = "삭제는 만든 사람만 할 수 있습니다.";
// 댓글 있으면 말풍선 아이콘(+개수). 색은 currentColor 상속(막대=흰, 카드/리스트=진회색).
function cmtBadge(t, cls) {
const n = t.comment_count || 0;
if (!n) return "";
return '<span class="pj-cmt-ic ' + (cls || "") +
'" title="댓글 ' + n + '개">💬</span><span class="pj-cmt-n">' + n + "</span>";
}
const dataEl = el("pj-data");
const data = JSON.parse(dataEl.textContent);
const currentProject = data.project || {};
let stages = data.stages || [];
let tasks = data.tasks || [];
const members = data.members || [];
const priorityLabels = data.priorityLabels || {};
// 전체 프로젝트 집계 데이터 — 4개 뷰는 전 프로젝트를 보여준다.
const projectsMeta = data.projects || [];
const stagesByProject = data.stagesByProject || {};
const boardStages = data.boardStages || [];
const avatars = data.avatars || {};
const myTasksById = {};
(data.myTasks || []).forEach(function (t) { myTasksById[t.id] = t; });
// 우측 "내 업무" 패널 — openTaskModal 은 이 함수 뒤쪽에서 선언되지만
// function 선언은 호이스팅되어 여기서 참조해도 문제없다.
document.querySelectorAll(".pj-project-mytasks .pj-task-popup-link").forEach(function (a) {
a.addEventListener("click", function (e) {
e.preventDefault();
const t = myTasksById[a.dataset.taskId];
if (t) openTaskModal(t);
});
});
// 담당자/작성자 아이콘 = 구글 프로필 이미지(원형). 없으면 기본 아이콘.
function avatarHtml(email, cls) {
const url = email ? avatars[String(email).toLowerCase()] : "";
const c = cls || "";
if (url) return '<img class="pj-avatar ' + c + '" src="' + escapeHtml(url) + '" alt="" referrerpolicy="no-referrer">';
return '<span class="material-symbols-outlined pj-gicon ' + c + '">account_circle</span>';
}
let timeline = null;
// 마지막으로 보던 뷰를 기억한다 — 달력 이전/다음달 이동은 페이지를 통째로
// 새로고침하므로(?y=&m=), 기억이 없으면 달력을 보다가도 매번 기본값(보드)
// 으로 튕겨나간다.
const VIEW_KEY = "pj_view";
let currentView = "board";
try {
const saved = localStorage.getItem(VIEW_KEY);
if (saved && ["board", "list", "calendar", "timeline"].indexOf(saved) >= 0) currentView = saved;
} catch (_) {}
// ── 프로젝트 스코프 — 아사나처럼 기본은 "이 프로젝트만", "전체 프로젝트"로
// 전환하면 예전처럼 접근 가능한 모든 프로젝트를 합쳐서 보여준다.
// tasks 배열의 각 행은 서버가 어느 프로젝트를 보다가 담았는지를
// context_project_id 로 표시한다(멀티호밍된 업무는 원 소속과 연결된
// 프로젝트 양쪽에서 각각 한 번씩 나온다 — 그래야 각 화면에 다 보인다).
// "전체" 모드는 원 소속(context_project_id === project_id)만 세어 중복을 없앤다.
let viewScope = "project";
function visibleTasks() {
return viewScope === "project"
? tasks.filter(function (t) { return t.context_project_id === projectId; })
: tasks.filter(function (t) { return t.context_project_id === t.project_id; });
}
// ── 뷰 토글 ──
const tabs = el("pj-view-tabs");
tabs.addEventListener("click", function (e) {
const btn = e.target.closest(".pj-tab");
if (!btn) return;
const view = btn.dataset.view;
tabs.querySelectorAll(".pj-tab").forEach(function (t) {
t.classList.toggle("is-active", t === btn);
});
document.querySelectorAll(".pj-view").forEach(function (v) {
v.hidden = v.dataset.view !== view;
});
currentView = view;
try { localStorage.setItem(VIEW_KEY, view); } catch (_) {}
renderView(view);
});
// 서버가 그린 기본 탭(보드)과 기억된 탭이 다르면 화면을 맞춰준다.
if (currentView !== "board") {
tabs.querySelectorAll(".pj-tab").forEach(function (t) {
t.classList.toggle("is-active", t.dataset.view === currentView);
});
document.querySelectorAll(".pj-view").forEach(function (v) {
v.hidden = v.dataset.view !== currentView;
});
}
// ── 프로젝트 스코프 토글 ──
const scopeToggle = el("pj-scope-toggle");
if (scopeToggle) {
scopeToggle.addEventListener("click", function (e) {
const btn = e.target.closest("[data-scope]");
if (!btn) return;
viewScope = btn.dataset.scope;
scopeToggle.querySelectorAll("[data-scope]").forEach(function (b) {
b.classList.toggle("is-active", b === btn);
});
renderView(currentView);
});
}
function renderView(view) {
if (view === "calendar") renderCalendar();
else if (view === "timeline") renderTimeline();
else if (view === "board") renderBoard();
else if (view === "list") renderList();
}
// ════════════════════════════════════════════════════════════
// 달력 — 구글 캘린더식. 막대(칩)는 클라이언트가 그린다.
// · 한 주에 표시 행 수 제한(MAX_LANES) → 넘치면 "+N" 칩, 클릭 시 그날 목록 팝오버
// · 드래그 이동: 낙관적 업데이트(즉시 다시 그림, 새로고침 없음) + 실패 시 롤백
// ════════════════════════════════════════════════════════════
const MAX_LANES = 3;
function addDays(dstr, days) {
if (!dstr) return null;
const d = new Date(dstr + "T00:00:00");
d.setDate(d.getDate() + days);
const y = d.getFullYear(), m = String(d.getMonth() + 1).padStart(2, "0"), dd = String(d.getDate()).padStart(2, "0");
return y + "-" + m + "-" + dd;
}
function dayDiff(a, b) {
return Math.round((new Date(b + "T00:00:00") - new Date(a + "T00:00:00")) / 86400000);
}
function clearDropHL() {
document.querySelectorAll(".pj-day.pj-drop-over").forEach(function (d) { d.classList.remove("pj-drop-over"); });
}
function dayCellAt(x, y) {
const elAt = document.elementFromPoint(x, y);
return elAt ? elAt.closest(".pj-day[data-date]") : null;
}
// 업무 표시 기간 [시작, 끝] (문자열, 끝>=시작). 둘 중 하나만 있으면 단일일.
function taskSpan(t) {
const s = t.start_date, d = t.due_date;
if (s && d) return s <= d ? [s, d] : [d, s];
if (s) return [s, s];
if (d) return [d, d];
return null;
}
// 업무마다 '한 달 전체' 기준으로 고정 레인(행) 배정 → 여러 주에 걸쳐도 같은 줄 유지.
// (주마다 따로 배치하면 첫주/다음주의 순서가 바뀌는 문제 방지)
function computeLanes() {
const items = [];
visibleTasks().forEach(function (t) {
const sp = taskSpan(t);
if (sp) items.push({ id: t.id, s: sp[0], e: sp[1] });
});
// 시작일 → 종료일 → id 순(결정적). 문자열 'YYYY-MM-DD' 사전식 비교.
items.sort(function (a, b) {
if (a.s !== b.s) return a.s < b.s ? -1 : 1;
if (a.e !== b.e) return a.e < b.e ? -1 : 1;
return a.id - b.id;
});
const laneEnd = []; // lane 별 마지막 점유 종료일
const map = {};
items.forEach(function (it) {
for (var i = 0; i < laneEnd.length; i++) {
if (it.s > laneEnd[i]) { map[it.id] = i; laneEnd[i] = it.e; return; }
}
map[it.id] = laneEnd.length; laneEnd.push(it.e);
});
return map;
}
// 전체 달력 다시 그리기 — 서버가 그린 날짜 셀(.pj-day[data-date])을 읽어 주별로 막대 배치.
function renderCalendar() {
const cal = document.querySelector(".pj-cal");
if (!cal) return;
closeDayPop();
const laneOf = computeLanes();
cal.querySelectorAll(".pj-week").forEach(function (weekEl) {
const dayEls = weekEl.querySelectorAll(".pj-day[data-date]");
if (!dayEls.length) return;
const weekStart = dayEls[0].dataset.date;
const weekEnd = dayEls[dayEls.length - 1].dataset.date;
// 이 주에 걸치는 업무 → 막대 세그먼트(레인은 전역 배정값 사용)
const bars = [];
visibleTasks().forEach(function (t) {
const sp = taskSpan(t);
if (!sp) return;
if (sp[1] < weekStart || sp[0] > weekEnd) return;
const segStart = sp[0] < weekStart ? weekStart : sp[0];
const segEnd = sp[1] > weekEnd ? weekEnd : sp[1];
bars.push({
t: t,
startCol: dayDiff(weekStart, segStart),
span: dayDiff(segStart, segEnd) + 1,
cl: sp[0] < weekStart, cr: sp[1] > weekEnd,
lane: laneOf[t.id] || 0,
});
});
// 전역 레인 순서는 유지하되, 이 주에 실제 존재하는 레인만 0,1,2…로 압축
// (빈 행 제거 → 헛된 '+N' 방지, 단 두 막대의 상하 순서는 항상 동일).
const present = {};
bars.forEach(function (b) { present[b.lane] = true; });
const lanesSorted = Object.keys(present).map(Number).sort(function (a, b) { return a - b; });
const rankOf = {};
lanesSorted.forEach(function (l, i) { rankOf[l] = i; });
const cont = weekEl.querySelector(".pj-week-bars");
cont.innerHTML = "";
const overflow = [0, 0, 0, 0, 0, 0, 0];
bars.forEach(function (b) {
const row = rankOf[b.lane];
if (row >= MAX_LANES) { // 넘침 → 그 칸들 +N 카운트
for (var c = b.startCol; c < b.startCol + b.span; c++) overflow[c]++;
return;
}
const div = document.createElement("div");
div.className = "pj-bar" + (b.t.completed_at ? " pj-bar-done" : "") +
(b.cl ? " pj-bar-l" : "") + (b.cr ? " pj-bar-r" : "");
div.dataset.taskId = b.t.id;
div.style.gridColumn = (b.startCol + 1) + " / span " + b.span;
div.style.gridRow = (row + 1);
div.style.background = b.t.completed_at ? "#9aa5b1" : (b.t.project_color || "#4573d2");
div.title = "[" + (b.t.project_name || "") + "] " + (b.t.title || "") +
(b.t.assignee_email ? " · " + idOf(b.t.assignee_email) : "");
div.innerHTML = '<span class="pj-bar-label">' + escapeHtml(b.t.title || "") + "</span>" + cmtBadge(b.t);
cont.appendChild(div);
});
for (var col = 0; col < 7; col++) {
if (overflow[col] > 0) {
const m = document.createElement("div");
m.className = "pj-bar-more";
m.style.gridColumn = (col + 1) + " / span 1";
m.style.gridRow = (MAX_LANES + 1);
m.dataset.date = dayEls[col].dataset.date;
m.textContent = "+" + overflow[col] + " 더";
cont.appendChild(m);
}
}
});
wireCalendarDrag(cal);
}
// 드래그(포인터 기반). 안 움직이면 클릭=모달, +N 칩 클릭=그날 팝오버.
function wireCalendarDrag(cal) {
cal.querySelectorAll(".pj-bar[data-task-id]").forEach(function (bar) {
bar.addEventListener("mousedown", function (e) {
if (e.button !== 0) return;
e.preventDefault();
const taskId = bar.dataset.taskId;
const sx = e.clientX, sy = e.clientY;
let dragging = false, ghost = null;
function onMove(ev) {
if (!dragging) {
if (Math.abs(ev.clientX - sx) + Math.abs(ev.clientY - sy) < 5) return;
dragging = true;
cal.classList.add("pj-cal-dragging");
if (canManage) {
ghost = document.createElement("div");
ghost.className = "pj-bar-ghost";
ghost.textContent = (bar.textContent || "").trim();
document.body.appendChild(ghost);
}
}
if (ghost) { ghost.style.left = (ev.clientX + 10) + "px"; ghost.style.top = (ev.clientY + 12) + "px"; }
clearDropHL();
const cell = dayCellAt(ev.clientX, ev.clientY);
if (cell) cell.classList.add("pj-drop-over");
}
function onUp(ev) {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
cal.classList.remove("pj-cal-dragging");
if (ghost) ghost.remove();
clearDropHL();
const t = tasks.find(function (x) { return String(x.id) === String(taskId); });
if (!dragging) { if (t && canManage) openTaskModal(t); return; }
if (!canManage) return;
const cell = dayCellAt(ev.clientX, ev.clientY);
if (cell) moveTaskTo(taskId, cell.dataset.date);
}
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
});
});
}
// 낙관적 이동 — 즉시 다시 그리고 백그라운드 저장, 실패 시 롤백.
async function moveTaskTo(taskId, dropDate) {
const t = tasks.find(function (x) { return String(x.id) === String(taskId); });
if (!t) return;
const anchor = t.start_date || t.due_date;
if (!anchor) { alert("날짜가 없는 업무는 달력에서 이동할 수 없습니다."); return; }
const delta = dayDiff(anchor, dropDate);
if (delta === 0) return;
const old = { s: t.start_date, d: t.due_date };
t.start_date = t.start_date ? addDays(t.start_date, delta) : null;
t.due_date = t.due_date ? addDays(t.due_date, delta) : null;
renderCalendar();
try {
await api("PUT", "/project/api/tasks/" + taskId, { start_date: t.start_date, due_date: t.due_date });
} catch (err) {
t.start_date = old.s; t.due_date = old.d; renderCalendar();
alert("이동 실패: " + err.message);
}
}
// ── "+N 더" 그날 업무 팝오버 ──
let dayPop = null;
function closeDayPop() {
if (dayPop) { dayPop.remove(); dayPop = null; document.removeEventListener("mousedown", onDocDown, true); }
}
function onDocDown(e) {
if (dayPop && !dayPop.contains(e.target) && !e.target.closest(".pj-bar-more")) closeDayPop();
}
function openDayPopover(dateStr, anchorEl) {
closeDayPop();
const list = visibleTasks().filter(function (t) {
const sp = taskSpan(t); return sp && sp[0] <= dateStr && dateStr <= sp[1];
});
dayPop = document.createElement("div");
dayPop.className = "pj-daypop";
const head = document.createElement("div");
head.className = "pj-daypop-head"; head.textContent = dateStr;
dayPop.appendChild(head);
list.forEach(function (t) {
const row = document.createElement("div");
row.className = "pj-daypop-item" + (t.completed_at ? " is-done" : "");
row.innerHTML =
'<span class="pj-daypop-dot" style="background:' +
(t.completed_at ? "#9aa5b1" : (t.project_color || "#4573d2")) + '"></span>' +
'<span class="pj-daypop-title">' + escapeHtml(t.title || "") + "</span>";
row.addEventListener("click", function () { closeDayPop(); if (canManage) openTaskModal(t); });
dayPop.appendChild(row);
});
document.body.appendChild(dayPop);
const r = anchorEl.getBoundingClientRect();
let left = window.scrollX + r.left;
const top = window.scrollY + r.bottom + 4;
left = Math.min(left, window.scrollX + document.documentElement.clientWidth - dayPop.offsetWidth - 8);
dayPop.style.left = Math.max(8, left) + "px";
dayPop.style.top = top + "px";
setTimeout(function () { document.addEventListener("mousedown", onDocDown, true); }, 0);
}
(function () {
const cal = document.querySelector(".pj-cal");
if (cal) cal.addEventListener("click", function (e) {
const more = e.target.closest(".pj-bar-more");
if (more) openDayPopover(more.dataset.date, more);
});
})();
// ── 타임라인 (vis-timeline) — 프로젝트별 행(그룹), 행마다 전체 일정 배경 + 업무 막대 ──
function renderTimeline() {
const node = el("pj-timeline");
if (timeline) { timeline.destroy(); timeline = null; }
const dated = visibleTasks().filter(function (t) { return t.start_date || t.due_date; });
if (!dated.length) { node.innerHTML = '<div class="pj-empty">기간이 설정된 업무가 없습니다.</div>'; return; }
// 프로젝트 메타(시작/마감/색) 조회용
const projById = {};
projectsMeta.forEach(function (p) { projById[p.id] = p; });
// "이 프로젝트" 스코프는 세션별로 행을 나눈다(아사나 타임라인=섹션 스윔레인).
// "전체 프로젝트"는 예전처럼 프로젝트별로 나눈다(세션이 프로젝트마다 달라
// 하나로 묶을 수 없다).
const groupBySession = viewScope === "project";
const byGroup = {};
dated.forEach(function (t) {
const gid = groupBySession ? String(t.stage_id || "none") : String(t.project_id);
(byGroup[gid] = byGroup[gid] || []).push(t);
});
let groupOrder = Object.keys(byGroup);
if (groupBySession) {
const sessionOrder = (stagesByProject[projectId] || []).slice()
.sort(function (a, b) { return a.sort_order - b.sort_order; })
.map(function (s) { return String(s.id); });
groupOrder = sessionOrder.filter(function (id) { return byGroup[id]; });
if (byGroup.none) groupOrder.push("none");
}
const items = [];
const groupRows = [];
groupOrder.forEach(function (gid) {
const list = byGroup[gid];
const grp = (groupBySession ? "S" : "P") + gid;
const meta = groupBySession ? {} : (projById[gid] || {});
const groupColor = list[0].project_color || meta.color || "#4573d2";
const groupName = groupBySession
? (gid === "none" ? "미지정" : (list[0].stage_name || "세션"))
: (list[0].project_name || meta.name || "프로젝트");
groupRows.push({ id: grp, content: escapeHtml(groupName) });
let minMs = Infinity, maxMs = -Infinity;
list.forEach(function (t) {
const s = t.start_date || t.due_date;
const e = t.due_date || t.start_date;
const startISO = s + (t.start_time ? "T" + t.start_time : "");
const endISO = e + (t.due_time ? "T" + t.due_time : "");
const isRange = (e !== s) || (!!t.start_time && !!t.due_time && t.start_time !== t.due_time);
items.push({
// vis 가 HTML 을 정화(sanitize)하므로 아이콘 폰트 대신 이모지로 댓글 표시.
id: t.id, group: grp,
content: (t.title || "") + (t.comment_count ? " 💬" + t.comment_count : ""),
start: startISO, end: isRange ? endISO : undefined,
type: isRange ? "range" : "point",
style: "background-color:" + (t.completed_at ? "#9aa5b1" : groupColor) + ";color:#fff;border:none;",
});
const sm = new Date(startISO).getTime();
const em = new Date(endISO).getTime();
if (sm < minMs) minMs = sm;
if (em > maxMs) maxMs = em;
});
// 배경(전체 일정) = 프로젝트 모드일 때만 프로젝트 자체 시작/마감일 우선,
// 세션 모드는 세션에 자체 기간이 없으므로 업무 최소~최대만 쓴다.
let bgStart = (!groupBySession && meta.start_date) ? new Date(meta.start_date + "T00:00:00").getTime() : minMs;
let bgEnd = (!groupBySession && meta.due_date) ? new Date(meta.due_date + "T00:00:00").getTime() : maxMs;
// 업무가 그 기간 밖에 있어도 가려지지 않게 범위를 넓힌다.
bgStart = Math.min(bgStart, minMs);
bgEnd = Math.max(bgEnd, maxMs);
bgEnd += 86400000; // 마지막 날 하루를 덮도록 +1일
items.push({
id: "bg" + grp, group: grp, type: "background",
start: new Date(bgStart), end: new Date(bgEnd),
style: "background-color:" + groupColor + "22;",
});
});
const groups = new vis.DataSet(groupRows);
const koWd = function (d) { return ["일", "월", "화", "수", "목", "금", "토"][d.getDay()]; };
const pad2 = function (n) { return ("0" + n).slice(-2); };
// 저장된 보던 구간이 있으면 생성 옵션에 start/end 로 넣어 자동맞춤(autofit)을 막는다.
// (setWindow 로 나중에 바꾸면 초기 autofit 이 rangechanged 로 덮어써 초기화됨)
const saved = loadTLWindow();
const opts = {
stack: true, height: "520px",
horizontalScroll: true, // 마우스 휠 = 좌우 이동(패닝)
verticalScroll: false,
zoomKey: "ctrlKey", // 확대/축소는 Ctrl+휠
margin: { item: 8 },
format: {
minorLabels: function (date, scale) {
const d = new Date(date.valueOf());
switch (scale) {
case "millisecond": case "second": case "minute": case "hour":
return pad2(d.getHours()) + ":" + pad2(d.getMinutes());
case "weekday": case "day": return d.getDate() + "일 (" + koWd(d) + ")";
case "week": return d.getDate() + "일";
case "month": return (d.getMonth() + 1) + "월";
case "year": return d.getFullYear() + "년";
default: return d.getDate() + "일";
}
},
majorLabels: function (date, scale) {
const d = new Date(date.valueOf());
switch (scale) {
case "millisecond": case "second": case "minute": case "hour":
return (d.getMonth() + 1) + "월 " + d.getDate() + "일 (" + koWd(d) + ")";
case "weekday": case "day": case "week":
return d.getFullYear() + "년 " + (d.getMonth() + 1) + "월";
case "month": return d.getFullYear() + "년";
default: return "";
}
},
},
};
if (saved) { opts.start = new Date(saved.start); opts.end = new Date(saved.end); }
timeline = new vis.Timeline(node, new vis.DataSet(items), groups, opts);
timeline.on("select", function (props) {
if (!canManage || !props.items.length) return;
const t = tasks.find(function (x) { return x.id === props.items[0]; });
if (t) openTaskModal(t);
});
// 사용자가 직접 확대/이동했을 때만 저장(autofit 등 프로그램 변경은 무시).
timeline.on("rangechanged", function (p) { if (p.byUser) saveTLWindow(p.start, p.end); });
}
function loadTLWindow() {
try {
const r = JSON.parse(localStorage.getItem("pj_tl_window") || "null");
return (r && r.start && r.end) ? r : null;
} catch (_) { return null; }
}
function saveTLWindow(s, e) {
try {
localStorage.setItem("pj_tl_window",
JSON.stringify({ start: new Date(s).toISOString(), end: new Date(e).toISOString() }));
} catch (_) {}
}
// ── 보드 (칸반 = 세션) ──
// scope==="project" → 이 프로젝트의 실제 세션(id 기준) + 세션 관리(이름변경·
// 완료토글·삭제·추가·드래그 재정렬).
// scope==="all" → 여러 프로젝트를 세션 '이름' 기준으로 합쳐 보여준다(기존
// 동작 그대로 — 세션 하나를 특정할 수 없어 관리 컨트롤은 없다).
// "+ 세션 추가" 버튼은 보드(스크롤 영역) 밖 고정 위치에 있어 세션이
// 늘어나 스크롤이 생겨도 항상 보인다 — 한 번만 wiring, 표시 여부만 매
// 렌더마다 토글.
const boardAddStageBtn = el("pj-board-addstage");
if (boardAddStageBtn) {
boardAddStageBtn.addEventListener("click", async function () {
const name = prompt("새 세션 이름");
if (!name || !name.trim()) return;
try {
await api("POST", "/project/api/projects/" + projectId + "/stages", { name: name.trim() });
await reloadStagesForCurrentProject();
renderBoard();
} catch (e) { alert("추가 실패: " + e.message); }
});
}
function renderBoard() {
// pj-board(바깥)는 overflow-y:auto 스크롤 뷰포트, pj-board-canvas(안쪽)가
// 실제 매이슨리 절대배치 캔버스 — 스크롤 컨테이너와 콘텐츠 높이 계산을
// 분리해야 pj-board 의 높이가 부모(flex) 크기를 그대로 따라간다.
const canvas = el("pj-board-canvas");
canvas.innerHTML = "";
const vis = visibleTasks();
if (boardAddStageBtn) boardAddStageBtn.hidden = !(viewScope === "project" && canManage);
if (viewScope === "project") renderBoardByStage(canvas, vis);
else renderBoardByName(canvas, vis);
layoutMasonry(canvas);
}
// 세션(보드 컬럼) 매이슨리 배치 — 열은 왼쪽부터 순서대로 고정 배정하고,
// 각 열은 자기 안에 쌓인 카드 높이만큼만 다음 세션을 그 밑에 이어붙인다
// (CSS Grid처럼 행 전체를 가장 큰 세션에 맞춰 늘리지 않는다).
function layoutMasonry(board) {
const items = Array.prototype.slice.call(board.children).filter(function (node) {
return node.classList.contains("pj-col");
});
if (!items.length) { board.style.height = "auto"; return; }
const gap = 16, minColWidth = 220, maxCols = 4;
const boardWidth = board.clientWidth || 1;
const cols = Math.max(1, Math.min(maxCols, Math.floor((boardWidth + gap) / (minColWidth + gap))));
const colWidth = (boardWidth - gap * (cols - 1)) / cols;
const colHeights = new Array(cols).fill(0);
items.forEach(function (node, i) {
const c = i % cols;
node.style.width = colWidth + "px";
node.style.left = (c * (colWidth + gap)) + "px";
node.style.top = colHeights[c] + "px";
colHeights[c] += node.offsetHeight + gap;
});
board.style.height = Math.max.apply(null, colHeights) + "px";
}
let boardResizeTimer = null;
window.addEventListener("resize", function () {
if (currentView !== "board") return;
clearTimeout(boardResizeTimer);
boardResizeTimer = setTimeout(function () { layoutMasonry(el("pj-board-canvas")); }, 120);
});
function renderBoardByName(board, vis) {
// 컬럼 이름 = 서버가 정렬해준 전체 세션 이름 + 세션 미지정 업무용 '미지정'
let names = boardStages.slice();
const hasUnstaged = vis.some(function (t) { return !t.stage_name; });
if (hasUnstaged) names.push("미지정");
names.forEach(function (name) {
const col = document.createElement("div");
col.className = "pj-col";
col.dataset.stageName = name;
col.innerHTML =
'<div class="pj-col-head"><span class="pj-col-name">' + escapeHtml(name) + "</span>" +
'<span class="pj-col-count"></span></div>' +
'<div class="pj-col-body"></div>';
const body = col.querySelector(".pj-col-body");
const colTasks = vis.filter(function (t) {
return name === "미지정" ? !t.stage_name : (t.stage_name || "") === name;
});
col.querySelector(".pj-col-count").textContent = colTasks.length;
colTasks.forEach(function (t) { body.appendChild(taskCard(t)); });
if (canManage && name !== "미지정") enableDropByName(body, name);
board.appendChild(col);
});
}
// 보드 드롭(전체보기) — 컬럼 이름(세션명)으로 옮긴다. 업무가 속한 '그
// 프로젝트'의 동일 이름 세션 id 를 찾아 적용(프로젝트마다 세션 id 가 다르다).
function enableDropByName(body, name) {
body.addEventListener("dragover", function (e) {
if (!e.dataTransfer.types.includes("text/plain")) return;
e.preventDefault(); body.classList.add("is-over");
});
body.addEventListener("dragleave", function () { body.classList.remove("is-over"); });
body.addEventListener("drop", async function (e) {
if (!e.dataTransfer.types.includes("text/plain")) return;
e.preventDefault();
body.classList.remove("is-over");
const taskId = parseInt(e.dataTransfer.getData("text/plain"), 10);
const t = tasks.find(function (x) { return x.id === taskId; });
if (!t || (t.stage_name || "") === name) return;
const plist = stagesByProject[t.project_id] || [];
const target = plist.find(function (s) { return s.name === name; });
if (!target) { alert("'" + (t.project_name || "이 프로젝트") + "' 에는 '" + name + "' 세션이 없습니다."); return; }
try {
const res = await api("PUT", "/project/api/tasks/" + taskId, { stage_id: target.id });
mergeTask(res.task);
renderBoard();
} catch (err) { alert("이동 실패: " + err.message); }
});
}
// ── 보드(프로젝트 스코프) — 세션은 이 프로젝트의 실제 행(id)이라 관리 UI를
// 붙일 수 있다: 이름변경·완료토글·삭제(만든 사람만)·추가·드래그 재정렬.
function renderBoardByStage(board, vis) {
const list = (stagesByProject[projectId] || []).slice()
.sort(function (a, b) { return a.sort_order - b.sort_order; });
// 세션은 프로젝트를 만들 때 자동으로 생기지 않는다(아사나처럼 직접 추가) —
// 아직 하나도 없으면 안내만 보여준다.
if (!list.length && !vis.length) {
const empty = document.createElement("div");
empty.className = "pj-empty";
empty.style.gridColumn = "1 / -1";
empty.textContent = "세션이 없습니다. 위 \"+ 세션 추가\"로 만들어 보세요.";
board.appendChild(empty);
}
list.forEach(function (s) {
const col = document.createElement("div");
col.className = "pj-col";
col.dataset.stageId = s.id;
col.innerHTML =
'<div class="pj-col-head"' + (canManage ? ' draggable="true"' : "") + '>' +
'<span class="pj-col-name">' + escapeHtml(s.name) + "</span>" +
(s.is_done_stage ? '<span class="pj-chip pj-chip-sm">완료</span>' : "") +
'<span class="pj-col-count"></span>' +
(canManage
? '<span class="pj-col-ctrls">' +
'<button type="button" class="pj-icon-btn pj-col-rename" title="이름 변경">✎</button>' +
'<button type="button" class="pj-icon-btn pj-col-toggledone" title="완료 세션 지정/해제">' +
(s.is_done_stage ? "☑" : "☐") + "</button>" +
'<button type="button" class="pj-icon-btn pj-col-del" title="세션 삭제">×</button>' +
"</span>"
: "") +
"</div><div class=\"pj-col-body\"></div>";
const body = col.querySelector(".pj-col-body");
const colTasks = vis.filter(function (t) { return t.stage_id === s.id; });
col.querySelector(".pj-col-count").textContent = colTasks.length;
colTasks.forEach(function (t) { body.appendChild(taskCard(t)); });
if (canManage) {
enableDropByStageId(body, s.id);
wireColumnControls(col, s);
wireColumnDrag(col, s);
}
board.appendChild(col);
});
const hasUnstaged = vis.some(function (t) { return !t.stage_id; });
if (hasUnstaged) {
const col = document.createElement("div");
col.className = "pj-col";
col.dataset.stageName = "미지정";
col.innerHTML =
'<div class="pj-col-head"><span class="pj-col-name">미지정</span>' +
'<span class="pj-col-count"></span></div><div class="pj-col-body"></div>';
const body = col.querySelector(".pj-col-body");
const colTasks = vis.filter(function (t) { return !t.stage_id; });
col.querySelector(".pj-col-count").textContent = colTasks.length;
colTasks.forEach(function (t) { body.appendChild(taskCard(t)); });
board.appendChild(col);
}
}
async function reloadStagesForCurrentProject() {
const res = await api("GET", "/project/api/projects/" + projectId + "/stages");
stages = res.stages || [];
stagesByProject[projectId] = stages;
}
function wireColumnControls(col, s) {
const renameBtn = col.querySelector(".pj-col-rename");
if (renameBtn) renameBtn.addEventListener("click", async function (e) {
e.stopPropagation();
const name = prompt("세션 이름", s.name);
if (!name || !name.trim() || name.trim() === s.name) return;
try {
await api("PUT", "/project/api/projects/" + projectId + "/stages/" + s.id, { name: name.trim() });
await reloadStagesForCurrentProject();
renderBoard();
} catch (err) { alert("이름 변경 실패: " + err.message); }
});
const doneBtn = col.querySelector(".pj-col-toggledone");
if (doneBtn) doneBtn.addEventListener("click", async function (e) {
e.stopPropagation();
try {
await api("PUT", "/project/api/projects/" + projectId + "/stages/" + s.id,
{ is_done_stage: !s.is_done_stage });
await reloadStagesForCurrentProject();
renderBoard();
} catch (err) { alert("변경 실패: " + err.message); }
});
const delBtn = col.querySelector(".pj-col-del");
if (delBtn) delBtn.addEventListener("click", async function (e) {
e.stopPropagation();
if (!canDelete(s.created_by)) { alert(DELETE_DENIED); return; }
if (!confirm("'" + s.name + "' 세션을 삭제할까요? 이 세션의 업무는 '미지정'이 됩니다.")) return;
try {
await api("DELETE", "/project/api/projects/" + projectId + "/stages/" + s.id);
await reloadStagesForCurrentProject();
renderBoard();
} catch (err) { alert("삭제 실패: " + err.message); }
});
}
// 세션(컬럼) 드래그 재정렬 — 카드 드래그와 같은 네이티브 HTML5 DnD 방식이되,
// dataTransfer 타입을 달리 써서(application/x-pj-stage) 카드 드롭과 안 섞인다.
function wireColumnDrag(col, s) {
const head = col.querySelector(".pj-col-head");
if (!head || head.getAttribute("draggable") !== "true") return;
head.addEventListener("dragstart", function (e) {
e.dataTransfer.setData("application/x-pj-stage", String(s.id));
e.dataTransfer.effectAllowed = "move";
col.classList.add("is-dragging");
});
head.addEventListener("dragend", function () { col.classList.remove("is-dragging"); });
col.addEventListener("dragover", function (e) {
if (!e.dataTransfer.types.includes("application/x-pj-stage")) return;
e.preventDefault();
col.classList.add("pj-col-drop-target");
});
col.addEventListener("dragleave", function () { col.classList.remove("pj-col-drop-target"); });
col.addEventListener("drop", async function (e) {
if (!e.dataTransfer.types.includes("application/x-pj-stage")) return;
e.preventDefault();
col.classList.remove("pj-col-drop-target");
const draggedId = parseInt(e.dataTransfer.getData("application/x-pj-stage"), 10);
if (!draggedId || draggedId === s.id) return;
const list = (stagesByProject[projectId] || []).slice()
.sort(function (a, b) { return a.sort_order - b.sort_order; });
const ids = list.map(function (x) { return x.id; });
const from = ids.indexOf(draggedId), to = ids.indexOf(s.id);
if (from < 0 || to < 0) return;
ids.splice(from, 1);
ids.splice(to, 0, draggedId);
try {
const res = await api("PUT", "/project/api/projects/" + projectId + "/stages/order",
{ ordered_ids: ids });
stages = res.stages || [];
stagesByProject[projectId] = stages;
renderBoard();
} catch (err) { alert("순서 변경 실패: " + err.message); }
});
}
// 보드 드롭(프로젝트 스코프) — 세션 id 로 바로 옮긴다. 이 프로젝트가 원
// 소속이 아닌(멀티호밍된) 업무는 원본을 안 건드리고 "이 프로젝트 안에서의
// 세션"만 연결(POST .../links)로 바꾼다.
function enableDropByStageId(body, stageId) {
body.addEventListener("dragover", function (e) {
if (!e.dataTransfer.types.includes("text/plain")) return;
e.preventDefault(); body.classList.add("is-over");
});
body.addEventListener("dragleave", function () { body.classList.remove("is-over"); });
body.addEventListener("drop", async function (e) {
if (!e.dataTransfer.types.includes("text/plain")) return;
e.preventDefault();
body.classList.remove("is-over");
const taskId = parseInt(e.dataTransfer.getData("text/plain"), 10);
const t = tasks.find(function (x) { return x.id === taskId; });
if (!t || t.stage_id === stageId) return;
try {
if (t.project_id === projectId) {
const res = await api("PUT", "/project/api/tasks/" + taskId, { stage_id: stageId });
mergeTask(res.task);
} else {
await api("POST", "/project/api/tasks/" + taskId + "/links",
{ project_id: projectId, stage_id: stageId });
t.stage_id = stageId;
t.is_linked = true;
}
renderBoard();
} catch (err) { alert("이동 실패: " + err.message); }
});
}
function taskCard(t) {
const card = document.createElement("div");
card.className = "pj-card" + (t.completed_at ? " is-done" : "");
card.draggable = canManage;
card.dataset.taskId = t.id;
// 전체보기는 항상 원 프로젝트 배지를 보여준다. 프로젝트 스코프에서는
// "이 프로젝트가 원 소속"인 카드는 굳이 표시하지 않고, 멀티호밍으로
// 들어온(다른 프로젝트가 원 소속인) 카드만 아사나처럼 배지를 붙인다.
const showProjTag = viewScope === "all" || t.project_id !== projectId;
card.innerHTML =
(showProjTag && t.project_name
? '<div class="pj-card-proj" style="color:' + (t.project_color || "#4573d2") + '">● ' + escapeHtml(t.project_name) + "</div>"
: "") +
'<div class="pj-card-title">' + escapeHtml(t.title) + "</div>" +
'<div class="pj-card-meta">' +
(t.priority && t.priority !== "normal"
? '<span class="pj-pri pj-pri-' + t.priority + '">' + (priorityLabels[t.priority] || t.priority) + "</span>"
: "") +
(t.assignee_email
? '<span class="pj-assignee">' + avatarHtml(t.assignee_email, "pj-gicon-sm") +
escapeHtml(idOf(t.assignee_email)) + "</span>"
: "") +
(t.subtask_total
? '<span class="pj-card-subtasks" title="하위 업무">' + (t.subtask_done || 0) + "/" + t.subtask_total + "</span>"
: "") +
(t.due_date ? '<span class="pj-card-due">' + t.due_date + "</span>" : "") +
(t.comment_count ? '<span class="pj-card-cmt">' + cmtBadge(t) + "</span>" : "") +
"</div>";
card.addEventListener("click", function () { if (canManage) openTaskModal(t); });
if (canManage) {
card.addEventListener("dragstart", function (e) {
e.dataTransfer.setData("text/plain", String(t.id));
card.classList.add("is-dragging");
});
card.addEventListener("dragend", function () { card.classList.remove("is-dragging"); });
}
return card;
}
// ── 리스트 (정렬·컬럼 너비 조절) ──
// "전체 프로젝트" 스코프 — 세션이 프로젝트마다 달라 그룹화 의미가 없으므로
// 예전처럼 프로젝트·세션을 컬럼으로 둔 평범한 정렬 테이블.
const LIST_COLS_ALL = [
{ key: "project", label: "프로젝트", text: function (t) { return t.project_name || "-"; }, sort: function (t) { return (t.project_name || "").toLowerCase(); } },
{ key: "title", label: "업무", text: function (t) { return t.title; }, sort: function (t) { return (t.title || "").toLowerCase(); } },
{ key: "assignee", label: "담당자", text: function (t) { return t.assignee_email ? idOf(t.assignee_email) : "-"; }, sort: function (t) { return idOf(t.assignee_email || ""); } },
{ key: "stage", label: "세션", text: function (t) { return t.stage_name || "-"; }, sort: function (t) { return t.stage_name || ""; } },
{ key: "priority", label: "우선순위", text: function (t) { return priorityLabels[t.priority] || t.priority || "-"; }, sort: function (t) { return ({ low: 0, normal: 1, high: 2 })[t.priority]; } },
{ key: "start", label: "시작", text: function (t) { return t.start_date || "-"; }, sort: function (t) { return t.start_date || ""; } },
{ key: "due", label: "마감", text: function (t) { return t.due_date || "-"; }, sort: function (t) { return t.due_date || ""; } },
];
// "이 프로젝트" 스코프 — 아사나 리스트 뷰처럼 세션이 컬럼이 아니라 그룹
// 구분선이 된다(renderListGrouped). 그래서 세션 컬럼은 뺀다.
const LIST_COLS_PROJECT = LIST_COLS_ALL.filter(function (c) { return c.key !== "project" && c.key !== "stage"; });
function listCols() { return viewScope === "project" ? LIST_COLS_PROJECT : LIST_COLS_ALL; }
let listSort = { key: null, dir: 1 };
function listWidths() { try { return JSON.parse(localStorage.getItem("pj_list_widths") || "{}"); } catch (_) { return {}; } }
function saveListWidth(key, w) { const m = listWidths(); m[key] = w; try { localStorage.setItem("pj_list_widths", JSON.stringify(m)); } catch (_) {} }
function enableColResize(th, key) {
const handle = th.querySelector(".pj-th-resize");
if (!handle) return;
handle.addEventListener("mousedown", function (e) {
e.preventDefault(); e.stopPropagation();
const startX = e.clientX, startW = th.offsetWidth;
function mv(ev) { th.style.width = Math.max(60, startW + (ev.clientX - startX)) + "px"; }
function up() { document.removeEventListener("mousemove", mv); document.removeEventListener("mouseup", up); saveListWidth(key, th.offsetWidth); }
document.addEventListener("mousemove", mv);
document.addEventListener("mouseup", up);
});
}
function sortRows(rows, cols) {
if (!listSort.key) return rows;
const col = cols.find(function (c) { return c.key === listSort.key; });
if (!col) return rows;
rows.sort(function (a, b) {
let va = col.sort(a), vb = col.sort(b);
if (va == null) va = -Infinity;
if (vb == null) vb = -Infinity;
if (va < vb) return -listSort.dir;
if (va > vb) return listSort.dir;
return 0;
});
return rows;
}
function renderListHead(table, cols) {
const widths = listWidths();
const trh = document.createElement("tr");
cols.forEach(function (col) {
const th = document.createElement("th");
th.dataset.key = col.key;
if (widths[col.key]) th.style.width = widths[col.key] + "px";
const arrow = listSort.key === col.key ? (listSort.dir > 0 ? "▲" : "▼") : "↕";
th.innerHTML =
'<span class="pj-th-label">' + escapeHtml(col.label) +
' <span class="pj-th-sort">' + arrow + "</span></span>" +
'<span class="pj-th-resize"></span>';
th.querySelector(".pj-th-label").addEventListener("click", function () {
if (listSort.key === col.key) listSort.dir = -listSort.dir;
else { listSort.key = col.key; listSort.dir = 1; }
renderList();
});
enableColResize(th, col.key);
trh.appendChild(th);
});
const thead = table.querySelector("thead");
thead.innerHTML = "";
thead.appendChild(trh);
}
function taskRow(t, cols) {
const tr = document.createElement("tr");
if (t.completed_at) tr.className = "is-done";
// 행 배경 = 프로젝트 색 연하게(10%) — 다른 프로젝트에서 온(멀티호밍) 업무를
// 구분하기 위함. 이 프로젝트 고유 업무는 색이 다 같아 사실상 안 보인다.
const pc = t.project_color || "#4573d2";
tr.style.background = pc + "14";
tr.innerHTML = cols.map(function (c) {
let inner = escapeHtml(String(c.text(t)));
if (c.key === "title") inner += cmtBadge(t);
if (c.key === "assignee" && t.assignee_email) {
inner = '<span class="pj-assignee">' + avatarHtml(t.assignee_email, "pj-gicon-sm") + escapeHtml(idOf(t.assignee_email)) + "</span>";
}
return "<td>" + inner + "</td>";
}).join("");
if (canManage) { tr.style.cursor = "pointer"; tr.addEventListener("click", function () { openTaskModal(t); }); }
return tr;
}
function listCollapsedSet() {
try { return new Set(JSON.parse(localStorage.getItem("pj_list_collapsed") || "[]")); }
catch (_) { return new Set(); }
}
function toggleListCollapsed(key) {
const s = listCollapsedSet();
if (s.has(key)) s.delete(key); else s.add(key);
try { localStorage.setItem("pj_list_collapsed", JSON.stringify(Array.from(s))); } catch (_) {}
}
function renderList() {
const table = el("pj-list");
table.classList.add("pj-table-fixed");
const cols = listCols();
renderListHead(table, cols);
const tbody = table.querySelector("tbody");
tbody.innerHTML = "";
if (viewScope !== "project") {
sortRows(visibleTasks(), cols).forEach(function (t) { tbody.appendChild(taskRow(t, cols)); });
return;
}
// ── 아사나 리스트 뷰처럼 세션을 그룹 구분선으로 ──
renderListGrouped(tbody, cols);
}
function renderListGrouped(tbody, cols) {
const vis = visibleTasks();
const sessions = (stagesByProject[projectId] || []).slice()
.sort(function (a, b) { return a.sort_order - b.sort_order; });
const collapsed = listCollapsedSet();
const colSpan = cols.length;
function addGroup(key, name, groupTasks) {
const head = document.createElement("tr");
head.className = "pj-list-group-row";
const isCollapsed = collapsed.has(key);
const td = document.createElement("td");
td.colSpan = colSpan;
td.className = "pj-list-group-head";
td.innerHTML =
'<span class="pj-list-group-caret material-symbols-outlined">' +
(isCollapsed ? "chevron_right" : "expand_more") + "</span>" +
'<span class="pj-list-group-name">' + escapeHtml(name) + "</span>" +
'<span class="pj-list-group-count">' + groupTasks.length + "</span>";
td.addEventListener("click", function () { toggleListCollapsed(key); renderList(); });
head.appendChild(td);
tbody.appendChild(head);
if (!isCollapsed) sortRows(groupTasks.slice(), cols).forEach(function (t) { tbody.appendChild(taskRow(t, cols)); });
}
sessions.forEach(function (s) {
addGroup("s" + s.id, s.name, vis.filter(function (t) { return t.stage_id === s.id; }));
});
const unstaged = vis.filter(function (t) { return !t.stage_id; });
if (unstaged.length) addGroup("none", "미지정", unstaged);
if (!sessions.length && !unstaged.length) {
const tr = document.createElement("tr");
const td = document.createElement("td");
td.colSpan = colSpan;
td.className = "pj-empty-sm";
td.textContent = "세션이 없습니다.";
tr.appendChild(td);
tbody.appendChild(tr);
}
}
function mergeTask(task) {
const i = tasks.findIndex(function (x) { return x.id === task.id; });
if (i >= 0) tasks[i] = Object.assign({}, tasks[i], task);
else tasks.push(task);
}
function refreshActiveView() {
// 모든 뷰 + 좌측 트리 클라이언트 재렌더(새로고침 없음).
renderView(currentView);
if (typeof renderTree === "function") renderTree();
}
// ── 업무 모달 ──
const taskModal = el("pj-modal-task");
let currentTaskId = null;
const descEditor = initDescEditor();
const subtaskBlock = initSubtaskBlock();
const linksBlock = initLinksBlock();
if (descEditor) {
descEditor.setOnImageRemoved(function () {
const tid = el("pj-t-id").value;
if (tid) loadAttachments(tid);
});
}
// 닫을 때(배경 클릭·Esc·취소 버튼) 수정한 내용이 있으면 저장할지 물어본다.
// 하위업무/첨부/댓글/연결은 각자 즉시 저장돼서 여기서 추적할 필요 없다 —
// "저장" 버튼이 실제로 보내는 필드만 스냅샷으로 비교.
let taskSnapshot = null;
function taskFormSnapshot() {
return JSON.stringify({
title: el("pj-t-title").value,
assignee: el("pj-t-assignee").value,
stage: el("pj-t-stage").value,
priority: el("pj-t-priority").value,
start: el("pj-t-start").value,
due: el("pj-t-due").value,
usetime: el("pj-t-usetime").checked,
stime: el("pj-t-stime").value,
dtime: el("pj-t-dtime").value,
desc: descEditor ? descEditor.getHtml() : "",
});
}
function isTaskDirty() {
return taskSnapshot !== null && taskFormSnapshot() !== taskSnapshot;
}
let taskClosing = false;
async function attemptCloseTaskModal() {
// erpConfirm 도 Esc 로 닫히는데 stopPropagation 을 안 해서, 확인창이
// 뜬 상태에서 Esc 를 누르면 이 함수가 다시 불릴 수 있다 — 재진입 방지.
if (taskModal.hidden || taskClosing) return;
taskClosing = true;
try {
if (isTaskDirty()) {
const save = await window.erpConfirm(
"수정한 내용이 있습니다. 저장하시겠습니까?",
{ okText: "저장", cancelText: "저장 안 함" }
);
if (save) { el("pj-save-task").click(); return; }
}
taskSnapshot = null;
closeModal(taskModal);
} finally { taskClosing = false; }
}
taskModal.addEventListener("click", function (e) {
if (e.target === taskModal || e.target.hasAttribute("data-close")) attemptCloseTaskModal();
});
document.addEventListener("keydown", function (e) {
if (e.key === "Escape" && !taskModal.hidden) { e.preventDefault(); attemptCloseTaskModal(); }
});
async function loadTaskExtras(task) {
if (!task || (!subtaskBlock && !linksBlock)) return;
try {
const res = await api("GET", "/project/api/tasks/" + task.id);
const detail = res.task;
if (subtaskBlock) {
subtaskBlock.setContext({ taskId: task.id, projectId: task.project_id, canDelete: canDelete });
subtaskBlock.render(detail.subtasks || []);
}
if (linksBlock) {
linksBlock.setContext({
taskId: task.id, projectId: task.project_id,
onChange: function () { loadTaskExtras(task); },
});
linksBlock.render(detail.links || []);
}
} catch (_) { /* 조용히 무시 — 본편집은 계속 가능해야 한다 */ }
}
// 단계 select 를 해당 업무가 속한 프로젝트의 단계로 채운다(전체 뷰에서 타 프로젝트 업무 편집 대비).
function fillStageSelect(projId) {
const sel = el("pj-t-stage");
const list = (projId != null && stagesByProject[projId]) ? stagesByProject[projId] : stages;
sel.innerHTML = list.map(function (s) {
return '<option value="' + s.id + '">' + escapeHtml(s.name) + "</option>";
}).join("");
}
// 담당자 select — 업무가 현재 로드된 프로젝트가 아니면 그 프로젝트 멤버를 새로 불러온다
// (전체 뷰/좌측 트리에서 타 프로젝트 업무를 열 때 담당자 옵션이 비어 보이는 문제 방지).
async function fillAssigneeSelect(projId, currentEmail) {
const sel = el("pj-t-assignee");
let list = members;
if (projId != null && projId !== projectId) {
try { list = (await api("GET", "/project/api/projects/" + projId + "/members")).members || []; }
catch (_) { list = []; }
}
sel.innerHTML = '<option value="">(미지정)</option>' + list.map(function (m) {
const name = idOf(m.user_email);
return '<option value="' + m.user_email + '" data-name="' + escapeHtml(name) + '">' + escapeHtml(name) + "</option>";
}).join("");
sel.value = currentEmail || "";
}
function openTaskModal(task) {
const projName = task ? (task.project_name || currentProject.name || "") : (currentProject.name || "");
el("pj-task-modal-title").textContent = "[" + projName + "]의 " + (task ? "업무 편집" : "새 업무");
el("pj-t-id").value = task ? task.id : "";
el("pj-t-title").value = task ? task.title : "";
if (descEditor) { descEditor.setTaskId(task ? task.id : null); descEditor.setHtml(task ? (task.description || "") : ""); }
fillAssigneeSelect(task ? task.project_id : projectId, task ? (task.assignee_email || "") : "");
fillStageSelect(task ? task.project_id : projectId);
el("pj-t-stage").value = task && task.stage_id ? task.stage_id : (stages[0] ? stages[0].id : "");
el("pj-t-priority").value = task ? (task.priority || "normal") : "normal";
el("pj-t-start").value = task ? (task.start_date || "") : "";
el("pj-t-due").value = task ? (task.due_date || "") : "";
// 시간 — 값이 있으면 '시간 지정' 체크 + 입력란 표시
const useTime = !!(task && (task.start_time || task.due_time));
el("pj-t-usetime").checked = useTime;
el("pj-t-stime").value = task ? (task.start_time || "") : "";
el("pj-t-dtime").value = task ? (task.due_time || "") : "";
el("pj-t-stime").disabled = !useTime;
el("pj-t-dtime").disabled = !useTime;
el("pj-delete-task").hidden = !task;
el("pj-toggle-complete").hidden = !task;
if (task) setCompleteBtn(!!task.completed_at);
// 댓글·첨부·하위업무·연결은 기존 업무 편집 시에만
currentTaskId = task ? task.id : null;
el("pj-attach-block").hidden = !task;
el("pj-comment-block").hidden = !task;
if (subtaskBlock) el("pj-subtask-block").hidden = !task;
if (linksBlock) el("pj-links-block").hidden = !task;
if (task) { loadAttachments(task.id); loadComments(task.id); loadTaskExtras(task); }
openModal(taskModal);
taskSnapshot = taskFormSnapshot();
}
function setCompleteBtn(done) {
const btn = el("pj-toggle-complete");
btn.textContent = done ? "↩ 완료 취소" : "✓ 완료로 표시";
btn.classList.toggle("is-done", done);
btn.dataset.completed = done ? "1" : "";
}
el("pj-toggle-complete").addEventListener("click", async function () {
const id = el("pj-t-id").value;
if (!id) return;
const done = el("pj-toggle-complete").dataset.completed === "1";
try {
const res = await api("PUT", "/project/api/tasks/" + id, { completed: !done });
mergeTask(res.task);
setCompleteBtn(!!res.task.completed_at);
refreshActiveView();
} catch (e) { alert("처리 실패: " + e.message); }
});
// ── 첨부파일 ──
function fmtSize(n) {
if (n < 1024) return n + " B";
if (n < 1048576) return (n / 1024).toFixed(1) + " KB";
return (n / 1048576).toFixed(1) + " MB";
}
async function loadAttachments(taskId) {
const ul = el("pj-attach-list");
ul.innerHTML = "<li class='pj-empty-sm'>불러오는 중…</li>";
try {
const res = await api("GET", "/project/api/tasks/" + taskId + "/attachments");
if (!res.attachments.length) { ul.innerHTML = "<li class='pj-empty-sm'>없음</li>"; return; }
ul.innerHTML = "";
res.attachments.forEach(function (a) {
const li = document.createElement("li");
li.className = "pj-attach-item";
li.innerHTML =
'<a href="/project/api/attachments/' + a.id + '/download">' + escapeHtml(a.filename) + "</a>" +
'<span class="pj-attach-size">' + fmtSize(a.size_bytes) + "</span>" +
(canManage ? '<button type="button" class="pj-icon-btn pj-attach-del" data-id="' + a.id + '">×</button>' : "");
ul.appendChild(li);
});
ul.querySelectorAll(".pj-attach-del").forEach(function (b) {
b.addEventListener("click", async function () {
if (!confirm("이 첨부를 삭제할까요?")) return;
try { await api("DELETE", "/project/api/attachments/" + b.dataset.id); loadAttachments(taskId); }
catch (e) { alert("삭제 실패: " + e.message); }
});
});
} catch (e) { ul.innerHTML = "<li class='pj-empty-sm'>불러오기 실패</li>"; }
}
const attachInput = el("pj-attach-input");
if (attachInput) attachInput.addEventListener("change", async function () {
if (!currentTaskId || !attachInput.files.length) return;
const fd = new FormData();
fd.append("file", attachInput.files[0]);
try {
const res = await fetch("/project/api/tasks/" + currentTaskId + "/attachments", { method: "POST", body: fd });
if (!res.ok) { let m = res.statusText; try { m = (await res.json()).detail; } catch (_) {} throw new Error(m); }
attachInput.value = "";
loadAttachments(currentTaskId);
} catch (e) { alert("업로드 실패: " + e.message); }
});
// ── 댓글 ──
async function loadComments(taskId) {
const ul = el("pj-comment-list");
ul.innerHTML = "<li class='pj-empty-sm'>불러오는 중…</li>";
try {
const res = await api("GET", "/project/api/tasks/" + taskId + "/comments");
// 메모리 업무의 댓글 수 동기화(아이콘 표시용)
const tt = tasks.find(function (x) { return String(x.id) === String(taskId); });
if (tt) tt.comment_count = res.comments.length;
if (!res.comments.length) { ul.innerHTML = "<li class='pj-empty-sm'>댓글 없음</li>"; return; }
ul.innerHTML = "";
res.comments.forEach(function (c) {
const li = document.createElement("li");
li.className = "pj-comment-item";
li.innerHTML =
avatarHtml(c.author_email, "pj-gicon-sm") +
'<div class="pj-comment-main"><div class="pj-comment-head">' +
escapeHtml(idOf(c.author_email)) + ' <span class="pj-comment-time">' +
(c.created_at || "").slice(0, 16).replace("T", " ") + "</span></div>" +
'<div class="pj-comment-body"></div></div>' +
'<span class="pj-comment-acts">' +
'<button type="button" class="pj-icon-btn pj-comment-edit" title="수정">✎</button>' +
'<button type="button" class="pj-icon-btn pj-comment-del" title="삭제">×</button>' +
"</span>";
li.querySelector(".pj-comment-body").textContent = c.body; // XSS 안전(textContent)
li.querySelector(".pj-comment-del").addEventListener("click", async function () {
try { await api("DELETE", "/project/api/comments/" + c.id); loadComments(taskId); }
catch (e) { alert("삭제 실패: " + e.message); }
});
li.querySelector(".pj-comment-edit").addEventListener("click", function () {
editComment(li, c, taskId);
});
ul.appendChild(li);
});
} catch (e) { ul.innerHTML = "<li class='pj-empty-sm'>불러오기 실패</li>"; }
}
// 댓글 인라인 수정 — 본문을 textarea 로 교체, 저장/취소.
function editComment(li, c, taskId) {
const main = li.querySelector(".pj-comment-main");
const bodyEl = li.querySelector(".pj-comment-body");
const acts = li.querySelector(".pj-comment-acts");
if (li.querySelector(".pj-comment-edit-box")) return; // 중복 방지
acts.style.visibility = "hidden";
const box = document.createElement("div");
box.className = "pj-comment-edit-box";
const ta = document.createElement("textarea");
ta.rows = 2; ta.value = c.body;
const bar = document.createElement("div");
bar.className = "pj-comment-edit-actions";
const save = document.createElement("button");
save.type = "button"; save.className = "pj-btn pj-btn-primary"; save.textContent = "저장";
const cancel = document.createElement("button");
cancel.type = "button"; cancel.className = "pj-btn"; cancel.textContent = "취소";
bar.appendChild(cancel); bar.appendChild(save);
box.appendChild(ta); box.appendChild(bar);
bodyEl.style.display = "none";
main.appendChild(box);
ta.focus();
cancel.addEventListener("click", function () {
box.remove(); bodyEl.style.display = ""; acts.style.visibility = "";
});
save.addEventListener("click", async function () {
const text = ta.value.trim();
if (!text) { alert("내용을 입력하세요."); return; }
try {
await api("PUT", "/project/api/comments/" + c.id, { body: text });
loadComments(taskId);
} catch (e) { alert("수정 실패: " + e.message); }
});
}
async function sendComment() {
const inp = el("pj-comment-input");
const body = inp.value.trim();
if (!currentTaskId || !body) return;
try {
await api("POST", "/project/api/tasks/" + currentTaskId + "/comments", { body: body });
inp.value = "";
loadComments(currentTaskId);
} catch (e) { alert("등록 실패: " + e.message); }
}
el("pj-comment-send").addEventListener("click", sendComment);
el("pj-comment-input").addEventListener("keydown", function (e) {
if (e.key === "Enter") { e.preventDefault(); sendComment(); }
});
const newTaskBtn = el("pj-new-task");
if (newTaskBtn) newTaskBtn.addEventListener("click", function () { openTaskModal(null); });
// '시간 지정' 체크 → 시간 입력란 활성/비활성
el("pj-t-usetime").addEventListener("change", function () {
el("pj-t-stime").disabled = !this.checked;
el("pj-t-dtime").disabled = !this.checked;
});
// 시간 입력란 클릭 시 즉시 시간 피커 드롭다운 열기
["pj-t-stime", "pj-t-dtime"].forEach(function (id) {
const inp = el(id);
if (inp) inp.addEventListener("click", function () {
try { this.showPicker(); } catch (_) {}
});
});
el("pj-save-task").addEventListener("click", async function () {
if (descEditor && descEditor.isUploading()) { alert("이미지 업로드가 끝나면 저장해주세요."); return; }
const id = el("pj-t-id").value;
const assigneeSel = el("pj-t-assignee");
const assigneeName = assigneeSel.selectedOptions[0] ? (assigneeSel.selectedOptions[0].dataset.name || "") : "";
const useTime = el("pj-t-usetime").checked;
const payload = {
title: el("pj-t-title").value.trim(),
description: descEditor ? descEditor.getHtml() : "",
assignee_email: assigneeSel.value,
assignee_name: assigneeName,
stage_id: parseInt(el("pj-t-stage").value, 10) || null,
priority: el("pj-t-priority").value,
start_date: el("pj-t-start").value || null,
due_date: el("pj-t-due").value || null,
// 시간 미지정(체크 해제) = 종일 → null 전송
start_time: useTime ? (el("pj-t-stime").value || null) : null,
due_time: useTime ? (el("pj-t-dtime").value || null) : null,
};
if (!payload.title) { alert("제목을 입력하세요."); return; }
try {
let res;
if (id) res = await api("PUT", "/project/api/tasks/" + id, payload);
else res = await api("POST", "/project/api/projects/" + projectId + "/tasks", payload);
mergeTask(res.task);
taskSnapshot = null;
closeModal(taskModal);
refreshActiveView();
} catch (e) { alert("저장 실패: " + e.message); }
});
el("pj-delete-task").addEventListener("click", async function () {
const id = el("pj-t-id").value;
if (!id) return;
const t = tasks.find(function (x) { return String(x.id) === String(id); });
if (t && !canDelete(t.created_by)) { alert(DELETE_DENIED); return; }
if (!confirm("이 업무를 삭제할까요?")) return;
try {
await api("DELETE", "/project/api/tasks/" + id);
tasks = tasks.filter(function (x) { return String(x.id) !== String(id); });
closeModal(taskModal);
refreshActiveView();
} catch (e) { alert("삭제 실패: " + e.message); }
});
// ── 프로젝트 트리(사이드바) — 아사나처럼 프로젝트 이름만 나열한다(업무 목록은
// 안 보여준다 — 업무는 가운데 화면에서만 본다). 색상 원(첫 글자) 아이콘.
function treeProjLi(p) {
const del = isAdmin
? '<span class="pj-tree-acts"><button type="button" class="pj-icon-btn pj-tree-del" data-id="' + p.id +
'" data-name="' + escapeHtml(p.name) + '" data-creator="' + escapeHtml(p.created_by || "") + '" title="프로젝트 삭제">×</button></span>'
: "";
const initial = escapeHtml((p.name || "?").trim().charAt(0).toUpperCase() || "?");
return '<li class="pj-tree-li" data-id="' + p.id + '"><div class="pj-tree-row ' +
(p.id === projectId ? "is-active" : "") + '">' +
'<a class="pj-tree-link" href="/project/p/' + p.id + '">' +
'<span class="pj-tree-avatar" style="background:' + (p.color || "#4573d2") + '">' + initial + "</span>" +
'<span class="pj-tree-name">' + escapeHtml(p.name) + "</span></a>" + del + "</div></li>";
}
function wireTreeHandlers(rootUl) {
rootUl.querySelectorAll(".pj-tree-del").forEach(function (b) {
b.addEventListener("click", async function (e) {
e.preventDefault(); e.stopPropagation();
if (!canDelete(b.dataset.creator)) { alert(DELETE_DENIED); return; }
if (!confirm("'" + (b.dataset.name || "") + "' 프로젝트를 삭제할까요? 안의 업무도 모두 삭제됩니다.")) return;
try {
await api("DELETE", "/project/api/projects/" + b.dataset.id);
if (String(b.dataset.id) === String(projectId)) location.href = "/project/";
else location.reload();
} catch (err) { alert("삭제 실패: " + err.message); }
});
});
}
function renderTree() {
const rootUl = el("pj-tree-root");
if (!rootUl) return;
rootUl.innerHTML = projectsMeta.map(treeProjLi).join("");
wireTreeHandlers(rootUl);
}
renderTree();
// 새 최상위 프로젝트 (관리자)
const newProjSide = el("pj-new-project-side");
if (newProjSide) newProjSide.addEventListener("click", async function () {
const name = prompt("새 프로젝트 이름");
if (!name || !name.trim()) return;
const due = prompt("마감일 (YYYY-MM-DD, 없으면 취소 없이 비워두고 확인)") || "";
try {
await api("POST", "/project/api/projects", { name: name.trim(), due_date: due.trim() || null });
location.reload();
} catch (e) { alert("생성 실패: " + e.message); }
});
// ── 멤버 배정/제외 (관리자) ──
const memberModal = el("pj-modal-member");
wireModalClose(memberModal);
const addMemberBtn = el("pj-add-member");
if (addMemberBtn) addMemberBtn.addEventListener("click", async function () {
const sel = el("pj-m-user");
openModal(memberModal);
sel.innerHTML = '<option value="">불러오는 중…</option>';
try {
const res = await api("GET", "/project/api/assignable-users");
const existing = new Set(members.map(function (m) { return m.user_email; }));
const opts = res.users
.filter(function (u) { return !existing.has(u.email); })
.map(function (u) {
return '<option value="' + u.email + '" data-name="' + escapeHtml(u.name) + '">' +
escapeHtml(u.id) + " (" + escapeHtml(u.email) + ")</option>";
});
sel.innerHTML = opts.length
? opts.join("")
: '<option value="">배정 가능한 직원이 없습니다(권한 미부여 또는 전원 배정됨)</option>';
} catch (e) {
sel.innerHTML = '<option value="">불러오기 실패: ' + escapeHtml(e.message) + "</option>";
}
});
const saveMemberBtn = el("pj-save-member");
if (saveMemberBtn) saveMemberBtn.addEventListener("click", async function () {
const sel = el("pj-m-user");
if (!sel.value) { alert("직원을 선택하세요."); return; }
const name = sel.selectedOptions[0] ? (sel.selectedOptions[0].dataset.name || "") : "";
try {
await api("POST", "/project/api/projects/" + projectId + "/members", {
user_email: sel.value, user_name: name, role: el("pj-m-role").value,
});
location.reload();
} catch (e) { alert("배정 실패: " + e.message); }
});
document.querySelectorAll(".pj-del-member").forEach(function (b) {
b.addEventListener("click", async function () {
if (!confirm("이 멤버를 프로젝트에서 제외할까요?")) return;
try {
await api("DELETE", "/project/api/projects/" + projectId + "/members/" + encodeURIComponent(b.dataset.email));
location.reload();
} catch (e) { alert("제외 실패: " + e.message); }
});
});
// 최초 렌더 — 아사나처럼 프로젝트에 들어오면 보드(세션)가 기본 화면이다
// (달력을 보던 중이었다면 그 기억된 뷰를 그린다, 위 VIEW_KEY 복원 참고).
renderView(currentView);
// 사이드 트리에서 업무 클릭(?task=ID)으로 들어오면 해당 업무 모달 자동 열기.
// 단, 처리 후 URL 에서 task 파라미터를 제거한다 — 드래그 이동 후 location.reload()
// 시 이 파라미터가 남아 모달이 다시 뜨는 것을 방지.
const params = new URLSearchParams(location.search);
const taskParam = params.get("task");
if (taskParam) {
params.delete("task");
const qs = params.toString();
history.replaceState({}, "", location.pathname + (qs ? "?" + qs : ""));
const t = tasks.find(function (x) { return String(x.id) === String(taskParam); });
if (t) openTaskModal(t);
}
}
// ════════════════════════════════════════════════════════════
// 업무 모달 공용 확장 — 하위 업무 · 연결된 프로젝트(멀티호밍) · 설명 이미지 편집기
// 홈(index.html)과 프로젝트(project.html) 페이지가 업무 모달의 요소 id 를
// 그대로 공유하므로(각자 모달을 따로 갖지만 id는 같다) 여기 한 번만 만들어
// 양쪽 initHome()/initProject() 에서 그대로 쓴다.
// ════════════════════════════════════════════════════════════
// ── 설명(contenteditable) — 붙여넣기/업로드로 이미지를 끼워 넣는다 ──
function initDescEditor() {
const box = el("pj-t-desc");
if (!box) return null;
const wrap = box.parentElement;
const imgInput = el("pj-desc-img-input");
const hint = el("pj-desc-hint");
let taskId = null;
// ── 서식 도구 모음(간단 워드프로세서) — execCommand 는 구식 API지만
// 크롬/엣지는 여전히 지원하고, 이미 같은 파일의 이미지 삽입에서도 쓰고
// 있어(insertHTML) 일관된 패턴이다. 버튼 mousedown 에서 preventDefault
// 안 하면 클릭 순간 에디터가 포커스를 잃어 선택 범위가 풀린다.
const fmtbar = el("pj-desc-fmtbar");
if (fmtbar) {
fmtbar.addEventListener("mousedown", function (e) { e.preventDefault(); });
fmtbar.addEventListener("click", function (e) {
const btn = e.target.closest(".pj-desc-fmtbtn");
if (!btn) return;
box.focus();
const cmd = btn.dataset.cmd;
let arg = btn.dataset.arg || null;
if (cmd === "createLink") {
arg = prompt("연결할 주소(URL)를 입력하세요", "https://");
if (!arg) return;
}
try { document.execCommand(cmd, false, arg); } catch (_) {}
updateFmtState();
});
function updateFmtState() {
fmtbar.querySelectorAll(".pj-desc-fmtbtn[data-cmd]").forEach(function (btn) {
const cmd = btn.dataset.cmd;
if (cmd === "createLink" || cmd === "removeFormat") return;
let active = false;
try { active = document.queryCommandState(cmd); } catch (_) { active = false; }
btn.classList.toggle("is-active", active);
});
}
box.addEventListener("keyup", updateFmtState);
box.addEventListener("mouseup", updateFmtState);
box.addEventListener("focus", updateFmtState);
}
// ── 설명란 이미지 선택 → 크기 조절 핸들 + 삭제 버튼 오버레이 ──
// 크기는 style이 아니라 width 속성으로 저장한다(서버 sanitize가 style은
// 버리지만 img width/height 속성은 허용 목록에 있어 저장 후에도 유지된다).
const imgTools = document.createElement("div");
imgTools.className = "pj-img-tools";
imgTools.hidden = true;
imgTools.innerHTML =
'<button type="button" class="pj-img-del" title="이미지 삭제">×</button>' +
'<span class="pj-img-handle" title="드래그로 크기 조절"></span>';
wrap.appendChild(imgTools);
const imgDelBtn = imgTools.querySelector(".pj-img-del");
const imgHandle = imgTools.querySelector(".pj-img-handle");
let selectedImg = null;
let onImageRemoved = null;
// 설명란 이미지는 첨부파일 업로드로 들어간 실제 첨부 레코드다 — 에디터에서
// 지우면 첨부파일 목록에도 안 남게 서버 첨부도 같이 삭제한다.
async function deleteSelectedImage() {
if (!selectedImg) return;
const img = selectedImg;
const m = (img.getAttribute("src") || "").match(/\/attachments\/(\d+)\/inline/);
img.remove();
selectImg(null);
if (!m) return;
try {
await api("DELETE", "/project/api/attachments/" + m[1]);
} catch (_) { /* 이미 지워졌을 수 있음 — 에디터에서는 이미 빠졌으니 조용히 무시 */ }
if (onImageRemoved) onImageRemoved();
}
function positionImgTools() {
if (!selectedImg || !selectedImg.isConnected) { selectedImg = null; imgTools.hidden = true; return; }
imgTools.style.left = selectedImg.offsetLeft + "px";
imgTools.style.top = selectedImg.offsetTop + "px";
imgTools.style.width = selectedImg.offsetWidth + "px";
imgTools.style.height = selectedImg.offsetHeight + "px";
imgTools.hidden = false;
}
function selectImg(img) {
if (selectedImg) selectedImg.classList.remove("pj-img-selected");
selectedImg = img || null;
if (selectedImg) { selectedImg.classList.add("pj-img-selected"); positionImgTools(); }
else { imgTools.hidden = true; }
}
box.addEventListener("click", function (e) {
if (e.target.tagName === "IMG") selectImg(e.target);
else selectImg(null);
});
document.addEventListener("click", function (e) {
if (selectedImg && !wrap.contains(e.target)) selectImg(null);
});
imgDelBtn.addEventListener("click", function () { deleteSelectedImage(); });
let dragging = false, dragStartX = 0, dragStartW = 0;
imgHandle.addEventListener("mousedown", function (e) {
if (!selectedImg) return;
e.preventDefault();
dragging = true;
dragStartX = e.clientX;
dragStartW = selectedImg.offsetWidth;
});
document.addEventListener("mousemove", function (e) {
if (!dragging || !selectedImg) return;
let newW = Math.round(dragStartW + (e.clientX - dragStartX));
newW = Math.max(40, Math.min(newW, wrap.clientWidth || 9999));
selectedImg.setAttribute("width", String(newW));
selectedImg.removeAttribute("height");
selectedImg.style.width = "";
selectedImg.style.height = "";
positionImgTools();
});
document.addEventListener("mouseup", function () { dragging = false; });
document.addEventListener("keydown", function (e) {
if (!selectedImg) return;
if (e.key !== "Delete" && e.key !== "Backspace") return;
const active = document.activeElement;
if (active && ["INPUT", "TEXTAREA", "SELECT"].indexOf(active.tagName) >= 0) return;
e.preventDefault();
deleteSelectedImage();
});
// 업로드 버튼 클릭 → 파일 선택창이 뜨는 동안 contenteditable의 선택 범위가
// 풀린다 — 마지막으로 박스 안에 있던 커서 위치를 기억해뒀다가 삽입 직전에
// 되살린다(안 그러면 이미지가 항상 맨 끝에 붙어버린다).
let lastRange = null;
function saveSelection() {
const sel = window.getSelection();
if (sel && sel.rangeCount && box.contains(sel.anchorNode)) {
lastRange = sel.getRangeAt(0).cloneRange();
}
}
box.addEventListener("keyup", saveSelection);
box.addEventListener("mouseup", saveSelection);
box.addEventListener("blur", saveSelection);
function setTaskId(id) {
taskId = id;
const enabled = !!id;
if (imgInput) {
imgInput.disabled = !enabled;
if (imgInput.parentElement) imgInput.parentElement.style.opacity = enabled ? "1" : ".5";
}
if (hint) hint.textContent = enabled ? "" : "저장 후 이미지를 추가할 수 있습니다.";
}
function insertHtmlAtCursor(html) {
box.focus();
const sel = window.getSelection();
if (lastRange && (!sel.rangeCount || !box.contains(sel.anchorNode))) {
try { sel.removeAllRanges(); sel.addRange(lastRange); } catch (_) { lastRange = null; }
}
let inserted = false;
try { inserted = document.execCommand("insertHTML", false, html); } catch (_) { inserted = false; }
if (!inserted) {
if (sel && sel.rangeCount && box.contains(sel.anchorNode)) {
const range = sel.getRangeAt(0);
range.deleteContents();
const frag = range.createContextualFragment(html);
range.insertNode(frag);
range.collapse(false);
} else {
box.innerHTML += html;
}
}
saveSelection();
}
// 삽입 전 원본 크기를 확인해, 설명란보다 넓으면 90%로 줄여서 넣는다
// (그대로 넣으면 크기조절 핸들이 설명란 밖으로 삐져나간다).
function naturalImageSize(file) {
return new Promise(function (resolve) {
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = function () { URL.revokeObjectURL(url); resolve({ width: img.naturalWidth }); };
img.onerror = function () { URL.revokeObjectURL(url); resolve(null); };
img.src = url;
});
}
// 업로드 중엔 저장을 막는다(안 그러면 업로드가 끝나기 전에 저장을 눌러
// 방금 넣은 이미지가 설명에서 통째로 빠진 채 저장되는 경우가 있었다).
let pendingUploads = 0;
function beginUpload() {
pendingUploads++;
if (hint) hint.textContent = "이미지 업로드 중…";
}
function endUpload() {
pendingUploads = Math.max(0, pendingUploads - 1);
if (pendingUploads === 0 && hint) hint.textContent = taskId ? "" : "저장 후 이미지를 추가할 수 있습니다.";
}
async function insertImageFile(file) {
if (!taskId) { alert("먼저 저장한 뒤 이미지를 추가할 수 있습니다."); return; }
// 업로드는 비동기라 그 사이 포커스가 파일선택창 등으로 빠져나가면서
// contenteditable 의 선택범위가 사라지거나(브라우저가 엉뚱한 위치로
// 되살리기도 한다) 커서 위치가 틀어진다 — 업로드 시작 "직전"의 커서를
// 무조건 기억해뒀다가 삽입 시 그대로 복원한다(현재 라이브 셀렉션은
// 신뢰하지 않는다).
saveSelection();
const insertAt = lastRange ? lastRange.cloneRange() : null;
beginUpload();
const fd = new FormData();
fd.append("file", file);
try {
const results = await Promise.all([
naturalImageSize(file),
fetch("/project/api/tasks/" + taskId + "/attachments", { method: "POST", body: fd }),
]);
const dims = results[0], res = results[1];
if (!res.ok) { let m = res.statusText; try { m = (await res.json()).detail; } catch (_) {} throw new Error(m); }
const data = await res.json();
const boxW = box.clientWidth || 0;
const widthAttr = (dims && dims.width && boxW && dims.width > boxW)
? ' width="' + Math.round(boxW * 0.9) + '"' : "";
box.focus();
if (insertAt) {
try { const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(insertAt); } catch (_) {}
}
insertHtmlAtCursor('<img src="/project/api/attachments/' + data.attachment.id + '/inline" alt=""' + widthAttr + '>');
} catch (e) { alert("이미지 업로드 실패: " + e.message); }
finally { endUpload(); }
}
box.addEventListener("paste", function (e) {
const items = (e.clipboardData && e.clipboardData.items) || [];
for (let i = 0; i < items.length; i++) {
if (items[i].type && items[i].type.indexOf("image/") === 0) {
e.preventDefault();
const file = items[i].getAsFile();
if (file) insertImageFile(file);
return;
}
}
// 이미지가 아니면 서식 없는 텍스트로만 붙여넣기(외부 사이트 서식 유입 방지).
e.preventDefault();
const text = (e.clipboardData || window.clipboardData).getData("text/plain");
insertHtmlAtCursor(escapeHtml(text).replace(/\n/g, "<br>"));
});
if (imgInput) imgInput.addEventListener("change", function () {
if (imgInput.files.length) insertImageFile(imgInput.files[0]);
imgInput.value = "";
});
return {
setTaskId: setTaskId,
setHtml: function (html) { selectImg(null); lastRange = null; box.innerHTML = html || ""; },
getHtml: function () { return box.innerHTML; },
isUploading: function () { return pendingUploads > 0; },
setOnImageRemoved: function (fn) { onImageRemoved = fn; },
};
}
// ── 하위 업무(인라인) — 체크박스·제목·담당자·마감일을 한 줄에서 바로 저장 ──
function initSubtaskBlock() {
const listEl = el("pj-subtask-list");
if (!listEl) return null;
const input = el("pj-subtask-input");
const addAssignee = el("pj-subtask-add-assignee");
const addDue = el("pj-subtask-add-due");
const addBtn = el("pj-subtask-add");
let taskId = null, projectId = null, canDeleteFn = function () { return false; }, onChange = null;
async function membersOptionsHtml(pid) {
try {
const res = await api("GET", "/project/api/projects/" + pid + "/members");
return (res.members || []).map(function (m) {
return '<option value="' + m.user_email + '">' + escapeHtml(idOf(m.user_email)) + "</option>";
}).join("");
} catch (_) { return ""; }
}
function rowEl(sub, optionsHtml) {
const li = document.createElement("li");
li.className = "pj-subtask-row" + (sub.completed_at ? " is-done" : "");
li.dataset.id = sub.id;
li.innerHTML =
'<input type="checkbox" class="pj-subtask-done"' + (sub.completed_at ? " checked" : "") + " />" +
'<input type="text" class="pj-subtask-title" />' +
'<select class="pj-subtask-assignee"><option value="">(미지정)</option>' + optionsHtml + "</select>" +
'<input type="date" class="pj-subtask-due" />' +
'<button type="button" class="pj-icon-btn pj-subtask-del" title="삭제" hidden>×</button>';
const titleInp = li.querySelector(".pj-subtask-title");
const doneChk = li.querySelector(".pj-subtask-done");
const assigneeSel = li.querySelector(".pj-subtask-assignee");
const dueInp = li.querySelector(".pj-subtask-due");
const delBtn = li.querySelector(".pj-subtask-del");
titleInp.value = sub.title || "";
assigneeSel.value = sub.assignee_email || "";
dueInp.value = sub.due_date || "";
if (canDeleteFn(sub.created_by)) delBtn.hidden = false;
async function patch(fields) {
try { await api("PUT", "/project/api/tasks/" + sub.id, fields); if (onChange) onChange(); }
catch (e) { alert("저장 실패: " + e.message); }
}
titleInp.addEventListener("change", function () {
const v = titleInp.value.trim();
if (!v) { titleInp.value = sub.title || ""; return; }
sub.title = v; patch({ title: v });
});
doneChk.addEventListener("change", function () {
li.classList.toggle("is-done", doneChk.checked);
patch({ completed: doneChk.checked });
});
assigneeSel.addEventListener("change", function () {
const opt = assigneeSel.selectedOptions[0];
patch({ assignee_email: assigneeSel.value, assignee_name: opt ? opt.textContent : "" });
});
dueInp.addEventListener("change", function () { patch({ due_date: dueInp.value || null }); });
delBtn.addEventListener("click", async function () {
if (!confirm("이 하위 업무를 삭제할까요?")) return;
try {
await api("DELETE", "/project/api/tasks/" + sub.id);
li.remove();
if (onChange) onChange();
} catch (e) { alert("삭제 실패: " + e.message); }
});
return li;
}
async function render(subtasks) {
listEl.innerHTML = subtasks.length ? "" : "<li class='pj-empty-sm'>없음</li>";
if (!subtasks.length) return;
const optionsHtml = projectId ? await membersOptionsHtml(projectId) : "";
listEl.innerHTML = "";
subtasks.forEach(function (sub) { listEl.appendChild(rowEl(sub, optionsHtml)); });
}
if (addBtn) addBtn.addEventListener("click", async function () {
const title = (input.value || "").trim();
if (!title) return;
if (!taskId) { alert("먼저 업무를 저장한 뒤 하위 업무를 추가할 수 있습니다."); return; }
const assigneeOpt = addAssignee ? addAssignee.selectedOptions[0] : null;
try {
const res = await api("POST", "/project/api/projects/" + projectId + "/tasks", {
title: title, parent_task_id: taskId,
assignee_email: addAssignee ? addAssignee.value : "",
assignee_name: assigneeOpt ? assigneeOpt.textContent : "",
due_date: addDue ? (addDue.value || null) : null,
});
input.value = "";
if (addAssignee) addAssignee.value = "";
if (addDue) addDue.value = "";
const optionsHtml = await membersOptionsHtml(projectId);
const empty = listEl.querySelector(".pj-empty-sm");
if (empty) empty.remove();
listEl.appendChild(rowEl(res.task, optionsHtml));
if (onChange) onChange();
} catch (e) { alert("추가 실패: " + e.message); }
});
if (input) input.addEventListener("keydown", function (e) {
if (e.key === "Enter") { e.preventDefault(); if (addBtn) addBtn.click(); }
});
return {
setContext: function (opts) {
taskId = opts.taskId; projectId = opts.projectId;
canDeleteFn = opts.canDelete || function () { return false; };
onChange = opts.onChange || null;
if (addAssignee && projectId) {
membersOptionsHtml(projectId).then(function (html) {
addAssignee.innerHTML = "<option value=''>(미지정)</option>" + html;
});
}
},
render: render,
};
}
// ── 연결된 프로젝트(멀티호밍, 아사나식) — 업무 하나를 다른 프로젝트의 세션에도 연결 ──
function initLinksBlock() {
const chipsEl = el("pj-link-chips");
if (!chipsEl) return null;
const projSel = el("pj-link-project");
const stageSel = el("pj-link-stage");
const saveBtn = el("pj-link-save");
let taskId = null, homeProjectId = null, onChange = null;
function chipEl(link) {
const span = document.createElement("span");
span.className = "pj-link-chip";
span.innerHTML =
'<span style="color:' + (link.project_color || "#4573d2") + '">●</span> ' +
escapeHtml(link.project_name || "") +
(link.stage_name ? " · " + escapeHtml(link.stage_name) : "") +
' <button type="button" class="pj-link-del" title="연결 해제">×</button>';
span.querySelector(".pj-link-del").addEventListener("click", async function () {
if (!confirm("'" + (link.project_name || "") + "' 연결을 해제할까요?")) return;
try {
await api("DELETE", "/project/api/tasks/" + taskId + "/links/" + link.project_id);
span.remove();
if (onChange) onChange();
} catch (e) { alert("해제 실패: " + e.message); }
});
return span;
}
function render(links) {
chipsEl.innerHTML = links.length ? "" : "<span class='pj-empty-sm'>없음</span>";
links.forEach(function (l) { chipsEl.appendChild(chipEl(l)); });
}
async function loadProjects() {
projSel.innerHTML = "<option value=''>불러오는 중…</option>";
stageSel.innerHTML = "<option value=''>프로젝트를 먼저 선택하세요</option>";
try {
const res = await api("GET", "/project/api/projects");
const list = (res.projects || []).filter(function (p) {
return p.id !== homeProjectId && !p.parent_id;
});
projSel.innerHTML = "<option value=''>프로젝트 선택…</option>" + list.map(function (p) {
return '<option value="' + p.id + '">' + escapeHtml(p.name) + "</option>";
}).join("");
} catch (e) { projSel.innerHTML = "<option value=''>불러오기 실패</option>"; }
}
if (projSel) projSel.addEventListener("change", async function () {
const pid = parseInt(projSel.value, 10);
if (!pid) { stageSel.innerHTML = "<option value=''>프로젝트를 먼저 선택하세요</option>"; return; }
stageSel.innerHTML = "<option value=''>불러오는 중…</option>";
try {
const res = await api("GET", "/project/api/projects/" + pid + "/stages");
stageSel.innerHTML = (res.stages || []).map(function (s) {
return '<option value="' + s.id + '">' + escapeHtml(s.name) + "</option>";
}).join("");
} catch (e) { stageSel.innerHTML = "<option value=''>불러오기 실패</option>"; }
});
if (saveBtn) saveBtn.addEventListener("click", async function () {
const pid = parseInt(projSel.value, 10);
if (!pid) { alert("프로젝트를 선택하세요."); return; }
if (!taskId) { alert("먼저 업무를 저장한 뒤 연결할 수 있습니다."); return; }
try {
await api("POST", "/project/api/tasks/" + taskId + "/links",
{ project_id: pid, stage_id: stageSel.value ? parseInt(stageSel.value, 10) : null });
projSel.value = "";
stageSel.innerHTML = "<option value=''>프로젝트를 먼저 선택하세요</option>";
if (onChange) onChange();
} catch (e) { alert("추가 실패: " + e.message); }
});
return {
setContext: function (opts) {
taskId = opts.taskId; homeProjectId = opts.projectId; onChange = opts.onChange || null;
loadProjects();
},
render: render,
};
}
function idOf(email) {
return String(email == null ? "" : email).split("@")[0];
}
function escapeHtml(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/"/g, "&quot;").replace(/'/g, "&#39;");
}
// ── 알림 벨 (미읽음 배지) ──
async function initBell() {
const badge = document.getElementById("pj-bell-badge");
if (!badge) return;
const bell = badge.closest(".pj-bell");
try {
const res = await fetch("/project/api/notifications/unread-count");
if (!res.ok) return;
const d = await res.json();
if (d.unread > 0) {
badge.textContent = d.unread > 99 ? "99+" : d.unread;
badge.hidden = false;
if (bell) bell.classList.add("has-unread"); // 종 흔들림 애니메이션
}
} catch (_) {}
}
// ── 알림센터 페이지 ──
function initInbox() {
const inbox = document.getElementById("pj-inbox");
const readAll = document.getElementById("pj-readall");
if (!inbox && !readAll) return;
if (readAll) readAll.addEventListener("click", async function () {
try { await fetch("/project/api/notifications/read-all", { method: "POST" }); location.reload(); }
catch (e) { alert("실패: " + e.message); }
});
if (inbox) inbox.querySelectorAll(".pj-inbox-item").forEach(function (li) {
li.addEventListener("click", async function (e) {
if (e.target.closest("a")) return; // '이동' 링크는 통과
if (!li.classList.contains("is-unread")) return;
try {
await fetch("/project/api/notifications/" + li.dataset.id + "/read", { method: "POST" });
li.classList.remove("is-unread");
} catch (_) {}
});
});
}
// ── 실시간 새로고침 ──
// 다른 사용자가 프로젝트/업무/댓글/멤버/단계를 추가·수정하면, 같은 화면을 열어둔
// 모든 사용자가 자동으로 새로고침되도록 주기적으로 서버 버전을 확인한다.
// 모달을 열어 입력 중이면 방해하지 않고, 모달이 모두 닫힐 때(closeModal) 반영한다.
function initLiveRefresh() {
if (!document.querySelector(".pj-wrap, .pj-project-view")) return;
let baseline = null;
async function poll() {
try {
const res = await fetch("/project/api/live-version");
if (res.ok) {
const d = await res.json();
if (baseline === null) {
baseline = d.ts;
} else if (d.ts !== baseline) {
if (anyModalOpen()) { pjPendingReload = true; }
else { location.reload(); return; }
}
}
} catch (_) {}
setTimeout(poll, 8000);
}
setTimeout(poll, 8000);
}
// ── "내 업무" 패널 — 프로젝트별 폴더 아이콘 클릭으로 하위 업무 목록
// 펼치기/접기. 홈 화면·프로젝트 상세 화면 둘 다 같은 마크업이라 공용으로
// 한 번만 처리한다(각 initXxx 와 무관, 서버 렌더 마크업 그대로 사용).
function wireMyTreeToggles() {
const KEY = "pj_mytree_collapsed";
let collapsed;
try { collapsed = new Set(JSON.parse(localStorage.getItem(KEY) || "[]")); }
catch (_) { collapsed = new Set(); }
function save() {
try { localStorage.setItem(KEY, JSON.stringify(Array.prototype.slice.call(collapsed))); } catch (_) {}
}
document.querySelectorAll(".pj-mytree-group").forEach(function (group) {
const gid = group.dataset.groupId;
const btn = group.querySelector(".pj-mytree-toggle");
const icon = btn ? btn.querySelector(".material-symbols-outlined") : null;
function apply() {
const isCollapsed = collapsed.has(gid);
group.classList.toggle("is-collapsed", isCollapsed);
if (icon) icon.textContent = isCollapsed ? "folder" : "folder_open";
}
apply();
if (btn) btn.addEventListener("click", function () {
if (collapsed.has(gid)) collapsed.delete(gid); else collapsed.add(gid);
save();
apply();
});
});
}
// ── 항상 보이는 스크롤 막대 ──
// 윈도우/엣지가 오버레이(자동숨김) 스크롤바를 쓰면 네이티브 막대는 마우스를
// 올리거나 스크롤 중일 때만 잠깐 보이고, ::-webkit-scrollbar 커스텀도
// 무시된다(DevTools 박스모델에서 두께 0 확인). 그래서 자체 스크롤하는 패널은
// 네이티브 막대를 CSS 로 숨기고(.pj-vbar-* 참고) 여기서 막대를 직접 그린다.
// 휠·키보드 스크롤은 그대로 동작하고, 막대 드래그/트랙 클릭도 지원한다.
function attachScrollIndicator(scroller) {
if (!scroller || scroller.dataset.pjVbar) return;
scroller.dataset.pjVbar = "1";
const anchor = document.createElement("div");
anchor.className = "pj-vbar-anchor";
anchor.innerHTML = '<div class="pj-vbar-track" hidden><div class="pj-vbar-thumb"></div></div>';
scroller.insertBefore(anchor, scroller.firstChild);
const track = anchor.firstChild;
const thumb = track.firstChild;
let thumbH = 0, thumbTop = 0;
function update() {
const sh = scroller.scrollHeight, ch = scroller.clientHeight;
if (ch <= 0 || sh <= ch + 1) { if (!track.hidden) track.hidden = true; return; }
if (track.hidden) track.hidden = false;
thumbH = Math.max(28, Math.round(ch * ch / sh));
thumbTop = Math.round((ch - thumbH) * scroller.scrollTop / (sh - ch));
// 같은 값이면 안 쓴다 — MutationObserver 가 자기 변경에 반응해 도는 걸 막는다.
const trackH = ch + "px", th = thumbH + "px", tf = "translateY(" + thumbTop + "px)";
if (track.style.height !== trackH) track.style.height = trackH;
if (thumb.style.height !== th) thumb.style.height = th;
if (thumb.style.transform !== tf) thumb.style.transform = tf;
}
scroller.addEventListener("scroll", update, { passive: true });
scroller.addEventListener("load", update, true); // 아바타 이미지 로드로 높이가 바뀔 때
window.addEventListener("resize", update);
if (window.ResizeObserver) new ResizeObserver(update).observe(scroller);
if (window.MutationObserver) {
new MutationObserver(function (records) {
for (let i = 0; i < records.length; i++) {
if (!anchor.contains(records[i].target)) { update(); return; }
}
}).observe(scroller, { childList: true, subtree: true, attributes: true, characterData: true });
}
let dragging = false, startY = 0, startTop = 0;
thumb.addEventListener("mousedown", function (e) {
e.preventDefault(); e.stopPropagation();
dragging = true; startY = e.clientY; startTop = scroller.scrollTop;
thumb.classList.add("is-dragging");
});
document.addEventListener("mousemove", function (e) {
if (!dragging) return;
const sh = scroller.scrollHeight, ch = scroller.clientHeight, room = ch - thumbH;
if (room <= 0) return;
scroller.scrollTop = startTop + (e.clientY - startY) * (sh - ch) / room;
});
document.addEventListener("mouseup", function () {
if (dragging) { dragging = false; thumb.classList.remove("is-dragging"); }
});
track.addEventListener("mousedown", function (e) {
if (e.target !== track) return;
e.preventDefault();
const y = e.clientY - track.getBoundingClientRect().top;
scroller.scrollTop += (y < thumbTop ? -1 : 1) * scroller.clientHeight * 0.9;
});
update();
}
function initScrollIndicators() {
document.querySelectorAll(".pj-home-side, .pj-home-main, .pj-side, .pj-board")
.forEach(attachScrollIndicator);
}
document.addEventListener("DOMContentLoaded", function () {
initHome();
initProject();
initBell();
initInbox();
initLiveRefresh();
wireMyTreeToggles();
initScrollIndicators();
});
})();