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:
@@ -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 ""
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 직렬화
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block head_extra %}
|
||||
<link rel="stylesheet" href="/static/project.css?v=20260915e" />
|
||||
<link rel="stylesheet" href="/static/project.css?v=20260915f" />
|
||||
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
|
||||
{% endblock %}
|
||||
|
||||
@@ -45,5 +45,5 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script src="/static/project.js?v=20260915d" defer></script>
|
||||
<script src="/static/project.js?v=20260915e" defer></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block head_extra %}
|
||||
<link rel="stylesheet" href="/static/project.css?v=20260915e" />
|
||||
<link rel="stylesheet" href="/static/project.css?v=20260915f" />
|
||||
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
|
||||
{% endblock %}
|
||||
|
||||
@@ -28,6 +28,17 @@
|
||||
{% if p.status == 'completed' %}<span class="pj-chip pj-chip-sm">완료</span>{% endif %}
|
||||
</div>
|
||||
{% if p.description %}<div class="pj-project-desc">{{ p.description }}</div>{% endif %}
|
||||
{% if p.members %}
|
||||
<div class="pj-project-members">
|
||||
{% for m in p.members %}
|
||||
<span class="pj-project-member">
|
||||
{% if m.avatar %}<img class="pj-avatar pj-gicon-sm" src="{{ m.avatar }}" alt="" />
|
||||
{% else %}<span class="material-symbols-outlined pj-gicon pj-gicon-sm">account_circle</span>{% endif %}
|
||||
{{ m.name }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="pj-project-meta">
|
||||
{% if p.due_date %}<span class="pj-chip">마감 {{ p.due_label }}</span>{% endif %}
|
||||
{% if p.task_total %}<span class="pj-chip">업무 {{ p.task_done }}/{{ p.task_total }}</span>{% endif %}
|
||||
@@ -39,11 +50,18 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
{% if is_admin %}
|
||||
<button type="button" class="pj-icon-btn pj-card-edit" title="프로젝트 수정">
|
||||
<span class="material-symbols-outlined">edit</span>
|
||||
</button>
|
||||
{% endif %}
|
||||
<div class="pj-card-actions">
|
||||
{% if p.can_add_task %}
|
||||
<button type="button" class="pj-icon-btn pj-card-addtask" data-project-id="{{ p.id }}" title="업무 추가">
|
||||
<span class="material-symbols-outlined">add_task</span>
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if is_admin %}
|
||||
<button type="button" class="pj-icon-btn pj-card-edit" title="프로젝트 수정">
|
||||
<span class="material-symbols-outlined">edit</span>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if p.task_preview %}
|
||||
<ul class="pj-project-tasklist">
|
||||
@@ -64,6 +82,11 @@
|
||||
{% if t.due_date %}
|
||||
<span class="pj-project-task-due {% if t.due_overdue %}is-overdue{% endif %}">{{ t.due_label }}</span>
|
||||
{% endif %}
|
||||
{% if t.comment_count %}
|
||||
<span class="pj-cmt-badge" data-task-id="{{ t.id }}" data-count="{{ t.comment_count }}" title="댓글 {{ t.comment_count }}개">
|
||||
<span class="pj-cmt-ic">💬</span><span class="pj-cmt-n">{{ t.comment_count }}</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
@@ -150,6 +173,11 @@
|
||||
</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
{% if t.comment_count %}
|
||||
<span class="pj-cmt-badge" data-task-id="{{ t.id }}" data-count="{{ t.comment_count }}" title="댓글 {{ t.comment_count }}개">
|
||||
<span class="pj-cmt-ic">💬</span><span class="pj-cmt-n">{{ t.comment_count }}</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
@@ -176,6 +204,9 @@
|
||||
<label>색상
|
||||
<span class="pj-color-palette" id="pj-f-colors"></span>
|
||||
</label>
|
||||
<div class="pj-field-label">멤버
|
||||
<div class="pj-member-picker" id="pj-f-members">불러오는 중…</div>
|
||||
</div>
|
||||
<div class="pj-modal-actions">
|
||||
<button type="button" class="pj-btn" data-close>취소</button>
|
||||
<button type="button" class="pj-btn pj-btn-primary" id="pj-save-project">만들기</button>
|
||||
@@ -273,11 +304,24 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 댓글만 보는 팝업 (업무 카드의 말풍선 아이콘 더블클릭) -->
|
||||
<div class="pj-modal" id="pj-modal-comments" hidden>
|
||||
<div class="pj-modal-card pj-modal-chat">
|
||||
<h3>댓글</h3>
|
||||
<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>
|
||||
|
||||
<script id="pj-home-data" type="application/json">
|
||||
{{ home_tasks | tojson }}
|
||||
</script>
|
||||
<script>
|
||||
window.PJ_COLORS = {{ ["#4573d2","#37a3a3","#62a420","#e8a33d","#e8384f","#aa62e3","#f06a6a","#5a6772"] | tojson }};
|
||||
</script>
|
||||
<script src="/static/project.js?v=20260915d" defer></script>
|
||||
<script src="/static/project.js?v=20260915e" defer></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block head_extra %}
|
||||
<link rel="stylesheet" href="/static/project.css?v=20260915e" />
|
||||
<link rel="stylesheet" href="/static/project.css?v=20260915f" />
|
||||
<!-- 구글 머티리얼 심볼(담당자 아이콘 등) — self-host -->
|
||||
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
|
||||
<!-- 타임라인 vis-timeline — self-host -->
|
||||
@@ -291,5 +291,5 @@
|
||||
</script>
|
||||
|
||||
<script src="/static/vendor/vis-timeline/vis-timeline-graph2d.min.js"></script>
|
||||
<script src="/static/project.js?v=20260915d" defer></script>
|
||||
<script src="/static/project.js?v=20260915e" defer></script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user