feat(project): 홈 카드도 완료 체크 시 반투명 + 대시보드 끝으로(세션과 동일 원칙)

기존엔 완료 프로젝트가 "완료된 프로젝트" 별도 섹션(숨기기 토글 포함)으로
빠졌는데, 요청에 따라 세션 완료 처리와 같은 방식으로 바꿈 — 같은 그리드
안에서 완료 여부 → sort_order → id 순으로 안정 정렬해 완료면 맨 끝으로,
해제하면 sort_order 기준 원래 자리로 자동 복귀(resortProjectGrid, 서버
쪽도 index() 에서 같은 기준으로 미리 정렬해 내려줌: _group 아님, 별도
정렬 한 줄 sorted(tree, key=...)). 완료 섹션/숨기기 토글 관련 죽은
코드(JS·CSS·템플릿) 정리.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-17 14:48:19 +09:00
parent 8d765ec60d
commit 4a2e20520e
6 changed files with 41 additions and 57 deletions
+6 -5
View File
@@ -389,9 +389,11 @@ async def index(request: Request) -> HTMLResponse:
member_email=None if admin else user["email"], member_email=None if admin else user["email"],
) )
tree = _build_tree(projects) tree = _build_tree(projects)
# 진행중/완료 프로젝트를 구분해 별도 섹션으로 표시. # 완료 프로젝트 별도 섹션 대신, 같은 대시보드 안에서 반투명 + 맨
active_projects = [p for p in tree if p.get("status") != "completed"] # 끝으로만 보낸다(세션 완료 처리와 같은 원칙). sorted() 는 안정 정렬이라
done_projects = [p for p in tree if p.get("status") == "completed"] # tree 가 이미 sort_order 순이면 완료 여부로만 다시 나눠도 각 그룹
# 내부 순서는 그대로 유지된다 — 완료 해제 시 원래 자리로 자동 복귀.
home_projects = sorted(tree, key=lambda p: p.get("status") == "completed")
# 아바타 맵: 이메일(소문자) → 구글 프로필 이미지 URL (홈 카드 업무 담당자 표시용) # 아바타 맵: 이메일(소문자) → 구글 프로필 이미지 URL (홈 카드 업무 담당자 표시용)
from app.main import user_store # noqa: WPS433 from app.main import user_store # noqa: WPS433
@@ -473,8 +475,7 @@ async def index(request: Request) -> HTMLResponse:
"page_title": "프로젝트 관리", "page_title": "프로젝트 관리",
"page_subtitle": "달력·타임라인·보드로 프로젝트를 관리하세요.", "page_subtitle": "달력·타임라인·보드로 프로젝트를 관리하세요.",
"force_sidebar_collapsed": True, "force_sidebar_collapsed": True,
"active_projects": active_projects, "home_projects": home_projects,
"done_projects": done_projects,
"my_projects": my_projects, "my_projects": my_projects,
"avatars": avatars, "avatars": avatars,
"home_tasks": home_tasks, "home_tasks": home_tasks,
@@ -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=20260917v" /> <link rel="stylesheet" href="/static/project.css?v=20260917w" />
<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=20260917y" defer></script> <script src="/static/project.js?v=20260917z" 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=20260917v" /> <link rel="stylesheet" href="/static/project.css?v=20260917w" />
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" /> <link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
{% endblock %} {% endblock %}
@@ -21,7 +21,8 @@
data-color="{{ p.color }}" data-color="{{ p.color }}"
data-status="{{ p.status or 'active' }}" data-status="{{ p.status or 'active' }}"
data-creator="{{ p.created_by or '' }}" data-creator="{{ p.created_by or '' }}"
data-has-stages="{{ 'true' if p.has_stages else 'false' }}"> data-has-stages="{{ 'true' if p.has_stages else 'false' }}"
data-sort-order="{{ p.sort_order or 0 }}">
<a class="pj-project-cardlink" href="/project/p/{{ p.id }}"> <a class="pj-project-cardlink" href="/project/p/{{ p.id }}">
<div class="pj-project-header"> <div class="pj-project-header">
<span class="pj-project-name">{{ p.name }} <span class="pj-project-name">{{ p.name }}
@@ -124,29 +125,19 @@
</div> </div>
</div> </div>
{% if not active_projects and not done_projects %} {% if not home_projects %}
<div class="pj-empty"> <div class="pj-empty">
아직 프로젝트가 없습니다. 상단의 <b>새 프로젝트</b> 버튼으로 만들어 보세요. 아직 프로젝트가 없습니다. 상단의 <b>새 프로젝트</b> 버튼으로 만들어 보세요.
</div> </div>
{% elif not active_projects %}
<div class="pj-empty pj-empty-sm">진행중인 프로젝트가 없습니다.</div>
{% endif %} {% endif %}
<!-- 완료 프로젝트는 별도 섹션이 아니라 같은 그리드 안에서 반투명 +
맨 끝으로만(세션 완료 처리와 같은 원칙). 서버가 이미 정렬해서
내려주고(완료 여부로만 안정 정렬), 완료 토글 시 JS가 같은
기준으로 카드를 다시 배치한다(resortProjectGrid). -->
<div class="pj-project-grid" id="pj-project-grid"> <div class="pj-project-grid" id="pj-project-grid">
{% for p in active_projects %}{{ project_card(p) }}{% endfor %} {% for p in home_projects %}{{ project_card(p) }}{% endfor %}
</div> </div>
{% if done_projects %}
<div class="pj-section-head pj-section-head-done">
<h2>완료된 프로젝트</h2>
<button type="button" class="pj-btn" id="pj-toggle-done" data-count="{{ done_projects | length }}">
완료 {{ done_projects | length }}개 숨기기
</button>
</div>
<div class="pj-project-grid" id="pj-project-grid-done">
{% for p in done_projects %}{{ project_card(p) }}{% endfor %}
</div>
{% endif %}
</section> </section>
<!-- 내 업무 --> <!-- 내 업무 -->
@@ -429,5 +420,5 @@
window.PJ_COLORS = {{ ["#4573d2","#37a3a3","#62a420","#e8a33d","#e8384f","#aa62e3","#f06a6a","#5a6772"] | tojson }}; window.PJ_COLORS = {{ ["#4573d2","#37a3a3","#62a420","#e8a33d","#e8384f","#aa62e3","#f06a6a","#5a6772"] | tojson }};
window.PJ_STAGE_COLORS = {{ ["#4573d2","#37a3a3","#62a420","#e8a33d","#e8384f","#aa62e3","#f06a6a","#5a6772","#e8398a","#a15c43"] | tojson }}; window.PJ_STAGE_COLORS = {{ ["#4573d2","#37a3a3","#62a420","#e8a33d","#e8384f","#aa62e3","#f06a6a","#5a6772","#e8398a","#a15c43"] | tojson }};
</script> </script>
<script src="/static/project.js?v=20260917y" defer></script> <script src="/static/project.js?v=20260917z" 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=20260917v" /> <link rel="stylesheet" href="/static/project.css?v=20260917w" />
<!-- 구글 머티리얼 심볼(담당자 아이콘 등) — 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 -->
@@ -543,5 +543,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=20260917y" defer></script> <script src="/static/project.js?v=20260917z" defer></script>
{% endblock %} {% endblock %}
-2
View File
@@ -140,8 +140,6 @@
.pj-project-tasks-empty { padding: 4px 16px 14px; font-size: 12px; color: #b7bdc5; border-top: 1px solid #f0f1f3; margin-top: 2px; } .pj-project-tasks-empty { padding: 4px 16px 14px; font-size: 12px; color: #b7bdc5; border-top: 1px solid #f0f1f3; margin-top: 2px; }
/* 완료 프로젝트 섹션 헤더 */ /* 완료 프로젝트 섹션 헤더 */
.pj-section-head-done { margin-top: 22px; }
.pj-section-head-done h2 { font-size: 14px; color: #6b7280; }
/* 홈/프로젝트 상세 공통 "내 업무" 패널 — 부모 grid 행(1fr)에 맞춰 높이를 /* 홈/프로젝트 상세 공통 "내 업무" 패널 — 부모 grid 행(1fr)에 맞춰 높이를
확정하고 자기 안에서만 스크롤. 300px 는 padding 포함(border-box). */ 확정하고 자기 안에서만 스크롤. 300px 는 padding 포함(border-box). */
+21 -27
View File
@@ -473,7 +473,7 @@
// ════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════
function initHome() { function initHome() {
const grid = el("pj-project-grid"); const grid = el("pj-project-grid");
if (!grid && !el("pj-toggle-done")) return; // 홈 아님 if (!grid) return; // 홈 아님
const wrap = document.querySelector(".pj-wrap"); const wrap = document.querySelector(".pj-wrap");
const me = ((wrap && wrap.dataset.me) || "").toLowerCase(); const me = ((wrap && wrap.dataset.me) || "").toLowerCase();
const isSuper = !!(wrap && wrap.dataset.isSuper === "true"); const isSuper = !!(wrap && wrap.dataset.isSuper === "true");
@@ -570,10 +570,24 @@
} }
} else if (dueChip) dueChip.remove(); } else if (dueChip) dueChip.remove();
} }
// 진행중 ↔ 완료 그리드 이동. 완료 그리드가 화면에 아직 없으면(첫 완료 // 완료 체크 시 반투명 + 대시보드 맨 끝으로, 해제하면 sort_order 기준
// 프로젝트) 다음 새로고침까지 원래 위치에 남는다 — 드문 경우라 허용. // 원래 자리로(세션 완료 처리와 같은 원칙 — resortProjectGrid 가 매번
const targetGrid = el(p.status === "completed" ? "pj-project-grid-done" : "pj-project-grid"); // "완료 여부 → sort_order → id" 순으로 안정 재배치한다).
if (targetGrid && card.parentElement !== targetGrid) targetGrid.appendChild(card); resortProjectGrid();
}
function resortProjectGrid() {
const grid = el("pj-project-grid");
if (!grid) return;
const cards = Array.prototype.slice.call(grid.querySelectorAll(".pj-project-card"));
cards.sort(function (a, b) {
const da = a.dataset.status === "completed" ? 1 : 0;
const db = b.dataset.status === "completed" ? 1 : 0;
if (da !== db) return da - db;
const oa = Number(a.dataset.sortOrder) || 0, ob = Number(b.dataset.sortOrder) || 0;
if (oa !== ob) return oa - ob;
return Number(a.dataset.id) - Number(b.dataset.id);
});
cards.forEach(function (c) { grid.appendChild(c); });
} }
// 새 프로젝트 카드 DOM — project_card(p) 매크로(index.html)를 그대로 미러링. // 새 프로젝트 카드 DOM — project_card(p) 매크로(index.html)를 그대로 미러링.
// 방금 만든 프로젝트라 멤버/업무는 아직 없다(멤버는 생성 직후 별도 배정, // 방금 만든 프로젝트라 멤버/업무는 아직 없다(멤버는 생성 직후 별도 배정,
@@ -586,7 +600,7 @@
' data-desc="' + escapeHtml(p.description || "") + '"' + ' data-desc="' + escapeHtml(p.description || "") + '"' +
' data-start="' + (p.start_date || "") + '" data-due="' + (p.due_date || "") + '"' + ' data-start="' + (p.start_date || "") + '" data-due="' + (p.due_date || "") + '"' +
' data-color="' + color + '" data-status="active" data-creator="' + escapeHtml(p.created_by || "") + '"' + ' data-color="' + color + '" data-status="active" data-creator="' + escapeHtml(p.created_by || "") + '"' +
' data-has-stages="false">' + ' data-has-stages="false" data-sort-order="' + (p.sort_order || 0) + '">' +
'<a class="pj-project-cardlink" href="/project/p/' + p.id + '">' + '<a class="pj-project-cardlink" href="/project/p/' + p.id + '">' +
'<div class="pj-project-header"><span class="pj-project-name">' + escapeHtml(p.name || "") + "</span></div>" + '<div class="pj-project-header"><span class="pj-project-name">' + escapeHtml(p.name || "") + "</span></div>" +
'<div class="pj-project-body">' + '<div class="pj-project-body">' +
@@ -872,26 +886,6 @@
updateMyTreeCounts(groupEl); updateMyTreeCounts(groupEl);
} }
// ── 완료 프로젝트 섹션 접기/펼치기 (localStorage 기억) ──
// display 를 인라인 스타일로 직접 제어 — 외부 CSS 규칙과의 특이도/우선순위
// 문제를 원천 차단(어떤 스타일시트보다 인라인 style 이 항상 우선한다).
const toggleBtn = el("pj-toggle-done");
const doneGrid = el("pj-project-grid-done");
if (toggleBtn && doneGrid) {
const KEY = "pj_hide_done";
let hideDone = localStorage.getItem(KEY) === "1";
function apply() {
doneGrid.style.display = hideDone ? "none" : "";
const n = toggleBtn.dataset.count;
toggleBtn.textContent = hideDone ? "완료 " + n + "개 보기" : "완료 " + n + "개 숨기기";
}
apply();
toggleBtn.addEventListener("click", function () {
hideDone = !hideDone;
try { localStorage.setItem(KEY, hideDone ? "1" : "0"); } catch (_) {}
apply();
});
}
// ── 새 프로젝트 모달 ── // ── 새 프로젝트 모달 ──
const newBtn = el("pj-new-project"); const newBtn = el("pj-new-project");
@@ -1349,7 +1343,7 @@
// 프로젝트 카드 드래그 순서 변경 — 진행중/완료 그리드 각각 안에서만 // 프로젝트 카드 드래그 순서 변경 — 진행중/완료 그리드 각각 안에서만
// 재정렬(그리드 사이 드래그로 옮기면 상태가 바뀐 것처럼 보이니 막는다). // 재정렬(그리드 사이 드래그로 옮기면 상태가 바뀐 것처럼 보이니 막는다).
function wireProjectGridDrag() { function wireProjectGridDrag() {
const grids = [el("pj-project-grid"), el("pj-project-grid-done")].filter(Boolean); const grids = [el("pj-project-grid")].filter(Boolean);
if (!grids.length) return; if (!grids.length) return;
let dragCard = null, sourceGrid = null; let dragCard = null, sourceGrid = null;
grids.forEach(function (grid) { grids.forEach(function (grid) {