feat(project): 카드 드래그 순서변경/전체멤버/탭 슬라이드/내업무 실시간반영

- 홈 프로젝트 카드 드래그 재정렬(관리자, projects.sort_order 공유)
- 프로젝트 제목 색상 pill 스타일로 강조
- 보드 세션 우클릭에 "업무 추가" 메뉴 추가
- 좌측 "멤버"→"프로젝트 멤버" + "전체 멤버" 그리드(클릭 시 담당 업무 팝업)
- 뷰 탭/스코프 토글에 슬라이드 하이라이트 애니메이션
- "내 업무" 패널이 8초 실시간 폴링에서 빠져 있던 문제 수정(홈/프로젝트 화면 모두 /api/my-tasks 로 patch)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-17 10:58:06 +09:00
parent 62ab883d24
commit 2f611767ca
7 changed files with 331 additions and 18 deletions
+12
View File
@@ -801,6 +801,18 @@ class ProjectStore:
raise KeyError(stage_id)
return self._serialize(row)
def reorder_projects(self, *, ordered_ids: list[int]) -> None:
"""홈 화면 프로젝트 카드 드래그 순서 변경. 최상위 프로젝트(parent_id
IS NULL)만 대상 — 서브프로젝트 정렬(부모 안에서의 순서)은 건드리지 않는다."""
with self._pool.connection() as conn:
with conn.transaction():
for i, pid in enumerate(ordered_ids):
conn.execute(
"UPDATE projects SET sort_order = %s "
"WHERE id = %s AND parent_id IS NULL",
(i, int(pid)),
)
def reorder_stages(self, *, project_id: int, ordered_ids: list[int]) -> None:
"""주어진 순서대로 sort_order 재배정. 해당 프로젝트 단계만 갱신."""
with self._pool.connection() as conn:
+48
View File
@@ -732,6 +732,22 @@ async def api_delete_project(request: Request, project_id: int) -> JSONResponse:
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 — 멤버 (관리자가 사용자 배정)
# ────────────────────────────────────────────────────────────
@@ -759,6 +775,38 @@ async def api_assignable_users(request: Request) -> JSONResponse:
return JSONResponse({"users": out})
@router.get("/api/all-users")
async def api_all_users(request: Request) -> JSONResponse:
"""프로젝트 화면 좌측 "전체 멤버" 표시용 — project 모듈 권한 보유 등록
사용자 전원. assignable-users 와 후보군은 같지만, 이건 멤버 배정(쓰기)이
아니라 단순 열람용이라 관리자가 아니어도 볼 수 있다."""
_require_user(request)
from app.main import user_store # noqa: WPS433
from app.store import has_module # noqa: WPS433
out = [
{
"email": u["email"],
"name": u.get("name") or u["email"].split("@")[0],
"id": u["email"].split("@")[0],
"avatar": u.get("picture") or "",
}
for u in user_store.list_all()
if has_module(u, "project")
]
out.sort(key=lambda x: x["id"])
return JSONResponse({"users": out})
@router.get("/api/users/tasks")
async def api_user_tasks(request: Request, email: str) -> JSONResponse:
"""'전체 멤버' 아이콘 클릭 팝업 — 지정한 사용자가 담당자인 업무 목록
(프로젝트/세션/마감일). /api/my-tasks 와 같은 조회를 이메일만 바꿔 재사용."""
_require_user(request)
st = _db_or_503(request)
return JSONResponse({"tasks": st.list_tasks(assignee_email=email, limit=500)})
@router.get("/api/projects/{project_id}/members")
async def api_list_members(request: Request, project_id: int) -> JSONResponse:
_require_user(request)
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/project.css?v=20260917i" />
<link rel="stylesheet" href="/static/project.css?v=20260917j" />
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
{% endblock %}
@@ -45,5 +45,5 @@
{% endif %}
</div>
<script src="/static/project.js?v=20260917n" defer></script>
<script src="/static/project.js?v=20260917o" defer></script>
{% endblock %}
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/project.css?v=20260917i" />
<link rel="stylesheet" href="/static/project.css?v=20260917j" />
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
{% endblock %}
@@ -393,5 +393,5 @@
<script>
window.PJ_COLORS = {{ ["#4573d2","#37a3a3","#62a420","#e8a33d","#e8384f","#aa62e3","#f06a6a","#5a6772"] | tojson }};
</script>
<script src="/static/project.js?v=20260917n" defer></script>
<script src="/static/project.js?v=20260917o" defer></script>
{% endblock %}
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/project.css?v=20260917i" />
<link rel="stylesheet" href="/static/project.css?v=20260917j" />
<!-- 구글 머티리얼 심볼(담당자 아이콘 등) — self-host -->
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
<!-- 타임라인 vis-timeline — self-host -->
@@ -66,7 +66,7 @@
<div class="pj-side-block">
<div class="pj-side-head">
<span>멤버</span>
<span>프로젝트 멤버</span>
{% if is_admin %}
<button type="button" class="pj-icon-btn" id="pj-add-member" title="멤버 배정">+</button>
{% endif %}
@@ -90,6 +90,13 @@
{% endfor %}
</ul>
</div>
<!-- 전체 멤버 — 등록된 전체 직원(프로젝트 권한 보유자). 아이콘 클릭 시
그 사람이 담당자인 업무를 팝업으로 보여준다(프로젝트/세션/마감일). -->
<div class="pj-side-block">
<div class="pj-side-head"><span>전체 멤버</span></div>
<div class="pj-allmembers-grid" id="pj-allmembers-grid">불러오는 중…</div>
</div>
</aside>
<!-- ── 우측: 뷰 ── -->
@@ -102,7 +109,9 @@
<button type="button" class="pj-tab" data-view="timeline">타임라인</button>
<button type="button" class="pj-tab" data-view="dashboard">대시보드</button>
</div>
<h1 class="pj-project-title">{{ project.name }}</h1>
<h1 class="pj-project-title">
<span class="pj-project-title-pill" style="--pj-color: {{ project.color or '#4573d2' }}">{{ project.name }}</span>
</h1>
<!-- 아사나처럼 기본은 "이 프로젝트"만 보여준다. 전체 프로젝트 보기는
예전처럼 접근 가능한 모든 프로젝트를 합쳐서 보여주는 모드. -->
<div class="pj-view-tabs" id="pj-scope-toggle">
@@ -451,6 +460,18 @@
</div>
{% endif %}
<!-- 전체 멤버 아이콘 클릭 팝업 — 그 사람이 담당자인 업무를 프로젝트/세션/마감일과 함께 나열 -->
<div class="pj-modal" id="pj-modal-member-tasks" hidden>
<div class="pj-modal-card pj-mt-card">
<h3 id="pj-mt-title">업무</h3>
<ul class="pj-mt-list" id="pj-mt-list"></ul>
<div class="pj-modal-actions">
<span style="flex:1"></span>
<button type="button" class="pj-btn" data-close>닫기</button>
</div>
</div>
</div>
<script>window.PJ_COLORS = {{ color_palette | tojson }};</script>
<!-- 데이터 -->
<script id="pj-data" type="application/json">
@@ -470,5 +491,5 @@
</script>
<script src="/static/vendor/vis-timeline/vis-timeline-graph2d.min.js"></script>
<script src="/static/project.js?v=20260917n" defer></script>
<script src="/static/project.js?v=20260917o" defer></script>
{% endblock %}