feat(project): 서브프로젝트 제거, 사이드 트리를 프로젝트→업무 2단계로

요구에 따라 서브프로젝트 개념을 UI 에서 제거. 좌측 트리를 프로젝트(최상위)
→ 그 안의 업무 2단계로 재구성:
- 프로젝트 노드(folder) 펼치면 소속 업무가 체크 아이콘으로 표시
- 업무 클릭 → /project/p/{id}?task={tid} 로 이동 후 해당 업무 모달 자동 오픈
- 완료 업무는 취소선. 트리에서 프로젝트 삭제(관리자)
- 과거 parent_id 서브프로젝트는 트리/홈에서 숨김(최상위만 노출)
- 홈 카드의 '서브 N' 칩 제거

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-25 13:20:28 +09:00
parent a22bb1e043
commit 03916a61b1
6 changed files with 84 additions and 64 deletions
+23 -5
View File
@@ -356,12 +356,31 @@ async def project_page(request: Request, project_id: int) -> HTMLResponse:
include_archived=False, include_archived=False,
member_email=None if admin else user["email"], member_email=None if admin else user["email"],
) )
tree = _build_tree(all_projects)
stages = st.list_stages(project_id=project_id) stages = st.list_stages(project_id=project_id)
tasks = st.list_tasks(project_id=project_id, include_subprojects=True, limit=2000) tasks = st.list_tasks(project_id=project_id, limit=2000)
members = st.list_members(project_id=project_id) members = st.list_members(project_id=project_id)
can_manage = _can_manage(request, st, user, project_id) can_manage = _can_manage(request, st, user, project_id)
subprojects = [p for p in all_projects if p.get("parent_id") == project_id]
# 좌측 트리: 프로젝트(최상위) → 그 안의 업무. (서브프로젝트 개념 제거)
nav_projects: list[dict[str, Any]] = []
for p in all_projects:
if p.get("parent_id"):
continue # 과거 서브프로젝트는 트리에서 제외
p_tasks = (
tasks if p["id"] == project_id
else st.list_tasks(project_id=p["id"], limit=500)
)
nav_projects.append({
"id": p["id"],
"name": p["name"],
"color": p.get("color") or "#4573d2",
"tasks": [
{"id": t["id"], "title": t["title"],
"color": t.get("project_color") or p.get("color") or "#4573d2",
"completed": bool(t.get("completed_at"))}
for t in p_tasks
],
})
# 달력(휴가식 월간 그리드) — ?y=&m= 로 월 이동, 기본 이번 달 # 달력(휴가식 월간 그리드) — ?y=&m= 로 월 이동, 기본 이번 달
today = today_kst() today = today_kst()
@@ -387,11 +406,10 @@ async def project_page(request: Request, project_id: int) -> HTMLResponse:
"page_title": project["name"], "page_title": project["name"],
"page_subtitle": project.get("description") or "프로젝트 보드", "page_subtitle": project.get("description") or "프로젝트 보드",
"project": project, "project": project,
"tree": tree, "nav_projects": nav_projects,
"stages": stages, "stages": stages,
"tasks": tasks, "tasks": tasks,
"members": members, "members": members,
"subprojects": subprojects,
"priority_labels": store.PRIORITY_LABELS, "priority_labels": store.PRIORITY_LABELS,
"color_palette": list(store.COLOR_PALETTE), "color_palette": list(store.COLOR_PALETTE),
"today": today.isoformat(), "today": today.isoformat(),
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %} {% extends "erp_base.html" %}
{% block head_extra %} {% block head_extra %}
<link rel="stylesheet" href="/static/project.css?v=20260625j" /> <link rel="stylesheet" href="/static/project.css?v=20260625k" />
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" /> <link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
{% endblock %} {% endblock %}
@@ -45,5 +45,5 @@
{% endif %} {% endif %}
</div> </div>
<script src="/static/project.js?v=20260625j" defer></script> <script src="/static/project.js?v=20260625k" defer></script>
{% endblock %} {% endblock %}
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %} {% extends "erp_base.html" %}
{% block head_extra %} {% block head_extra %}
<link rel="stylesheet" href="/static/project.css?v=20260625j" /> <link rel="stylesheet" href="/static/project.css?v=20260625k" />
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" /> <link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
{% endblock %} {% endblock %}
@@ -43,7 +43,6 @@
<div class="pj-project-name">{{ p.name }}</div> <div class="pj-project-name">{{ p.name }}</div>
{% if p.description %}<div class="pj-project-desc">{{ p.description }}</div>{% endif %} {% if p.description %}<div class="pj-project-desc">{{ p.description }}</div>{% endif %}
<div class="pj-project-meta"> <div class="pj-project-meta">
{% if p.children %}<span class="pj-chip">서브 {{ p.children|length }}</span>{% endif %}
{% if p.due_date %}<span class="pj-chip">마감 {{ p.due_date }}</span>{% endif %} {% if p.due_date %}<span class="pj-chip">마감 {{ p.due_date }}</span>{% endif %}
</div> </div>
</div> </div>
@@ -101,5 +100,5 @@
<script> <script>
window.PJ_COLORS = {{ ["#4573d2","#37a3a3","#62a420","#e8a33d","#e8384f","#aa62e3","#f06a6a","#5a6772"] | tojson }}; window.PJ_COLORS = {{ ["#4573d2","#37a3a3","#62a420","#e8a33d","#e8384f","#aa62e3","#f06a6a","#5a6772"] | tojson }};
</script> </script>
<script src="/static/project.js?v=20260625j" defer></script> <script src="/static/project.js?v=20260625k" defer></script>
{% endblock %} {% endblock %}
@@ -1,49 +1,13 @@
{% extends "erp_base.html" %} {% extends "erp_base.html" %}
{% block head_extra %} {% block head_extra %}
<link rel="stylesheet" href="/static/project.css?v=20260625j" /> <link rel="stylesheet" href="/static/project.css?v=20260625k" />
<!-- 구글 머티리얼 심볼(담당자 아이콘 등) — self-host --> <!-- 구글 머티리얼 심볼(담당자 아이콘 등) — self-host -->
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" /> <link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
<!-- 타임라인 vis-timeline — self-host --> <!-- 타임라인 vis-timeline — self-host -->
<link href="/static/vendor/vis-timeline/vis-timeline-graph2d.min.css" rel="stylesheet" /> <link href="/static/vendor/vis-timeline/vis-timeline-graph2d.min.css" rel="stylesheet" />
{% endblock %} {% 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 topbar_actions %} {% block topbar_actions %}
<a class="pj-bell" href="/project/inbox" title="알림센터"> <a class="pj-bell" href="/project/inbox" title="알림센터">
<span class="material-symbols-outlined">notifications</span> <span class="material-symbols-outlined">notifications</span>
@@ -71,8 +35,50 @@
<button type="button" class="pj-icon-btn" id="pj-new-project-side" title="새 프로젝트">+</button> <button type="button" class="pj-icon-btn" id="pj-new-project-side" title="새 프로젝트">+</button>
{% endif %} {% endif %}
</div> </div>
{% if tree %} {% if nav_projects %}
{{ proj_tree(tree, project.id, (is_admin or can_manage), 0) }} <ul class="pj-tree">
{% for p in nav_projects %}
<li class="pj-tree-li" data-id="{{ p.id }}">
<div class="pj-tree-row {% if p.id == project.id %}is-active{% endif %}">
{% if p.tasks %}
<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/{{ p.id }}">
<span class="pj-tree-icon material-symbols-outlined" style="color: {{ p.color }}">folder</span>
<span class="pj-tree-name">{{ p.name }}</span>
</a>
{% if is_admin %}
<span class="pj-tree-acts">
<button type="button" class="pj-icon-btn pj-tree-del" data-id="{{ p.id }}" data-name="{{ p.name }}" title="프로젝트 삭제">×</button>
</span>
{% endif %}
</div>
{% if p.tasks %}
<div class="pj-tree-children" hidden>
<ul class="pj-tree">
{% for t in p.tasks %}
<li class="pj-tree-li pj-tree-task">
<div class="pj-tree-row" style="--pj-indent: 1">
<span class="pj-tree-caret-spacer"></span>
<a class="pj-tree-link pj-tree-tasklink"
href="/project/p/{{ p.id }}?task={{ t.id }}"
data-project="{{ p.id }}" data-task="{{ t.id }}">
<span class="pj-tree-icon material-symbols-outlined" style="color: {{ t.color }}">{% if t.completed %}task_alt{% else %}radio_button_unchecked{% endif %}</span>
<span class="pj-tree-name {% if t.completed %}pj-tree-done{% endif %}">{{ t.title }}</span>
</a>
</div>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
</li>
{% endfor %}
</ul>
{% else %} {% else %}
<div class="pj-empty-sm">프로젝트 없음</div> <div class="pj-empty-sm">프로젝트 없음</div>
{% endif %} {% endif %}
@@ -284,5 +290,5 @@
</script> </script>
<script src="/static/vendor/vis-timeline/vis-timeline-graph2d.min.js"></script> <script src="/static/vendor/vis-timeline/vis-timeline-graph2d.min.js"></script>
<script src="/static/project.js?v=20260625j" defer></script> <script src="/static/project.js?v=20260625k" defer></script>
{% endblock %} {% endblock %}
+2
View File
@@ -85,6 +85,8 @@
.pj-tree-acts { display: none; gap: 0; flex: 0 0 auto; } .pj-tree-acts { display: none; gap: 0; flex: 0 0 auto; }
.pj-tree-row:hover .pj-tree-acts { display: inline-flex; } .pj-tree-row:hover .pj-tree-acts { display: inline-flex; }
.pj-tree-acts .pj-icon-btn { width: 22px; height: 22px; font-size: 14px; } .pj-tree-acts .pj-icon-btn { width: 22px; height: 22px; font-size: 14px; }
.pj-tree-done { text-decoration: line-through; color: #9aa1a9; }
.pj-tree-task .pj-tree-name { font-size: 13px; color: #444; }
/* 구글 머티리얼 심볼 아이콘(담당자) */ /* 구글 머티리얼 심볼 아이콘(담당자) */
.material-symbols-outlined { font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24; line-height: 1; vertical-align: middle; } .material-symbols-outlined { font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24; line-height: 1; vertical-align: middle; }
+9 -14
View File
@@ -637,23 +637,11 @@
} }
})(); })();
// 서브프로젝트 추가 (트리 각 노드) // 프로젝트 삭제 (트리)
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) { document.querySelectorAll(".pj-tree-del").forEach(function (b) {
b.addEventListener("click", async function (e) { b.addEventListener("click", async function (e) {
e.preventDefault(); e.stopPropagation(); e.preventDefault(); e.stopPropagation();
if (!confirm("'" + (b.dataset.name || "") + "' 삭제할까요? 하위 서브프로젝트·업무도 모두 삭제됩니다.")) return; if (!confirm("'" + (b.dataset.name || "") + "' 프로젝트를 삭제할까요? 안의 업무도 모두 삭제됩니다.")) return;
try { try {
await api("DELETE", "/project/api/projects/" + b.dataset.id); await api("DELETE", "/project/api/projects/" + b.dataset.id);
if (String(b.dataset.id) === String(projectId)) location.href = "/project/"; if (String(b.dataset.id) === String(projectId)) location.href = "/project/";
@@ -720,6 +708,13 @@
// 최초 렌더 — 달력은 서버 렌더, 클릭 핸들러만 연결 // 최초 렌더 — 달력은 서버 렌더, 클릭 핸들러만 연결
wireCalendar(); wireCalendar();
// 사이드 트리에서 업무 클릭(?task=ID)으로 들어오면 해당 업무 모달 자동 열기
const taskParam = new URLSearchParams(location.search).get("task");
if (taskParam) {
const t = tasks.find(function (x) { return String(x.id) === String(taskParam); });
if (t) openTaskModal(t);
}
} }
function idOf(email) { function idOf(email) {