feat(project): 아사나식 프로젝트/서브프로젝트 트리 사이드바

좌측 사이드바를 직속 서브 평면 목록 → 전체 프로젝트 재귀 트리로 교체.
- 접기/펼치기 캐럿(localStorage 로 펼침 상태 저장, 활성 프로젝트 상위 자동 펼침)
- 프로젝트 색상 아이콘, 들여쓰기(depth), 활성 프로젝트 강조
- hover 시 서브추가(+)/삭제(×) 액션(관리자·멤버, 서버 권한 검증)
- 상단 '새 프로젝트'(관리자). Material Symbols 아이콘(self-host) 사용.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-25 13:09:17 +09:00
parent 57b4b37468
commit 64dd7445bc
5 changed files with 149 additions and 41 deletions
+74 -12
View File
@@ -605,23 +605,85 @@
} catch (e) { alert("삭제 실패: " + e.message); }
});
// ── 서브프로젝트 추가/삭제 ──
const newSubBtn = el("pj-new-sub");
if (newSubBtn) newSubBtn.addEventListener("click", async function () {
const name = prompt("서브프로젝트 이름");
// ── 프로젝트 트리 (아사나식 접기/펼치기) ──
function setTreeOpen(li, open) {
const children = li.querySelector(":scope > .pj-tree-children");
const caret = li.querySelector(":scope > .pj-tree-row > .pj-tree-caret");
if (!children) return;
children.hidden = !open;
if (caret) { caret.setAttribute("aria-expanded", open ? "true" : "false"); caret.classList.toggle("is-open", open); }
}
function treeOpenSet() {
try { return new Set(JSON.parse(localStorage.getItem("pj_tree_open") || "[]")); }
catch (_) { return new Set(); }
}
function treeSaveOpen(id, open) {
const s = treeOpenSet();
if (open) s.add(String(id)); else s.delete(String(id));
try { localStorage.setItem("pj_tree_open", JSON.stringify(Array.from(s))); } catch (_) {}
}
document.querySelectorAll(".pj-tree-caret").forEach(function (caret) {
caret.addEventListener("click", function (e) {
e.preventDefault(); e.stopPropagation();
const li = caret.closest(".pj-tree-li");
const children = li.querySelector(":scope > .pj-tree-children");
const open = children.hidden; // 현재 닫혀있으면 → 연다
setTreeOpen(li, open);
treeSaveOpen(li.dataset.id, open);
});
});
// 저장된 펼침 상태 복원 + 활성 프로젝트의 상위 자동 펼침
(function restoreTree() {
const openSet = treeOpenSet();
document.querySelectorAll(".pj-tree-li").forEach(function (li) {
if (openSet.has(String(li.dataset.id))) setTreeOpen(li, true);
});
const active = document.querySelector(".pj-tree-row.is-active");
let box = active ? active.closest(".pj-tree-children") : null;
while (box) {
box.hidden = false;
const li = box.closest(".pj-tree-li");
if (li) setTreeOpen(li, true);
box = li ? (li.parentElement ? li.parentElement.closest(".pj-tree-children") : null) : null;
}
})();
// 서브프로젝트 추가 (트리 각 노드)
document.querySelectorAll(".pj-tree-addsub").forEach(function (b) {
b.addEventListener("click", async function (e) {
e.preventDefault(); e.stopPropagation();
const name = prompt("서브프로젝트 이름");
if (!name || !name.trim()) return;
try {
await api("POST", "/project/api/projects/" + b.dataset.id + "/subprojects", { name: name.trim() });
location.reload();
} catch (err) { alert("생성 실패: " + err.message); }
});
});
// 프로젝트/서브 삭제 (트리)
document.querySelectorAll(".pj-tree-del").forEach(function (b) {
b.addEventListener("click", async function (e) {
e.preventDefault(); e.stopPropagation();
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); }
});
});
// 새 최상위 프로젝트 (관리자)
const newProjSide = el("pj-new-project-side");
if (newProjSide) newProjSide.addEventListener("click", async function () {
const name = prompt("새 프로젝트 이름");
if (!name || !name.trim()) return;
try {
await api("POST", "/project/api/projects/" + projectId + "/subprojects", { name: name.trim() });
await api("POST", "/project/api/projects", { name: name.trim() });
location.reload();
} catch (e) { alert("생성 실패: " + e.message); }
});
document.querySelectorAll(".pj-del-sub").forEach(function (b) {
b.addEventListener("click", async function () {
if (!confirm("이 서브프로젝트를 삭제할까요? (하위 업무도 모두 삭제)")) return;
try { await api("DELETE", "/project/api/projects/" + b.dataset.id); location.reload(); }
catch (e) { alert("삭제 실패: " + e.message); }
});
});
// ── 멤버 배정/제외 (관리자) ──
const memberModal = el("pj-modal-member");