33b6bd2201
설명에 붙여넣기/업로드로 넣은 이미지는 실제로는 task_attachments 레코드라, 에디터에서 이미지만 지우면(× 버튼 또는 Delete/Backspace) 서버 첨부는 그대로 남아 첨부파일 목록에 계속 보였다. src 의 attachment id 를 뽑아 DELETE 호출까지 같이 하고, 첨부파일 목록도 새로고침한다. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2730 lines
130 KiB
JavaScript
2730 lines
130 KiB
JavaScript
/* 프로젝트 관리(아사나식) 모듈 — 프론트엔드.
|
||
* 두 페이지에서 공용:
|
||
* - 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, "&").replace(/</g, "<").replace(/>/g, ">")
|
||
.replace(/"/g, """).replace(/'/g, "'");
|
||
}
|
||
|
||
// ── 알림 벨 (미읽음 배지) ──
|
||
async function initBell() {
|
||
const badge = document.getElementById("pj-bell-badge");
|
||
if (!badge) return;
|
||
const bell = badge.closest(".pj-bell");
|
||
try {
|
||
const res = await fetch("/project/api/notifications/unread-count");
|
||
if (!res.ok) return;
|
||
const d = await res.json();
|
||
if (d.unread > 0) {
|
||
badge.textContent = d.unread > 99 ? "99+" : d.unread;
|
||
badge.hidden = false;
|
||
if (bell) bell.classList.add("has-unread"); // 종 흔들림 애니메이션
|
||
}
|
||
} catch (_) {}
|
||
}
|
||
|
||
// ── 알림센터 페이지 ──
|
||
function initInbox() {
|
||
const inbox = document.getElementById("pj-inbox");
|
||
const readAll = document.getElementById("pj-readall");
|
||
if (!inbox && !readAll) return;
|
||
if (readAll) readAll.addEventListener("click", async function () {
|
||
try { await fetch("/project/api/notifications/read-all", { method: "POST" }); 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();
|
||
});
|
||
})();
|