feat(project): 홈 화면 업무추가·멤버표시·댓글팝업·실시간 새로고침 추가

- 카드 이름 옆 호버 노출 업무 추가 버튼(기존 업무편집 팝업을 생성 모드로 재사용)
- 카드 이름 아래 배정 멤버 아이콘+이름 표시, 새 프로젝트 모달에서 멤버 다중 선택
- 업무별 댓글 말풍선+개수 표시, 더블클릭 시 카톡형 댓글 전용 팝업
- 확인 안 한 새 댓글은 말풍선이 커지며 반짝임(localStorage 로 확인 여부 추적)
- GET /project/api/live-version 폴링으로 다른 사용자의 변경사항을 자동 새로고침
- 날짜 표기를 yy/mm/dd(요일)에서 mm/dd(요일)로 변경

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 13:03:02 +09:00
parent 1aad9844dd
commit f54344c3ef
8 changed files with 344 additions and 27 deletions
+32 -2
View File
@@ -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"))