fix(project): 프로젝트 순서변경 라우트 충돌 수정 + 세션편집 제목/드래그 손잡이 표시

- PUT /api/projects/order 가 /api/projects/{project_id} 뒤에 등록돼 있어
  "order" 가 project_id(int) 파싱 실패로 422 나던 버그 수정(라우트 등록 순서
  변경 — 경로 매칭은 등록 순서라 path param 뒤에 두면 리터럴 경로가 항상
  가려진다). 홈 카드/좌측 프로젝트 트리 드래그 재정렬 둘 다 이 버그였음.
- 세션 편집 팝업 제목에 세션 이름 표시
- 좌측 프로젝트 트리에 드래그 가능 손잡이(⣿) 표시(관리자, hover 시 노출)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-17 12:02:29 +09:00
parent 003d29d349
commit 6922679f51
6 changed files with 34 additions and 23 deletions
+21 -16
View File
@@ -703,6 +703,27 @@ async def api_create_subproject(
return JSONResponse({"project": project}, status_code=201) return JSONResponse({"project": project}, status_code=201)
@router.put("/api/projects/order")
async def api_reorder_projects(
request: Request, payload: dict[str, Any] = Body(...)
) -> JSONResponse:
"""홈 화면 프로젝트 카드 드래그 순서 변경. 최상위 프로젝트만 대상이며,
순서는 이 화면을 보는 모든 사용자에게 공유되므로(세션 재정렬과 동일 원칙)
관리자 전용으로 제한한다.
⚠️ 반드시 `PUT /api/projects/{project_id}` 보다 먼저 등록해야 한다 — 아니면
"order" 가 project_id 로 매칭 시도되어(정수 변환 실패로 422) 이 라우트가
영영 실행되지 않는다(경로 매칭은 등록 순서, path param 특이도 무관).
"""
_require_admin(request)
st = _db_or_503(request)
ids = payload.get("ordered_ids") or []
if not isinstance(ids, list) or not ids:
raise HTTPException(status_code=400, detail="ordered_ids 가 필요합니다.")
st.reorder_projects(ordered_ids=[int(i) for i in ids])
return JSONResponse({"ok": True})
@router.put("/api/projects/{project_id}") @router.put("/api/projects/{project_id}")
async def api_update_project( async def api_update_project(
request: Request, project_id: int, payload: dict[str, Any] = Body(...) request: Request, project_id: int, payload: dict[str, Any] = Body(...)
@@ -733,22 +754,6 @@ async def api_delete_project(request: Request, project_id: int) -> JSONResponse:
return JSONResponse({"ok": True}) return JSONResponse({"ok": True})
@router.put("/api/projects/order")
async def api_reorder_projects(
request: Request, payload: dict[str, Any] = Body(...)
) -> JSONResponse:
"""홈 화면 프로젝트 카드 드래그 순서 변경. 최상위 프로젝트만 대상이며,
순서는 이 화면을 보는 모든 사용자에게 공유되므로(세션 재정렬과 동일 원칙)
관리자 전용으로 제한한다."""
_require_admin(request)
st = _db_or_503(request)
ids = payload.get("ordered_ids") or []
if not isinstance(ids, list) or not ids:
raise HTTPException(status_code=400, detail="ordered_ids 가 필요합니다.")
st.reorder_projects(ordered_ids=[int(i) for i in ids])
return JSONResponse({"ok": True})
# ──────────────────────────────────────────────────────────── # ────────────────────────────────────────────────────────────
# JSON API — 멤버 (관리자가 사용자 배정) # JSON API — 멤버 (관리자가 사용자 배정)
# ──────────────────────────────────────────────────────────── # ────────────────────────────────────────────────────────────
@@ -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=20260917k" /> <link rel="stylesheet" href="/static/project.css?v=20260917l" />
<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=20260917p" defer></script> <script src="/static/project.js?v=20260917q" 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=20260917k" /> <link rel="stylesheet" href="/static/project.css?v=20260917l" />
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" /> <link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
{% 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=20260917p" defer></script> <script src="/static/project.js?v=20260917q" 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=20260917k" /> <link rel="stylesheet" href="/static/project.css?v=20260917l" />
<!-- 구글 머티리얼 심볼(담당자 아이콘 등) — 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 -->
@@ -533,5 +533,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=20260917p" defer></script> <script src="/static/project.js?v=20260917q" defer></script>
{% endblock %} {% endblock %}
+3
View File
@@ -229,6 +229,9 @@
.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-li[draggable="true"] { cursor: grab; } .pj-tree-li[draggable="true"] { cursor: grab; }
.pj-tree-li.is-dragging { opacity: .45; } .pj-tree-li.is-dragging { opacity: .45; }
/* 드래그 가능 손잡이 — 평소엔 흐리게 숨겨두고 행에 마우스 올리면 보인다 */
.pj-tree-handle { flex: 0 0 auto; width: 14px; text-align: center; color: #c2c6cc; font-size: 12px; cursor: grab; opacity: 0; transition: opacity .12s; }
.pj-tree-row:hover .pj-tree-handle { opacity: 1; }
/* 구글 머티리얼 심볼 아이콘(담당자) */ /* 구글 머티리얼 심볼 아이콘(담당자) */
.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; }
+4 -1
View File
@@ -1865,7 +1865,7 @@
if (stageModal) wireModalClose(stageModal); if (stageModal) wireModalClose(stageModal);
function openStageModal(stage) { function openStageModal(stage) {
if (!stageModal) return; if (!stageModal) return;
el("pj-stage-modal-title").textContent = stage ? "세션 편집" : "새 세션"; el("pj-stage-modal-title").textContent = stage ? "\"" + stage.name + "\" 세션 편집" : "새 세션";
el("pj-stg-id").value = stage ? stage.id : ""; el("pj-stg-id").value = stage ? stage.id : "";
el("pj-stg-name").value = stage ? stage.name : ""; el("pj-stg-name").value = stage ? stage.name : "";
getStageColor = renderColorPalette( getStageColor = renderColorPalette(
@@ -3037,8 +3037,11 @@
"</span>"; "</span>";
} }
const initial = escapeHtml((p.name || "?").trim().charAt(0).toUpperCase() || "?"); const initial = escapeHtml((p.name || "?").trim().charAt(0).toUpperCase() || "?");
// 드래그로 순서 변경 가능함을 알려주는 손잡이(⣿) — 관리자에게만 보인다.
const handle = isAdmin ? '<span class="pj-tree-handle" title="드래그로 순서 변경">⣿</span>' : "";
return '<li class="pj-tree-li"' + (isAdmin ? ' draggable="true"' : "") + ' data-id="' + p.id + '"><div class="pj-tree-row ' + return '<li class="pj-tree-li"' + (isAdmin ? ' draggable="true"' : "") + ' data-id="' + p.id + '"><div class="pj-tree-row ' +
(p.id === projectId ? "is-active" : "") + '">' + (p.id === projectId ? "is-active" : "") + '">' +
handle +
'<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>" + acts + "</div></li>"; '<span class="pj-tree-name">' + escapeHtml(p.name) + "</span></a>" + acts + "</div></li>";