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
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/project.css?v=20260625h" />
<link rel="stylesheet" href="/static/project.css?v=20260625i" />
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
{% endblock %}
@@ -45,5 +45,5 @@
{% endif %}
</div>
<script src="/static/project.js?v=20260625h" defer></script>
<script src="/static/project.js?v=20260625i" defer></script>
{% endblock %}
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/project.css?v=20260625h" />
<link rel="stylesheet" href="/static/project.css?v=20260625i" />
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
{% endblock %}
@@ -101,5 +101,5 @@
<script>
window.PJ_COLORS = {{ ["#4573d2","#37a3a3","#62a420","#e8a33d","#e8384f","#aa62e3","#f06a6a","#5a6772"] | tojson }};
</script>
<script src="/static/project.js?v=20260625h" defer></script>
<script src="/static/project.js?v=20260625i" defer></script>
{% endblock %}
@@ -1,13 +1,49 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/project.css?v=20260625h" />
<link rel="stylesheet" href="/static/project.css?v=20260625i" />
<!-- 구글 머티리얼 심볼(담당자 아이콘 등) — self-host -->
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
<!-- 타임라인 vis-timeline — self-host -->
<link href="/static/vendor/vis-timeline/vis-timeline-graph2d.min.css" rel="stylesheet" />
{% endblock %}
{# ── 아사나식 프로젝트/서브프로젝트 재귀 트리 ── #}
{% macro proj_tree(nodes, active_id, can_edit, depth) %}
<ul class="pj-tree" {% if depth > 0 %}data-sub="1"{% endif %}>
{% for n in nodes %}
<li class="pj-tree-li" data-id="{{ n.id }}">
<div class="pj-tree-row {% if n.id == active_id %}is-active{% endif %}" style="--pj-indent: {{ depth }}">
{% if n.children %}
<button type="button" class="pj-tree-caret" aria-expanded="false" title="펼치기/접기">
<span class="material-symbols-outlined">chevron_right</span>
</button>
{% else %}
<span class="pj-tree-caret-spacer"></span>
{% endif %}
<a class="pj-tree-link" href="/project/p/{{ n.id }}">
<span class="pj-tree-icon material-symbols-outlined" style="color: {{ n.color }}">
{% if n.children %}folder{% else %}check_circle{% endif %}
</span>
<span class="pj-tree-name">{{ n.name }}</span>
</a>
{% if can_edit %}
<span class="pj-tree-acts">
<button type="button" class="pj-icon-btn pj-tree-addsub" data-id="{{ n.id }}" title="서브프로젝트 추가">+</button>
<button type="button" class="pj-icon-btn pj-tree-del" data-id="{{ n.id }}" data-name="{{ n.name }}" title="삭제">×</button>
</span>
{% endif %}
</div>
{% if n.children %}
<div class="pj-tree-children" hidden>
{{ proj_tree(n.children, active_id, can_edit, depth + 1) }}
</div>
{% endif %}
</li>
{% endfor %}
</ul>
{% endmacro %}
{% block content %}
<div class="pj-wrap pj-project-view"
data-project-id="{{ project.id }}"
@@ -16,29 +52,20 @@
data-me="{{ user.email }}">
<div class="pj-layout">
<!-- ── 좌측: 서브프로젝트 + 멤버 ── -->
<!-- ── 좌측: 프로젝트 트리 + 멤버 ── -->
<aside class="pj-side">
<div class="pj-side-block">
<div class="pj-side-head">
<span>서브프로젝트</span>
{% if can_manage %}
<button type="button" class="pj-icon-btn" id="pj-new-sub" title="서브프로젝트 추가">+</button>
<span>프로젝트</span>
{% if is_admin %}
<button type="button" class="pj-icon-btn" id="pj-new-project-side" title="프로젝트">+</button>
{% endif %}
</div>
<ul class="pj-sub-list" id="pj-sub-list">
{% for s in subprojects %}
<li>
<a href="/project/p/{{ s.id }}">
<span class="pj-sub-dot" style="background: {{ s.color }}"></span>{{ s.name }}
</a>
{% if can_manage %}
<button type="button" class="pj-icon-btn pj-del-sub" data-id="{{ s.id }}" title="삭제">×</button>
{% endif %}
</li>
{% else %}
<li class="pj-empty-sm">없음</li>
{% endfor %}
</ul>
{% if tree %}
{{ proj_tree(tree, project.id, (is_admin or can_manage), 0) }}
{% else %}
<div class="pj-empty-sm">프로젝트 없음</div>
{% endif %}
</div>
<div class="pj-side-block">
@@ -256,5 +283,5 @@
</script>
<script src="/static/vendor/vis-timeline/vis-timeline-graph2d.min.js"></script>
<script src="/static/project.js?v=20260625h" defer></script>
<script src="/static/project.js?v=20260625i" defer></script>
{% endblock %}
+24 -5
View File
@@ -60,13 +60,32 @@
.pj-side-block { margin-bottom: 22px; }
.pj-side-head { display: flex; align-items: center; justify-content: space-between; font-size: 12px; font-weight: 700; color: #6b7280; text-transform: uppercase; letter-spacing: .04em; margin-bottom: 8px; }
.pj-sub-list, .pj-member-list { list-style: none; margin: 0; padding: 0; }
.pj-sub-list li, .pj-member-list li { display: flex; align-items: center; gap: 8px; padding: 5px 4px; border-radius: 7px; }
.pj-sub-list li:hover, .pj-member-list li:hover { background: #f6f7f8; }
.pj-sub-list a { display: flex; align-items: center; gap: 8px; text-decoration: none; color: inherit; font-size: 13.5px; flex: 1; }
.pj-sub-dot { width: 8px; height: 8px; border-radius: 50%; }
.pj-member-list { list-style: none; margin: 0; padding: 0; }
.pj-member-list li { display: flex; align-items: center; gap: 8px; padding: 5px 4px; border-radius: 7px; }
.pj-member-list li:hover { background: #f6f7f8; }
.pj-member-name { font-size: 13px; flex: 1; }
/* ── 아사나식 프로젝트 트리 ── */
.pj-tree { list-style: none; margin: 0; padding: 0; }
.pj-tree .pj-tree { padding-left: 0; } /* 들여쓰기는 row 패딩으로 처리 */
.pj-tree-row { display: flex; align-items: center; gap: 2px; border-radius: 7px; padding-right: 2px;
padding-left: calc(var(--pj-indent, 0) * 16px); position: relative; }
.pj-tree-row:hover { background: #f1f2f4; }
.pj-tree-row.is-active { background: #ececf8; }
.pj-tree-row.is-active .pj-tree-name { font-weight: 700; color: #1e1f21; }
.pj-tree-caret { border: none; background: transparent; cursor: pointer; width: 20px; height: 26px;
display: inline-flex; align-items: center; justify-content: center; color: #6b7280; padding: 0; }
.pj-tree-caret .material-symbols-outlined { font-size: 18px; transition: transform .12s; }
.pj-tree-caret.is-open .material-symbols-outlined { transform: rotate(90deg); }
.pj-tree-caret-spacer { width: 20px; flex: 0 0 auto; }
.pj-tree-link { display: flex; align-items: center; gap: 7px; flex: 1; min-width: 0;
text-decoration: none; color: inherit; padding: 5px 2px; }
.pj-tree-icon { font-size: 18px; flex: 0 0 auto; }
.pj-tree-name { font-size: 13.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pj-tree-acts { display: none; gap: 0; flex: 0 0 auto; }
.pj-tree-row:hover .pj-tree-acts { display: inline-flex; }
.pj-tree-acts .pj-icon-btn { width: 22px; height: 22px; font-size: 14px; }
/* 구글 머티리얼 심볼 아이콘(담당자) */
.material-symbols-outlined { font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24; line-height: 1; vertical-align: middle; }
.pj-gicon { font-size: 24px; color: #5f6368; flex: 0 0 auto; }
+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");