feat(project): 좌측 트리를 클라이언트 렌더 → 업무 추가/수정/삭제 즉시 반영
- 사이드 프로젝트 트리를 tasks 로부터 JS 로 재렌더(renderTree) - refreshActiveView 에서 트리도 갱신 → 새로고침 없이 좌측에 바로 표시 - 새 업무 추가 시 해당 프로젝트 자동 펼침 - projectsMeta 에 created_by 추가(트리 삭제 권한용) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -418,6 +418,7 @@ async def project_page(request: Request, project_id: int) -> HTMLResponse:
|
|||||||
"color": p.get("color") or "#4573d2",
|
"color": p.get("color") or "#4573d2",
|
||||||
"start_date": p.get("start_date"),
|
"start_date": p.get("start_date"),
|
||||||
"due_date": p.get("due_date"),
|
"due_date": p.get("due_date"),
|
||||||
|
"created_by": p.get("created_by") or "",
|
||||||
})
|
})
|
||||||
nav_projects.append({
|
nav_projects.append({
|
||||||
"id": p["id"],
|
"id": p["id"],
|
||||||
|
|||||||
@@ -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=20260626a" />
|
<link rel="stylesheet" href="/static/project.css?v=20260626b" />
|
||||||
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
|
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@@ -167,5 +167,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=20260626a" defer></script>
|
<script src="/static/project.js?v=20260626b" 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=20260626a" />
|
<link rel="stylesheet" href="/static/project.css?v=20260626b" />
|
||||||
<!-- 구글 머티리얼 심볼(담당자 아이콘 등) — 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 -->
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% if nav_projects %}
|
{% if nav_projects %}
|
||||||
<ul class="pj-tree">
|
<ul class="pj-tree" id="pj-tree-root">
|
||||||
{% for p in nav_projects %}
|
{% for p in nav_projects %}
|
||||||
<li class="pj-tree-li" data-id="{{ p.id }}">
|
<li class="pj-tree-li" data-id="{{ p.id }}">
|
||||||
<div class="pj-tree-row {% if p.id == project.id %}is-active{% endif %}">
|
<div class="pj-tree-row {% if p.id == project.id %}is-active{% endif %}">
|
||||||
@@ -291,5 +291,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=20260626a" defer></script>
|
<script src="/static/project.js?v=20260626b" defer></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
+59
-21
@@ -763,8 +763,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function refreshActiveView() {
|
function refreshActiveView() {
|
||||||
// 모든 뷰 클라이언트 재렌더(달력 포함, 새로고침 없음).
|
// 모든 뷰 + 좌측 트리 클라이언트 재렌더(새로고침 없음).
|
||||||
renderView(currentView);
|
renderView(currentView);
|
||||||
|
if (typeof renderTree === "function") renderTree();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 업무 모달 ──
|
// ── 업무 모달 ──
|
||||||
@@ -978,6 +979,8 @@
|
|||||||
if (id) res = await api("PUT", "/project/api/tasks/" + id, payload);
|
if (id) res = await api("PUT", "/project/api/tasks/" + id, payload);
|
||||||
else res = await api("POST", "/project/api/projects/" + projectId + "/tasks", payload);
|
else res = await api("POST", "/project/api/projects/" + projectId + "/tasks", payload);
|
||||||
mergeTask(res.task);
|
mergeTask(res.task);
|
||||||
|
// 새 업무는 해당 프로젝트 트리를 펼쳐 바로 보이게.
|
||||||
|
if (!id) treeSaveOpen((res.task && res.task.project_id) || projectId, true);
|
||||||
closeModal(taskModal);
|
closeModal(taskModal);
|
||||||
refreshActiveView();
|
refreshActiveView();
|
||||||
} catch (e) { alert("저장 실패: " + e.message); }
|
} catch (e) { alert("저장 실패: " + e.message); }
|
||||||
@@ -997,7 +1000,7 @@
|
|||||||
} catch (e) { alert("삭제 실패: " + e.message); }
|
} catch (e) { alert("삭제 실패: " + e.message); }
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── 프로젝트 트리 (아사나식 접기/펼치기) ──
|
// ── 프로젝트 트리 (아사나식) — tasks 로부터 클라이언트 렌더(업무 추가 즉시 반영) ──
|
||||||
function setTreeOpen(li, open) {
|
function setTreeOpen(li, open) {
|
||||||
const children = li.querySelector(":scope > .pj-tree-children");
|
const children = li.querySelector(":scope > .pj-tree-children");
|
||||||
const caret = li.querySelector(":scope > .pj-tree-row > .pj-tree-caret");
|
const caret = li.querySelector(":scope > .pj-tree-row > .pj-tree-caret");
|
||||||
@@ -1015,19 +1018,59 @@
|
|||||||
try { localStorage.setItem("pj_tree_open", JSON.stringify(Array.from(s))); } catch (_) {}
|
try { localStorage.setItem("pj_tree_open", JSON.stringify(Array.from(s))); } catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.querySelectorAll(".pj-tree-caret").forEach(function (caret) {
|
function treeTaskLi(t, pid) {
|
||||||
|
return '<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/' + pid + '?task=' + t.id +
|
||||||
|
'" data-project="' + pid + '" data-task="' + t.id + '">' +
|
||||||
|
'<span class="pj-tree-icon material-symbols-outlined" style="color:' + (t.project_color || "#4573d2") + '">' +
|
||||||
|
(t.completed_at ? "task_alt" : "radio_button_unchecked") + "</span>" +
|
||||||
|
'<span class="pj-tree-name ' + (t.completed_at ? "pj-tree-done" : "") + '">' + escapeHtml(t.title || "") + "</span>" +
|
||||||
|
"</a></div></li>";
|
||||||
|
}
|
||||||
|
function treeProjLi(p) {
|
||||||
|
const ptasks = tasks.filter(function (t) { return t.project_id === p.id; });
|
||||||
|
const has = ptasks.length > 0;
|
||||||
|
const caret = has
|
||||||
|
? '<button type="button" class="pj-tree-caret" aria-expanded="false" title="펼치기/접기"><span class="material-symbols-outlined">chevron_right</span></button>'
|
||||||
|
: '<span class="pj-tree-caret-spacer"></span>';
|
||||||
|
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>'
|
||||||
|
: "";
|
||||||
|
let h = '<li class="pj-tree-li" data-id="' + p.id + '"><div class="pj-tree-row ' + (p.id === projectId ? "is-active" : "") + '">' +
|
||||||
|
caret + '<a class="pj-tree-link" href="/project/p/' + p.id + '">' +
|
||||||
|
'<span class="pj-tree-icon material-symbols-outlined" style="color:' + (p.color || "#4573d2") + '">folder</span>' +
|
||||||
|
'<span class="pj-tree-name">' + escapeHtml(p.name) + "</span></a>" + del + "</div>";
|
||||||
|
if (has) h += '<div class="pj-tree-children" hidden><ul class="pj-tree">' +
|
||||||
|
ptasks.map(function (t) { return treeTaskLi(t, p.id); }).join("") + "</ul></div>";
|
||||||
|
return h + "</li>";
|
||||||
|
}
|
||||||
|
function wireTreeHandlers(rootUl) {
|
||||||
|
rootUl.querySelectorAll(".pj-tree-caret").forEach(function (caret) {
|
||||||
caret.addEventListener("click", function (e) {
|
caret.addEventListener("click", function (e) {
|
||||||
e.preventDefault(); e.stopPropagation();
|
e.preventDefault(); e.stopPropagation();
|
||||||
const li = caret.closest(".pj-tree-li");
|
const li = caret.closest(".pj-tree-li");
|
||||||
const children = li.querySelector(":scope > .pj-tree-children");
|
const children = li.querySelector(":scope > .pj-tree-children");
|
||||||
const open = children.hidden; // 현재 닫혀있으면 → 연다
|
const open = children.hidden;
|
||||||
setTreeOpen(li, open);
|
setTreeOpen(li, open);
|
||||||
treeSaveOpen(li.dataset.id, open);
|
treeSaveOpen(li.dataset.id, open);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
rootUl.querySelectorAll(".pj-tree-del").forEach(function (b) {
|
||||||
// 저장된 펼침 상태 복원 + 활성 프로젝트의 상위 자동 펼침
|
b.addEventListener("click", async function (e) {
|
||||||
(function restoreTree() {
|
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 restoreTreeState() {
|
||||||
const openSet = treeOpenSet();
|
const openSet = treeOpenSet();
|
||||||
document.querySelectorAll(".pj-tree-li").forEach(function (li) {
|
document.querySelectorAll(".pj-tree-li").forEach(function (li) {
|
||||||
if (openSet.has(String(li.dataset.id))) setTreeOpen(li, true);
|
if (openSet.has(String(li.dataset.id))) setTreeOpen(li, true);
|
||||||
@@ -1040,21 +1083,16 @@
|
|||||||
if (li) setTreeOpen(li, true);
|
if (li) setTreeOpen(li, true);
|
||||||
box = li ? (li.parentElement ? li.parentElement.closest(".pj-tree-children") : null) : null;
|
box = li ? (li.parentElement ? li.parentElement.closest(".pj-tree-children") : null) : null;
|
||||||
}
|
}
|
||||||
})();
|
}
|
||||||
|
function renderTree() {
|
||||||
|
const rootUl = el("pj-tree-root");
|
||||||
|
if (!rootUl) return;
|
||||||
|
rootUl.innerHTML = projectsMeta.map(treeProjLi).join("");
|
||||||
|
wireTreeHandlers(rootUl);
|
||||||
|
restoreTreeState();
|
||||||
|
}
|
||||||
|
renderTree();
|
||||||
|
|
||||||
// 프로젝트 삭제 (트리)
|
|
||||||
document.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); }
|
|
||||||
});
|
|
||||||
});
|
|
||||||
// 새 최상위 프로젝트 (관리자)
|
// 새 최상위 프로젝트 (관리자)
|
||||||
const newProjSide = el("pj-new-project-side");
|
const newProjSide = el("pj-new-project-side");
|
||||||
if (newProjSide) newProjSide.addEventListener("click", async function () {
|
if (newProjSide) newProjSide.addEventListener("click", async function () {
|
||||||
|
|||||||
Reference in New Issue
Block a user