From f54344c3efc85b6eb562f84c68acbbf3fa61d3ef Mon Sep 17 00:00:00 2001 From: king Date: Tue, 15 Sep 2026 13:03:02 +0900 Subject: [PATCH] =?UTF-8?q?feat(project):=20=ED=99=88=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=20=EC=97=85=EB=AC=B4=EC=B6=94=EA=B0=80=C2=B7=EB=A9=A4=EB=B2=84?= =?UTF-8?q?=ED=91=9C=EC=8B=9C=C2=B7=EB=8C=93=EA=B8=80=ED=8C=9D=EC=97=85?= =?UTF-8?q?=C2=B7=EC=8B=A4=EC=8B=9C=EA=B0=84=20=EC=83=88=EB=A1=9C=EA=B3=A0?= =?UTF-8?q?=EC=B9=A8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 카드 이름 옆 호버 노출 업무 추가 버튼(기존 업무편집 팝업을 생성 모드로 재사용) - 카드 이름 아래 배정 멤버 아이콘+이름 표시, 새 프로젝트 모달에서 멤버 다중 선택 - 업무별 댓글 말풍선+개수 표시, 더블클릭 시 카톡형 댓글 전용 팝업 - 확인 안 한 새 댓글은 말풍선이 커지며 반짝임(localStorage 로 확인 여부 추적) - GET /project/api/live-version 폴링으로 다른 사용자의 변경사항을 자동 새로고침 - 날짜 표기를 yy/mm/dd(요일)에서 mm/dd(요일)로 변경 Co-Authored-By: Claude Sonnet 5 --- app/modules/project/db.py | 17 ++ app/modules/project/router.py | 34 ++- .../project/templates/project/inbox.html | 4 +- .../project/templates/project/index.html | 58 +++++- .../project/templates/project/project.html | 4 +- app/static/project.css | 48 ++++- app/static/project.js | 196 +++++++++++++++++- docs/PROJECT_MODULE.md | 10 +- 8 files changed, 344 insertions(+), 27 deletions(-) diff --git a/app/modules/project/db.py b/app/modules/project/db.py index da3d058..5f09203 100644 --- a/app/modules/project/db.py +++ b/app/modules/project/db.py @@ -716,6 +716,23 @@ class ProjectStore: ) return cur.rowcount or 0 + # ════════════════════════════════════════════════════════════ + # 실시간 새로고침 — 프로젝트/업무/댓글/멤버/단계 중 가장 최근 변경 시각 + # ════════════════════════════════════════════════════════════ + def latest_version(self) -> str: + with self._pool.connection() as conn: + row = conn.execute( + "SELECT GREATEST(" + " COALESCE((SELECT MAX(updated_at) FROM projects), 'epoch'::timestamptz)," + " COALESCE((SELECT MAX(updated_at) FROM tasks), 'epoch'::timestamptz)," + " COALESCE((SELECT MAX(created_at) FROM task_comments), 'epoch'::timestamptz)," + " COALESCE((SELECT MAX(created_at) FROM project_members), 'epoch'::timestamptz)," + " COALESCE((SELECT MAX(created_at) FROM project_stages), 'epoch'::timestamptz)" + ") AS ts" + ).fetchone() + ts = row["ts"] if row else None + return ts.isoformat() if ts else "" + # ════════════════════════════════════════════════════════════ # 직렬화 # ════════════════════════════════════════════════════════════ diff --git a/app/modules/project/router.py b/app/modules/project/router.py index a9c7c8f..de0d47b 100644 --- a/app/modules/project/router.py +++ b/app/modules/project/router.py @@ -195,14 +195,14 @@ _WEEKDAYS_KR = ["월", "화", "수", "목", "금", "토", "일"] def _fmt_date_kr(d: str | None) -> str: - """'YYYY-MM-DD' → 'yy/mm/dd(요일)'. 형식이 아니면 원본 그대로.""" + """'YYYY-MM-DD' → 'mm/dd(요일)'. 형식이 아니면 원본 그대로.""" if not d: return "" try: dd = date.fromisoformat(d[:10]) except (ValueError, TypeError): return d - return dd.strftime("%y/%m/%d") + f"({_WEEKDAYS_KR[dd.weekday()]})" + return dd.strftime("%m/%d") + f"({_WEEKDAYS_KR[dd.weekday()]})" def _build_tree(projects: list[dict[str, Any]]) -> list[dict[str, Any]]: @@ -335,6 +335,18 @@ async def health(request: Request) -> JSONResponse: return JSONResponse({"status": "error", "detail": str(exc)}, status_code=500) +@router.get("/api/live-version") +async def api_live_version(request: Request) -> JSONResponse: + """프로젝트/업무/댓글/멤버/단계 중 가장 최근 변경 시각. + + 페이지를 열어둔 클라이언트가 주기적으로 조회해 값이 바뀌면 새로고침한다 + (다른 사용자가 프로젝트·업무·댓글을 추가/수정했을 때 실시간 반영용). + """ + _require_user(request) + st = _db_or_503(request) + return JSONResponse({"ts": st.latest_version()}) + + @router.get("/", response_class=HTMLResponse) async def index(request: Request) -> HTMLResponse: from app.main import build_erp_nav, render_template # noqa: WPS433 @@ -389,6 +401,24 @@ async def index(request: Request) -> HTMLResponse: p["task_more"] = max(0, len(ordered_tasks) - 8) p["due_label"] = _fmt_date_kr(p.get("due_date")) + # 프로젝트 이름 아래 표시할 멤버(아이콘+이름) + 업무추가 버튼 노출 권한 + # (관리자 또는 해당 프로젝트 멤버/owner). + p_members = st.list_members(project_id=p["id"]) + p["members"] = [ + { + "email": m["user_email"], + "name": m.get("user_name") or m["user_email"].split("@")[0], + "avatar": avatars.get(store.norm_str(m["user_email"], lower=True), ""), + } + for m in p_members + ] + member_emails = {store.norm_str(m["user_email"], lower=True) for m in p_members} + p["can_add_task"] = ( + admin + or store.norm_str(user["email"], lower=True) in member_emails + or store.norm_str(p.get("owner_email"), lower=True) == store.norm_str(user["email"], lower=True) + ) + my_tasks = st.list_tasks(assignee_email=user["email"], limit=500) for t in my_tasks: t["start_label"] = _fmt_date_kr(t.get("start_date")) diff --git a/app/modules/project/templates/project/inbox.html b/app/modules/project/templates/project/inbox.html index 00bb54f..130a8fc 100644 --- a/app/modules/project/templates/project/inbox.html +++ b/app/modules/project/templates/project/inbox.html @@ -1,7 +1,7 @@ {% extends "erp_base.html" %} {% block head_extra %} - + {% endblock %} @@ -45,5 +45,5 @@ {% endif %} - + {% endblock %} diff --git a/app/modules/project/templates/project/index.html b/app/modules/project/templates/project/index.html index 5e1cbaf..4712ccc 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 %} @@ -28,6 +28,17 @@ {% if p.status == 'completed' %}완료{% endif %} {% if p.description %}
{{ p.description }}
{% endif %} + {% if p.members %} +
+ {% for m in p.members %} + + {% if m.avatar %} + {% else %}account_circle{% endif %} + {{ m.name }} + + {% endfor %} +
+ {% endif %}
{% if p.due_date %}마감 {{ p.due_label }}{% endif %} {% if p.task_total %}업무 {{ p.task_done }}/{{ p.task_total }}{% endif %} @@ -39,11 +50,18 @@ {% endif %}
- {% if is_admin %} - - {% endif %} +
+ {% if p.can_add_task %} + + {% endif %} + {% if is_admin %} + + {% endif %} +
{% if p.task_preview %}