From 17f2d562311349fea9bbeba440a3a3d0b033a76b Mon Sep 17 00:00:00 2001 From: king Date: Thu, 25 Jun 2026 16:34:09 +0900 Subject: [PATCH] =?UTF-8?q?feat(project):=20=ED=99=88=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=EC=A0=9D=ED=8A=B8=20=EC=B9=B4=EB=93=9C=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=20+=20=EC=99=84=EB=A3=8C=20=ED=94=84?= =?UTF-8?q?=EB=A1=9C=EC=A0=9D=ED=8A=B8=20=EB=B9=84=ED=99=9C=EC=84=B1/?= =?UTF-8?q?=EC=88=A8=EA=B8=B0=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 카드에 수정(연필) 버튼: 이름·설명·기간·색상·완료 토글 + 삭제 (관리자) - 프로젝트 상태에 'completed' 추가, update_project status 허용 - 완료 프로젝트: 흐리게 비활성 + 홈 목록 맨 끝으로 정렬 - '완료 N개 숨기기' 토글 버튼(localStorage 기억) Co-Authored-By: Claude Opus 4.8 --- app/modules/project/router.py | 2 + app/modules/project/store.py | 2 +- .../project/templates/project/index.html | 74 ++++++++++--- app/static/project.css | 20 +++- app/static/project.js | 102 ++++++++++++++---- 5 files changed, 165 insertions(+), 35 deletions(-) diff --git a/app/modules/project/router.py b/app/modules/project/router.py index b4d8def..b372577 100644 --- a/app/modules/project/router.py +++ b/app/modules/project/router.py @@ -319,6 +319,8 @@ async def index(request: Request) -> HTMLResponse: include_archived=False, member_email=None if admin else user["email"], ) + # 완료된 프로젝트는 비활성으로 맨 끝에 (홈 카드 정렬). 안정 정렬. + projects = sorted(projects, key=lambda p: 1 if p.get("status") == "completed" else 0) tree = _build_tree(projects) my_tasks = st.list_tasks(assignee_email=user["email"], limit=500) # 내 업무를 프로젝트별로 묶어 트리로 표시 diff --git a/app/modules/project/store.py b/app/modules/project/store.py index ff398f4..7abf8b5 100644 --- a/app/modules/project/store.py +++ b/app/modules/project/store.py @@ -22,7 +22,7 @@ PRIORITIES: tuple[str, ...] = ("low", "normal", "high") PRIORITY_LABELS: dict[str, str] = {"low": "낮음", "normal": "보통", "high": "높음"} # 프로젝트 상태 -PROJECT_STATUSES: tuple[str, ...] = ("active", "archived") +PROJECT_STATUSES: tuple[str, ...] = ("active", "completed", "archived") # 멤버 역할 MEMBER_ROLES: tuple[str, ...] = ("manager", "member") diff --git a/app/modules/project/templates/project/index.html b/app/modules/project/templates/project/index.html index bd97bde..c568f78 100644 --- a/app/modules/project/templates/project/index.html +++ b/app/modules/project/templates/project/index.html @@ -1,7 +1,7 @@ {% extends "erp_base.html" %} {% block head_extra %} - + {% endblock %} @@ -14,6 +14,12 @@ + + + {% endif %} - + {% endblock %} diff --git a/app/static/project.css b/app/static/project.css index bfde81d..45a13aa 100644 --- a/app/static/project.css +++ b/app/static/project.css @@ -35,10 +35,26 @@ .pj-project-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 14px; } .pj-project-card { - display: flex; gap: 12px; padding: 16px; border: 1px solid #e7e9ec; border-radius: 14px; - background: #fff; text-decoration: none; color: inherit; transition: box-shadow .14s, transform .1s; + position: relative; border: 1px solid #e7e9ec; border-radius: 14px; + background: #fff; transition: box-shadow .14s, transform .1s; } .pj-project-card:hover { box-shadow: 0 4px 16px rgba(0,0,0,.08); transform: translateY(-1px); } +.pj-project-cardlink { display: flex; gap: 12px; padding: 16px; text-decoration: none; color: inherit; } +/* 완료 프로젝트 — 비활성(흐리게) */ +.pj-project-card.is-done { background: #f6f7f8; opacity: .62; } +.pj-project-card.is-done:hover { opacity: .85; } +.pj-project-card.is-done .pj-project-dot { background: #9aa5b1; } +/* 완료 숨기기 토글 */ +.pj-project-grid.pj-hide-done .pj-project-card.is-done { display: none; } +/* 카드 수정 버튼 */ +.pj-card-edit { + position: absolute; top: 8px; right: 8px; width: 28px; height: 28px; + display: inline-flex; align-items: center; justify-content: center; + border-radius: 8px; color: #6b7280; opacity: 0; transition: opacity .12s; +} +.pj-card-edit .material-symbols-outlined { font-size: 18px; } +.pj-project-card:hover .pj-card-edit { opacity: 1; } +.pj-card-edit:hover { background: #eceef0; color: #1e1f21; } .pj-project-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--pj-color, #4573d2); margin-top: 5px; flex: 0 0 auto; } .pj-project-name { font-weight: 700; font-size: 15px; } .pj-project-desc { color: #6b7280; font-size: 12.5px; margin-top: 3px; } diff --git a/app/static/project.js b/app/static/project.js index 7911bd8..262741b 100644 --- a/app/static/project.js +++ b/app/static/project.js @@ -33,9 +33,9 @@ } // ── 색상 팔레트 렌더 ─────────────────────────────────────── - function renderColorPalette(container, onPick) { + function renderColorPalette(container, onPick, initial) { const colors = window.PJ_COLORS || ["#4573d2"]; - let selected = colors[0]; + let selected = (initial && colors.indexOf(initial) >= 0) ? initial : colors[0]; container.innerHTML = ""; colors.forEach(function (c) { const b = document.createElement("button"); @@ -60,27 +60,91 @@ // 홈 페이지 — 새 프로젝트 // ════════════════════════════════════════════════════════════ function initHome() { + const grid = el("pj-project-grid"); + if (!grid && !el("pj-toggle-done")) return; // 홈 아님 + + // ── 완료 프로젝트 숨기기 토글 (localStorage 기억) ── + const toggleBtn = el("pj-toggle-done"); + if (toggleBtn) { + const KEY = "pj_hide_done"; + function apply(hide) { + if (grid) grid.classList.toggle("pj-hide-done", hide); + const n = toggleBtn.dataset.count; + toggleBtn.textContent = hide ? "완료 " + n + "개 보기" : "완료 " + n + "개 숨기기"; + } + apply(localStorage.getItem(KEY) === "1"); + toggleBtn.addEventListener("click", function () { + const hide = !(grid && grid.classList.contains("pj-hide-done")); + try { localStorage.setItem(KEY, hide ? "1" : "0"); } catch (_) {} + apply(hide); + }); + } + + // ── 새 프로젝트 모달 ── const newBtn = el("pj-new-project"); const modal = el("pj-modal-project"); - if (!newBtn || !modal) return; - wireModalClose(modal); - const getColor = renderColorPalette(el("pj-f-colors")); + if (newBtn && modal) { + wireModalClose(modal); + const getColor = renderColorPalette(el("pj-f-colors")); + newBtn.addEventListener("click", function () { openModal(modal); }); + el("pj-save-project").addEventListener("click", async function () { + const name = el("pj-f-name").value.trim(); + if (!name) { alert("이름을 입력하세요."); return; } + try { + await api("POST", "/project/api/projects", { + name: name, + description: el("pj-f-desc").value.trim(), + start_date: el("pj-f-start").value || null, + due_date: el("pj-f-due").value || null, + color: getColor(), + }); + location.reload(); + } catch (e) { alert("생성 실패: " + e.message); } + }); + } - newBtn.addEventListener("click", function () { openModal(modal); }); - el("pj-save-project").addEventListener("click", async function () { - const name = el("pj-f-name").value.trim(); - if (!name) { alert("이름을 입력하세요."); return; } - try { - await api("POST", "/project/api/projects", { - name: name, - description: el("pj-f-desc").value.trim(), - start_date: el("pj-f-start").value || null, - due_date: el("pj-f-due").value || null, - color: getColor(), + // ── 프로젝트 수정 모달 ── + const editModal = el("pj-modal-edit"); + if (editModal && grid) { + wireModalClose(editModal); + let getEditColor = null; + grid.querySelectorAll(".pj-card-edit").forEach(function (btn) { + btn.addEventListener("click", function (e) { + e.preventDefault(); e.stopPropagation(); + const card = btn.closest(".pj-project-card"); + el("pj-e-id").value = card.dataset.id; + el("pj-e-name").value = card.dataset.name || ""; + el("pj-e-desc").value = card.dataset.desc || ""; + el("pj-e-start").value = card.dataset.start || ""; + el("pj-e-due").value = card.dataset.due || ""; + el("pj-e-done").checked = card.dataset.status === "completed"; + getEditColor = renderColorPalette(el("pj-e-colors"), null, card.dataset.color); + openModal(editModal); }); - location.reload(); - } catch (e) { alert("생성 실패: " + e.message); } - }); + }); + 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; + if (!confirm("이 프로젝트를 삭제할까요? 안의 업무도 모두 삭제됩니다.")) return; + try { await api("DELETE", "/project/api/projects/" + id); location.reload(); } + catch (e) { alert("삭제 실패: " + e.message); } + }); + } } // ════════════════════════════════════════════════════════════