feat(project): 세션 색상/편집, 프로젝트 트리 드래그, 멤버업무 정렬, 댓글팝업 버그수정

- 좌측 프로젝트 리스트 드래그 재정렬(관리자, 홈 카드와 동일 API)
- 세션 추가/편집 팝업: 이름 + 아사나 10색 색상표(project_stages.color, 마이그레이션 004)
- 보드 세션 hover 아이콘 확대, 세션 제목 강조(업무 카드보다 크고 굵게)
- 완료 세션 반투명 + 보드 끝으로(정렬 기준만 변경 — 해제 시 원래 순서 자동 복귀)
- 전체 멤버 업무 팝업에 표 헤더(가운데정렬/굵게) + 정렬 아이콘 + 더블클릭 편집
- 댓글 채팅 팝업을 initCommentsChat() 공용화 — project.html에 모달이 없어 "내 업무"
  패널 댓글 더블클릭이 전혀 동작 안 했던 버그 수정(동적 배지 재바인딩 누락도 같이 수정)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-17 11:55:49 +09:00
parent 2f611767ca
commit 003d29d349
10 changed files with 438 additions and 174 deletions
+12 -5
View File
@@ -238,11 +238,13 @@ class ProjectStore:
return [self._serialize(r) for r in rows]
def add_stage(
self, *, project_id: int, name: str, is_done_stage: bool = False, created_by: str = ""
self, *, project_id: int, name: str, is_done_stage: bool = False,
color: str | None = None, created_by: str = "",
) -> dict[str, Any]:
nm = store.norm_str(name)
if not nm:
raise ValueError("세션 이름은 필수입니다.")
col = store.validate_stage_color(color)
with self._pool.connection() as conn:
nxt = conn.execute(
"SELECT COALESCE(MAX(sort_order), -1) + 1 AS n "
@@ -250,9 +252,9 @@ class ProjectStore:
(project_id,),
).fetchone()
row = conn.execute(
"INSERT INTO project_stages (project_id, name, sort_order, is_done_stage, created_by) "
"VALUES (%s,%s,%s,%s,%s) RETURNING *",
(project_id, nm, int(nxt["n"]), bool(is_done_stage),
"INSERT INTO project_stages (project_id, name, sort_order, is_done_stage, color, created_by) "
"VALUES (%s,%s,%s,%s,%s,%s) RETURNING *",
(project_id, nm, int(nxt["n"]), bool(is_done_stage), col,
store.norm_str(created_by, lower=True)),
).fetchone()
return self._serialize(row)
@@ -776,8 +778,10 @@ class ProjectStore:
# ════════════════════════════════════════════════════════════
def update_stage(
self, *, stage_id: int, name: str | None = None,
is_done_stage: bool | None = None,
is_done_stage: bool | None = None, color: Any = ...,
) -> dict[str, Any]:
"""color 는 지정 안 함(기본 `...`)과 명시적으로 지우기(`None`)를
구분해야 해서 다른 필드처럼 `is None` 이 아니라 `is not ...` 로 검사한다."""
sets: list[str] = []
params: list[Any] = []
if name is not None:
@@ -789,6 +793,9 @@ class ProjectStore:
if is_done_stage is not None:
sets.append("is_done_stage = %s")
params.append(bool(is_done_stage))
if color is not ...:
sets.append("color = %s")
params.append(store.validate_stage_color(color))
if not sets:
raise ValueError("변경할 내용이 없습니다.")
params.append(stage_id)
+3
View File
@@ -618,6 +618,7 @@ async def project_page(request: Request, project_id: int) -> HTMLResponse:
"avatars": avatars,
"priority_labels": store.PRIORITY_LABELS,
"color_palette": list(store.COLOR_PALETTE),
"stage_color_palette": list(store.STAGE_COLOR_PALETTE),
"today": today.isoformat(),
"year": year,
"month": month,
@@ -865,6 +866,7 @@ async def api_add_stage(
project_id=project_id,
name=payload.get("name", ""),
is_done_stage=bool(payload.get("is_done_stage", False)),
color=payload.get("color"),
created_by=user["email"],
)
except ValueError as exc:
@@ -1362,6 +1364,7 @@ async def api_update_stage(
stage_id=stage_id,
name=payload.get("name"),
is_done_stage=payload.get("is_done_stage"),
color=payload.get("color", ...),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
+21
View File
@@ -49,6 +49,21 @@ COLOR_PALETTE: tuple[str, ...] = (
"#5a6772", # slate
)
# 세션(칸반 컬럼) 색상 팔레트 — 아사나 태그/커스텀필드에서 쓰는 10색.
# 세션 생성/편집 팝업의 색상표가 이 순서 그대로 노출된다.
STAGE_COLOR_PALETTE: tuple[str, ...] = (
"#4573d2", # blue
"#37a3a3", # teal
"#62a420", # green
"#e8a33d", # amber
"#e8384f", # red
"#aa62e3", # purple
"#f06a6a", # coral
"#5a6772", # slate
"#e8398a", # pink
"#a15c43", # brown
)
# ════════════════════════════════════════════════════════════
# 대한민국 국경일·공휴일(2026~2030) — 프로젝트 달력 표시 전용.
#
@@ -181,6 +196,12 @@ def validate_color(color: Any) -> str:
return COLOR_PALETTE[0]
def validate_stage_color(color: Any) -> str | None:
"""세션 색상 — 팔레트 밖 값/빈값은 저장 안 함(NULL, 기본 회색 표시)."""
v = norm_str(color).lower()
return v if v in STAGE_COLOR_PALETTE else None
def validate_date(d: Any) -> str | None:
"""'YYYY-MM-DD' 형식만 통과. 빈값은 None."""
from datetime import date as _date # noqa: WPS433
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/project.css?v=20260917j" />
<link rel="stylesheet" href="/static/project.css?v=20260917k" />
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
{% endblock %}
@@ -45,5 +45,5 @@
{% endif %}
</div>
<script src="/static/project.js?v=20260917o" defer></script>
<script src="/static/project.js?v=20260917p" defer></script>
{% endblock %}
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/project.css?v=20260917j" />
<link rel="stylesheet" href="/static/project.css?v=20260917k" />
<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=20260917o" defer></script>
<script src="/static/project.js?v=20260917p" defer></script>
{% endblock %}
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/project.css?v=20260917j" />
<link rel="stylesheet" href="/static/project.css?v=20260917k" />
<!-- 구글 머티리얼 심볼(담당자 아이콘 등) — self-host -->
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
<!-- 타임라인 vis-timeline — self-host -->
@@ -290,6 +290,23 @@
</div>
</div>
<!-- 세션 추가/편집 모달 — 보드의 "+ 세션 추가" 버튼과 세션 헤더의 ✎(세션 편집) 공용 -->
<div class="pj-modal" id="pj-modal-stage" hidden>
<div class="pj-modal-card">
<h3 id="pj-stage-modal-title">새 세션</h3>
<input type="hidden" id="pj-stg-id" />
<label>이름<input type="text" id="pj-stg-name" placeholder="세션 이름" /></label>
<label>색상
<span class="pj-color-palette" id="pj-stg-colors"></span>
</label>
<div class="pj-modal-actions">
<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-stage">저장</button>
</div>
</div>
</div>
<!-- 업무 모달 -->
<div class="pj-modal" id="pj-modal-task" hidden>
<div class="pj-modal-card pj-task-modal-card">
@@ -460,10 +477,32 @@
</div>
{% endif %}
<!-- 댓글만 보는 채팅형 팝업 — 업무 카드/내 업무의 말풍선 아이콘 더블클릭(홈 화면과 동일 기능) -->
<div class="pj-modal" id="pj-modal-comments" hidden>
<div class="pj-modal-card pj-modal-chat">
<div class="pj-chat-head">
<h3 id="pj-cm-title">댓글</h3>
</div>
<input type="hidden" id="pj-cm-task-id" />
<ul class="pj-chat-list" id="pj-chat-list"></ul>
<div class="pj-comment-form">
<input type="text" id="pj-cm-input" placeholder="댓글 입력…" />
<button type="button" class="pj-btn pj-btn-primary" id="pj-cm-send">전송</button>
</div>
</div>
</div>
<!-- 전체 멤버 아이콘 클릭 팝업 — 그 사람이 담당자인 업무를 프로젝트/세션/마감일과 함께 나열 -->
<div class="pj-modal" id="pj-modal-member-tasks" hidden>
<div class="pj-modal-card pj-mt-card">
<h3 id="pj-mt-title">업무</h3>
<p class="pj-modal-hint">더블클릭하면 업무 편집 창이 열립니다.</p>
<div class="pj-mt-head" id="pj-mt-head">
<span class="pj-mt-h" data-sort="project">프로젝트<span class="pj-mt-sort-icon"></span></span>
<span class="pj-mt-h" data-sort="stage">세션<span class="pj-mt-sort-icon"></span></span>
<span class="pj-mt-h" data-sort="title">업무<span class="pj-mt-sort-icon"></span></span>
<span class="pj-mt-h" data-sort="due">마감일<span class="pj-mt-sort-icon"></span></span>
</div>
<ul class="pj-mt-list" id="pj-mt-list"></ul>
<div class="pj-modal-actions">
<span style="flex:1"></span>
@@ -472,7 +511,10 @@
</div>
</div>
<script>window.PJ_COLORS = {{ color_palette | tojson }};</script>
<script>
window.PJ_COLORS = {{ color_palette | tojson }};
window.PJ_STAGE_COLORS = {{ stage_color_palette | tojson }};
</script>
<!-- 데이터 -->
<script id="pj-data" type="application/json">
{
@@ -491,5 +533,5 @@
</script>
<script src="/static/vendor/vis-timeline/vis-timeline-graph2d.min.js"></script>
<script src="/static/project.js?v=20260917o" defer></script>
<script src="/static/project.js?v=20260917p" defer></script>
{% endblock %}