feat(project): 좌측 프로젝트 목록에 수정(✎) 버튼 추가

프로젝트 상세 화면 좌측 트리는 renderTree()가 매번 JS로 다시 그리므로
(라이브 새로고침 포함) 실제 동작은 projectsMeta/treeProjLi/wireTreeHandlers
쪽에 구현 — 홈 화면과 같은 프로젝트 수정 모달(이름/설명/기간/색상/완료
토글)을 재사용. 서버 쪽 projects_meta·nav_projects 에 description/status
필드 추가(기존엔 없어서 수정 폼을 못 채웠음). Jinja 쪽 nav_projects 루프도
동일하게 맞춰뒀지만 JS가 즉시 덮어쓰므로 첫 페인트용일 뿐 — 기존 삭제
버튼도 같은 이중 구조였음(관례를 그대로 따름).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-16 01:39:27 +09:00
parent be9c97e04d
commit 1710c8f811
5 changed files with 107 additions and 9 deletions
+6
View File
@@ -523,12 +523,18 @@ async def project_page(request: Request, project_id: int) -> HTMLResponse:
"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 "", "created_by": p.get("created_by") or "",
"description": p.get("description") or "",
"status": p.get("status") or "active",
}) })
nav_projects.append({ nav_projects.append({
"id": p["id"], "id": p["id"],
"name": p["name"], "name": p["name"],
"color": p.get("color") or "#4573d2", "color": p.get("color") or "#4573d2",
"created_by": p.get("created_by") or "", "created_by": p.get("created_by") or "",
"description": p.get("description") or "",
"start_date": p.get("start_date"),
"due_date": p.get("due_date"),
"status": p.get("status") or "active",
"tasks": [ "tasks": [
{"id": t["id"], "title": t["title"], {"id": t["id"], "title": t["title"],
"color": t.get("project_color") or p.get("color") or "#4573d2", "color": t.get("project_color") or p.get("color") or "#4573d2",
@@ -45,5 +45,5 @@
{% endif %} {% endif %}
</div> </div>
<script src="/static/project.js?v=20260917c" defer></script> <script src="/static/project.js?v=20260917d" defer></script>
{% endblock %} {% endblock %}
@@ -393,5 +393,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=20260917c" defer></script> <script src="/static/project.js?v=20260917d" defer></script>
{% endblock %} {% endblock %}
@@ -39,7 +39,11 @@
{% if nav_projects %} {% if nav_projects %}
<ul class="pj-tree" id="pj-tree-root"> <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 }}"
data-name="{{ p.name }}" data-desc="{{ p.description or '' }}"
data-start="{{ p.start_date or '' }}" data-due="{{ p.due_date or '' }}"
data-color="{{ p.color }}" data-status="{{ p.status or 'active' }}"
data-creator="{{ p.created_by or '' }}">
<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 %}">
<a class="pj-tree-link" href="/project/p/{{ p.id }}"> <a class="pj-tree-link" href="/project/p/{{ p.id }}">
<span class="pj-tree-avatar" style="background: {{ p.color }}">{{ p.name[0] | upper }}</span> <span class="pj-tree-avatar" style="background: {{ p.color }}">{{ p.name[0] | upper }}</span>
@@ -47,6 +51,7 @@
</a> </a>
{% if is_admin %} {% if is_admin %}
<span class="pj-tree-acts"> <span class="pj-tree-acts">
<button type="button" class="pj-icon-btn pj-tree-edit" title="프로젝트 수정"></button>
<button type="button" class="pj-icon-btn pj-tree-del" data-id="{{ p.id }}" data-name="{{ p.name }}" data-creator="{{ p.created_by }}" title="프로젝트 삭제">×</button> <button type="button" class="pj-icon-btn pj-tree-del" data-id="{{ p.id }}" data-name="{{ p.name }}" data-creator="{{ p.created_by }}" title="프로젝트 삭제">×</button>
</span> </span>
{% endif %} {% endif %}
@@ -218,6 +223,32 @@
</div> </div>
</div> </div>
<!-- 프로젝트 수정 모달 — 좌측 프로젝트 목록의 ✎ 버튼 -->
<div class="pj-modal" id="pj-modal-edit" hidden>
<div class="pj-modal-card">
<h3>프로젝트 수정</h3>
<input type="hidden" id="pj-e-id" />
<label>이름<input type="text" id="pj-e-name" placeholder="프로젝트 이름" /></label>
<label>설명<textarea id="pj-e-desc" rows="2"></textarea></label>
<div class="pj-form-row">
<label>시작일<input type="date" id="pj-e-start" /></label>
<label>마감일<input type="date" id="pj-e-due" /></label>
</div>
<label>색상
<span class="pj-color-palette" id="pj-e-colors"></span>
</label>
<label class="pj-time-toggle">
<input type="checkbox" id="pj-e-done" /> 프로젝트 완료 (목록 끝으로 이동·비활성)
</label>
<div class="pj-modal-actions">
<button type="button" class="pj-btn pj-btn-danger" id="pj-delete-project">삭제</button>
<span style="flex:1"></span>
<button type="button" class="pj-btn" data-close>취소</button>
<button type="button" class="pj-btn pj-btn-primary" id="pj-save-edit">저장</button>
</div>
</div>
</div>
<!-- 업무 모달 --> <!-- 업무 모달 -->
<div class="pj-modal" id="pj-modal-task" hidden> <div class="pj-modal" id="pj-modal-task" hidden>
<div class="pj-modal-card pj-task-modal-card"> <div class="pj-modal-card pj-task-modal-card">
@@ -388,6 +419,7 @@
</div> </div>
{% endif %} {% endif %}
<script>window.PJ_COLORS = {{ color_palette | tojson }};</script>
<!-- 데이터 --> <!-- 데이터 -->
<script id="pj-data" type="application/json"> <script id="pj-data" type="application/json">
{ {
@@ -406,5 +438,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=20260917c" defer></script> <script src="/static/project.js?v=20260917d" defer></script>
{% endblock %} {% endblock %}
+65 -5
View File
@@ -1979,16 +1979,24 @@
// ── 프로젝트 트리(사이드바) — 아사나처럼 프로젝트 이름만 나열한다(업무 목록은 // ── 프로젝트 트리(사이드바) — 아사나처럼 프로젝트 이름만 나열한다(업무 목록은
// 안 보여준다 — 업무는 가운데 화면에서만 본다). 색상 원(첫 글자) 아이콘. // 안 보여준다 — 업무는 가운데 화면에서만 본다). 색상 원(첫 글자) 아이콘.
function treeProjLi(p) { function treeProjLi(p) {
const del = isAdmin let acts = "";
? '<span class="pj-tree-acts"><button type="button" class="pj-icon-btn pj-tree-del" data-id="' + p.id + if (isAdmin) {
'" data-name="' + escapeHtml(p.name) + '" data-creator="' + escapeHtml(p.created_by || "") + '" title="프로젝트 삭제">×</button></span>' acts = '<span class="pj-tree-acts">' +
: ""; '<button type="button" class="pj-icon-btn pj-tree-edit" data-id="' + p.id +
'" data-name="' + escapeHtml(p.name) + '" data-desc="' + escapeHtml(p.description || "") +
'" data-start="' + (p.start_date || "") + '" data-due="' + (p.due_date || "") +
'" data-color="' + (p.color || "#4573d2") + '" data-status="' + (p.status || "active") +
'" title="프로젝트 수정">✎</button>' +
'<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() || "?"); const initial = escapeHtml((p.name || "?").trim().charAt(0).toUpperCase() || "?");
return '<li class="pj-tree-li" data-id="' + p.id + '"><div class="pj-tree-row ' + return '<li class="pj-tree-li" data-id="' + p.id + '"><div class="pj-tree-row ' +
(p.id === projectId ? "is-active" : "") + '">' + (p.id === projectId ? "is-active" : "") + '">' +
'<a class="pj-tree-link" href="/project/p/' + p.id + '">' + '<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-avatar" style="background:' + (p.color || "#4573d2") + '">' + initial + "</span>" +
'<span class="pj-tree-name">' + escapeHtml(p.name) + "</span></a>" + del + "</div></li>"; '<span class="pj-tree-name">' + escapeHtml(p.name) + "</span></a>" + acts + "</div></li>";
} }
function wireTreeHandlers(rootUl) { function wireTreeHandlers(rootUl) {
rootUl.querySelectorAll(".pj-tree-del").forEach(function (b) { rootUl.querySelectorAll(".pj-tree-del").forEach(function (b) {
@@ -2003,6 +2011,12 @@
} catch (err) { alert("삭제 실패: " + err.message); } } catch (err) { alert("삭제 실패: " + err.message); }
}); });
}); });
rootUl.querySelectorAll(".pj-tree-edit").forEach(function (b) {
b.addEventListener("click", function (e) {
e.preventDefault(); e.stopPropagation();
openProjectEditModal(b.dataset);
});
});
} }
function renderTree() { function renderTree() {
const rootUl = el("pj-tree-root"); const rootUl = el("pj-tree-root");
@@ -2024,6 +2038,52 @@
} catch (e) { alert("생성 실패: " + e.message); } } catch (e) { alert("생성 실패: " + e.message); }
}); });
// ── 좌측 프로젝트 목록 ✎ 버튼 — 프로젝트 수정 모달(홈 화면과 동일).
// 트리는 renderTree() 로 매번 다시 그려지므로(라이브 새로고침 포함) 열기
// 로직을 재사용 가능한 함수로 두고, wireTreeHandlers 에서 매번 다시 건다.
const editModal = el("pj-modal-edit");
let getEditColor = null;
function openProjectEditModal(data) {
el("pj-e-id").value = data.id;
el("pj-e-name").value = data.name || "";
el("pj-e-desc").value = data.desc || "";
el("pj-e-start").value = data.start || "";
el("pj-e-due").value = data.due || "";
el("pj-e-done").checked = data.status === "completed";
getEditColor = renderColorPalette(el("pj-e-colors"), null, data.color);
openModal(editModal);
}
if (editModal) {
wireModalClose(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 p = projectsMeta.find(function (x) { return String(x.id) === String(id); });
if (p && !canDelete(p.created_by)) { alert(DELETE_DENIED); return; }
if (!confirm("이 프로젝트를 삭제할까요? 안의 업무도 모두 삭제됩니다.")) return;
try {
await api("DELETE", "/project/api/projects/" + id);
if (String(id) === String(projectId)) location.href = "/project/";
else location.reload();
} catch (e) { alert("삭제 실패: " + e.message); }
});
}
// ── 멤버 배정/제외 (관리자) ── // ── 멤버 배정/제외 (관리자) ──
const memberModal = el("pj-modal-member"); const memberModal = el("pj-modal-member");
wireModalClose(memberModal); wireModalClose(memberModal);