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
+17
View File
@@ -716,6 +716,23 @@ class ProjectStore:
) )
return cur.rowcount or 0 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 ""
# ════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════
# 직렬화 # 직렬화
# ════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════
+32 -2
View File
@@ -195,14 +195,14 @@ _WEEKDAYS_KR = ["월", "화", "수", "목", "금", "토", "일"]
def _fmt_date_kr(d: str | None) -> str: def _fmt_date_kr(d: str | None) -> str:
"""'YYYY-MM-DD''yy/mm/dd(요일)'. 형식이 아니면 원본 그대로.""" """'YYYY-MM-DD''mm/dd(요일)'. 형식이 아니면 원본 그대로."""
if not d: if not d:
return "" return ""
try: try:
dd = date.fromisoformat(d[:10]) dd = date.fromisoformat(d[:10])
except (ValueError, TypeError): except (ValueError, TypeError):
return d 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]]: 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) 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) @router.get("/", response_class=HTMLResponse)
async def index(request: Request) -> HTMLResponse: async def index(request: Request) -> HTMLResponse:
from app.main import build_erp_nav, render_template # noqa: WPS433 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["task_more"] = max(0, len(ordered_tasks) - 8)
p["due_label"] = _fmt_date_kr(p.get("due_date")) 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) my_tasks = st.list_tasks(assignee_email=user["email"], limit=500)
for t in my_tasks: for t in my_tasks:
t["start_label"] = _fmt_date_kr(t.get("start_date")) t["start_label"] = _fmt_date_kr(t.get("start_date"))
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %} {% extends "erp_base.html" %}
{% block head_extra %} {% 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" /> <link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
{% endblock %} {% endblock %}
@@ -45,5 +45,5 @@
{% endif %} {% endif %}
</div> </div>
<script src="/static/project.js?v=20260915d" defer></script> <script src="/static/project.js?v=20260915e" defer></script>
{% endblock %} {% endblock %}
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %} {% extends "erp_base.html" %}
{% block head_extra %} {% 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" /> <link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
{% endblock %} {% endblock %}
@@ -28,6 +28,17 @@
{% if p.status == 'completed' %}<span class="pj-chip pj-chip-sm">완료</span>{% endif %} {% if p.status == 'completed' %}<span class="pj-chip pj-chip-sm">완료</span>{% endif %}
</div> </div>
{% if p.description %}<div class="pj-project-desc">{{ p.description }}</div>{% endif %} {% 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"> <div class="pj-project-meta">
{% if p.due_date %}<span class="pj-chip">마감 {{ p.due_label }}</span>{% endif %} {% 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 %} {% if p.task_total %}<span class="pj-chip">업무 {{ p.task_done }}/{{ p.task_total }}</span>{% endif %}
@@ -39,11 +50,18 @@
{% endif %} {% endif %}
</div> </div>
</a> </a>
<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 %} {% if is_admin %}
<button type="button" class="pj-icon-btn pj-card-edit" title="프로젝트 수정"> <button type="button" class="pj-icon-btn pj-card-edit" title="프로젝트 수정">
<span class="material-symbols-outlined">edit</span> <span class="material-symbols-outlined">edit</span>
</button> </button>
{% endif %} {% endif %}
</div>
{% if p.task_preview %} {% if p.task_preview %}
<ul class="pj-project-tasklist"> <ul class="pj-project-tasklist">
@@ -64,6 +82,11 @@
{% if t.due_date %} {% if t.due_date %}
<span class="pj-project-task-due {% if t.due_overdue %}is-overdue{% endif %}">{{ t.due_label }}</span> <span class="pj-project-task-due {% if t.due_overdue %}is-overdue{% endif %}">{{ t.due_label }}</span>
{% endif %} {% 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> </a>
</li> </li>
{% endfor %} {% endfor %}
@@ -150,6 +173,11 @@
</span> </span>
{% endif %} {% endif %}
</span> </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> </a>
</li> </li>
{% endfor %} {% endfor %}
@@ -176,6 +204,9 @@
<label>색상 <label>색상
<span class="pj-color-palette" id="pj-f-colors"></span> <span class="pj-color-palette" id="pj-f-colors"></span>
</label> </label>
<div class="pj-field-label">멤버
<div class="pj-member-picker" id="pj-f-members">불러오는 중…</div>
</div>
<div class="pj-modal-actions"> <div class="pj-modal-actions">
<button type="button" class="pj-btn" data-close>취소</button> <button type="button" class="pj-btn" data-close>취소</button>
<button type="button" class="pj-btn pj-btn-primary" id="pj-save-project">만들기</button> <button type="button" class="pj-btn pj-btn-primary" id="pj-save-project">만들기</button>
@@ -273,11 +304,24 @@
</div> </div>
</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"> <script id="pj-home-data" type="application/json">
{{ home_tasks | tojson }} {{ home_tasks | tojson }}
</script> </script>
<script> <script>
window.PJ_COLORS = {{ ["#4573d2","#37a3a3","#62a420","#e8a33d","#e8384f","#aa62e3","#f06a6a","#5a6772"] | tojson }}; window.PJ_COLORS = {{ ["#4573d2","#37a3a3","#62a420","#e8a33d","#e8384f","#aa62e3","#f06a6a","#5a6772"] | tojson }};
</script> </script>
<script src="/static/project.js?v=20260915d" defer></script> <script src="/static/project.js?v=20260915e" defer></script>
{% endblock %} {% endblock %}
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %} {% extends "erp_base.html" %}
{% block head_extra %} {% block head_extra %}
<link rel="stylesheet" href="/static/project.css?v=20260915e" /> <link rel="stylesheet" href="/static/project.css?v=20260915f" />
<!-- 구글 머티리얼 심볼(담당자 아이콘 등) — self-host --> <!-- 구글 머티리얼 심볼(담당자 아이콘 등) — self-host -->
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" /> <link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
<!-- 타임라인 vis-timeline — self-host --> <!-- 타임라인 vis-timeline — self-host -->
@@ -291,5 +291,5 @@
</script> </script>
<script src="/static/vendor/vis-timeline/vis-timeline-graph2d.min.js"></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 %} {% endblock %}
+41 -7
View File
@@ -44,18 +44,24 @@
.pj-project-card.is-done { background: #f6f7f8; opacity: .62; } .pj-project-card.is-done { background: #f6f7f8; opacity: .62; }
.pj-project-card.is-done:hover { opacity: .85; } .pj-project-card.is-done:hover { opacity: .85; }
.pj-project-card.is-done .pj-project-dot { background: #9aa5b1; } .pj-project-card.is-done .pj-project-dot { background: #9aa5b1; }
/* 카드 수정 버튼 */ /* 카드 우측 상단 액션(업무 추가/수정) — 호버 시에만 노출 */
.pj-card-edit { .pj-card-actions {
position: absolute; top: 8px; right: 8px; width: 28px; height: 28px; position: absolute; top: 8px; right: 8px; display: flex; gap: 2px;
}
.pj-card-actions .pj-icon-btn {
width: 28px; height: 28px;
display: inline-flex; align-items: center; justify-content: center; display: inline-flex; align-items: center; justify-content: center;
border-radius: 8px; color: #6b7280; opacity: 0; transition: opacity .12s; border-radius: 8px; color: #6b7280; opacity: 0; transition: opacity .12s;
} }
.pj-card-edit .material-symbols-outlined { font-size: 18px; } .pj-card-actions .pj-icon-btn .material-symbols-outlined { font-size: 18px; }
.pj-project-card:hover .pj-card-edit { opacity: 1; } .pj-project-card:hover .pj-card-actions .pj-icon-btn { opacity: 1; }
.pj-card-edit:hover { background: #eceef0; color: #1e1f21; } .pj-card-actions .pj-icon-btn:hover { background: #eceef0; color: #1e1f21; }
.pj-project-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--pj-color, #4573d2); margin-top: 5px; flex: 0 0 auto; } .pj-project-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--pj-color, #4573d2); margin-top: 5px; flex: 0 0 auto; }
.pj-project-name { font-weight: 700; font-size: 15px; } .pj-project-name { font-weight: 700; font-size: 15px; }
.pj-project-desc { color: #6b7280; font-size: 12.5px; margin-top: 3px; } .pj-project-desc { color: #6b7280; font-size: 12.5px; margin-top: 3px; }
/* 프로젝트 이름 아래 — 배정 멤버(아이콘+이름) */
.pj-project-members { display: flex; flex-wrap: wrap; gap: 3px 10px; margin-top: 6px; }
.pj-project-member { display: inline-flex; align-items: center; gap: 4px; font-size: 11.5px; color: #6b7280; }
.pj-project-meta { margin-top: 10px; display: flex; gap: 6px; flex-wrap: wrap; } .pj-project-meta { margin-top: 10px; display: flex; gap: 6px; flex-wrap: wrap; }
.pj-project-progress { margin-top: 10px; height: 5px; border-radius: 999px; background: #eef0f2; overflow: hidden; } .pj-project-progress { margin-top: 10px; height: 5px; border-radius: 999px; background: #eef0f2; overflow: hidden; }
.pj-project-progress-bar { height: 100%; border-radius: 999px; background: var(--pj-color, #4573d2); } .pj-project-progress-bar { height: 100%; border-radius: 999px; background: var(--pj-color, #4573d2); }
@@ -326,9 +332,37 @@
.pj-card-proj { font-size: 11px; font-weight: 700; margin-bottom: 3px; letter-spacing: -.2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .pj-card-proj { font-size: 11px; font-weight: 700; margin-bottom: 3px; letter-spacing: -.2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* 댓글 말풍선(💬 이모지) 표시 */ /* 댓글 말풍선(💬 이모지) 표시 */
.pj-cmt-ic { font-size: 12px; vertical-align: middle; margin-left: 4px; line-height: 1; } .pj-cmt-ic { font-size: 12px; vertical-align: middle; margin-left: 4px; line-height: 1; display: inline-block; }
.pj-cmt-n { font-size: 11px; font-weight: 700; vertical-align: middle; margin-left: 1px; } .pj-cmt-n { font-size: 11px; font-weight: 700; vertical-align: middle; margin-left: 1px; }
.pj-card-cmt { display: inline-flex; align-items: center; color: #6b7280; } .pj-card-cmt { display: inline-flex; align-items: center; color: #6b7280; }
.pj-cmt-badge { display: inline-flex; align-items: center; flex: 0 0 auto; color: #6b7280; cursor: pointer; }
/* 새 댓글이 등록된 뒤 아직 확인 안 한 업무 — 살짝 커지며 반짝임. 확인하면(더블클릭) 원래대로. */
@keyframes pj-cmt-pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.5); opacity: .55; }
}
.pj-cmt-badge.is-unread .pj-cmt-ic { animation: pj-cmt-pulse 1.1s ease-in-out infinite; }
.pj-cmt-badge.is-unread .pj-cmt-n { color: #e8384f; font-weight: 800; }
/* ── 새 프로젝트 모달 — 멤버 선택 ── */
.pj-modal-card .pj-field-label { display: block; font-size: 12.5px; font-weight: 600; color: #525860; margin-bottom: 12px; }
.pj-member-picker { display: flex; flex-wrap: wrap; gap: 4px 12px; margin-top: 5px; max-height: 140px; overflow-y: auto; padding: 6px 2px; border: 1px solid #eef0f2; border-radius: 8px; }
.pj-member-pick { display: inline-flex !important; align-items: center; gap: 5px; font-size: 12.5px; font-weight: 500 !important; color: #333; width: auto !important; margin: 0 !important; }
.pj-member-pick input { width: auto !important; margin: 0 !important; }
/* ── 댓글만 보는 채팅형 팝업(카톡 대화창 느낌) ── */
.pj-modal-chat { width: min(420px, 92vw); display: flex; flex-direction: column; }
.pj-chat-list { list-style: none; margin: 0 0 10px; padding: 4px; max-height: 420px; min-height: 120px; overflow-y: auto; background: #eef0f2; border-radius: 10px; display: flex; flex-direction: column; gap: 8px; }
.pj-chat-msg { display: flex; flex-direction: column; align-items: flex-start; max-width: 78%; }
.pj-chat-msg.mine { align-self: flex-end; align-items: flex-end; }
.pj-chat-name { font-size: 11px; color: #6b7280; margin-bottom: 2px; padding: 0 2px; }
.pj-chat-bubble {
background: #fff; border-radius: 14px 14px 14px 4px; padding: 8px 12px;
font-size: 13.5px; color: #1e1f21; white-space: pre-wrap; word-break: break-word;
box-shadow: 0 1px 2px rgba(0,0,0,.06);
}
.pj-chat-msg.mine .pj-chat-bubble { background: #fee500; border-radius: 14px 14px 4px 14px; }
.pj-chat-time { font-size: 10px; color: #9aa1a9; margin-top: 3px; padding: 0 2px; }
/* ── 보드 단계 컨트롤 ── */ /* ── 보드 단계 컨트롤 ── */
.pj-col-head { position: relative; } .pj-col-head { position: relative; }
+190 -4
View File
@@ -23,7 +23,13 @@
function el(id) { return document.getElementById(id); } function el(id) { return document.getElementById(id); }
function openModal(m) { if (m) m.hidden = false; } function openModal(m) { if (m) m.hidden = false; }
function closeModal(m) { if (m) m.hidden = true; } // 실시간 새로고침 대기 중이던 변경사항 — 모든 모달이 닫힌 시점에 반영(입력 중 방해 방지).
let pjPendingReload = false;
function anyModalOpen() { return !!document.querySelector(".pj-modal:not([hidden])"); }
function closeModal(m) {
if (m) m.hidden = true;
if (pjPendingReload && !anyModalOpen()) location.reload();
}
function wireModalClose(modal) { function wireModalClose(modal) {
if (!modal) return; if (!modal) return;
@@ -98,18 +104,42 @@
if (newBtn && modal) { if (newBtn && modal) {
wireModalClose(modal); wireModalClose(modal);
const getColor = renderColorPalette(el("pj-f-colors")); const getColor = renderColorPalette(el("pj-f-colors"));
newBtn.addEventListener("click", function () { openModal(modal); }); const membersBox = el("pj-f-members");
newBtn.addEventListener("click", async function () {
openModal(modal);
if (membersBox) {
membersBox.innerHTML = "불러오는 중…";
try {
const res = await api("GET", "/project/api/assignable-users");
const users = res.users || [];
membersBox.innerHTML = users.length ? users.map(function (u) {
return '<label class="pj-member-pick"><input type="checkbox" value="' + u.email + '" /> ' +
escapeHtml(u.id) + "</label>";
}).join("") : "<span class='pj-empty-sm'>배정 가능한 직원이 없습니다.</span>";
} catch (e) {
membersBox.innerHTML = "<span class='pj-empty-sm'>불러오기 실패</span>";
}
}
});
el("pj-save-project").addEventListener("click", async function () { el("pj-save-project").addEventListener("click", async function () {
const name = el("pj-f-name").value.trim(); const name = el("pj-f-name").value.trim();
if (!name) { alert("이름을 입력하세요."); return; } if (!name) { alert("이름을 입력하세요."); return; }
const memberEmails = membersBox
? Array.from(membersBox.querySelectorAll("input[type=checkbox]:checked")).map(function (c) { return c.value; })
: [];
try { try {
await api("POST", "/project/api/projects", { const res = await api("POST", "/project/api/projects", {
name: name, name: name,
description: el("pj-f-desc").value.trim(), description: el("pj-f-desc").value.trim(),
start_date: el("pj-f-start").value || null, start_date: el("pj-f-start").value || null,
due_date: el("pj-f-due").value || null, due_date: el("pj-f-due").value || null,
color: getColor(), color: getColor(),
}); });
const pid = res.project.id;
for (const email of memberEmails) {
try { await api("POST", "/project/api/projects/" + pid + "/members", { user_email: email }); }
catch (_) {}
}
location.reload(); location.reload();
} catch (e) { alert("생성 실패: " + e.message); } } catch (e) { alert("생성 실패: " + e.message); }
}); });
@@ -283,6 +313,10 @@
el("pj-t-stime").disabled = !useTime; el("pj-t-stime").disabled = !useTime;
el("pj-t-dtime").disabled = !useTime; el("pj-t-dtime").disabled = !useTime;
el("pj-t-open-project").href = "/project/p/" + task.project_id + "?task=" + task.id; el("pj-t-open-project").href = "/project/p/" + task.project_id + "?task=" + task.id;
el("pj-delete-task").hidden = false;
el("pj-t-open-project").hidden = false;
el("pj-attach-block").hidden = false;
el("pj-comment-block").hidden = false;
openModal(homeTaskModal); openModal(homeTaskModal);
await Promise.all([ await Promise.all([
fillAssignee(task.project_id, task.assignee_email || ""), fillAssignee(task.project_id, task.assignee_email || ""),
@@ -292,6 +326,33 @@
loadHomeComments(task.id); loadHomeComments(task.id);
} }
// 카드 이름 옆 "업무 추가" 버튼 — 같은 모달을 새 업무 작성용으로 초기화해 연다.
async function openHomeNewTaskModal(projectId) {
el("pj-task-modal-title").textContent = "새 업무 추가";
el("pj-t-id").value = "";
el("pj-t-project-id").value = projectId;
el("pj-t-title").value = "";
el("pj-t-desc").value = "";
el("pj-t-priority").value = "normal";
el("pj-t-start").value = "";
el("pj-t-due").value = "";
el("pj-t-usetime").checked = false;
el("pj-t-stime").value = ""; el("pj-t-stime").disabled = true;
el("pj-t-dtime").value = ""; el("pj-t-dtime").disabled = true;
el("pj-delete-task").hidden = true;
el("pj-t-open-project").hidden = true;
el("pj-attach-block").hidden = true;
el("pj-comment-block").hidden = true;
openModal(homeTaskModal);
await Promise.all([fillAssignee(projectId, ""), fillStage(projectId, null)]);
}
document.querySelectorAll(".pj-card-addtask").forEach(function (btn) {
btn.addEventListener("click", function (e) {
e.preventDefault(); e.stopPropagation();
openHomeNewTaskModal(btn.dataset.projectId);
});
});
el("pj-t-usetime").addEventListener("change", function () { el("pj-t-usetime").addEventListener("change", function () {
el("pj-t-stime").disabled = !this.checked; el("pj-t-stime").disabled = !this.checked;
el("pj-t-dtime").disabled = !this.checked; el("pj-t-dtime").disabled = !this.checked;
@@ -303,7 +364,7 @@
el("pj-save-task").addEventListener("click", async function () { el("pj-save-task").addEventListener("click", async function () {
const id = el("pj-t-id").value; const id = el("pj-t-id").value;
if (!id) return; const pid = el("pj-t-project-id").value;
const assigneeSel = el("pj-t-assignee"); const assigneeSel = el("pj-t-assignee");
const assigneeName = assigneeSel.selectedOptions[0] ? (assigneeSel.selectedOptions[0].dataset.name || "") : ""; const assigneeName = assigneeSel.selectedOptions[0] ? (assigneeSel.selectedOptions[0].dataset.name || "") : "";
const useTime = el("pj-t-usetime").checked; const useTime = el("pj-t-usetime").checked;
@@ -321,7 +382,12 @@
}; };
if (!payload.title) { alert("제목을 입력하세요."); return; } if (!payload.title) { alert("제목을 입력하세요."); return; }
try { try {
if (id) {
await api("PUT", "/project/api/tasks/" + id, payload); await api("PUT", "/project/api/tasks/" + id, payload);
} else {
if (!pid) return;
await api("POST", "/project/api/projects/" + pid + "/tasks", payload);
}
location.reload(); location.reload();
} catch (e) { alert("저장 실패: " + e.message); } } catch (e) { alert("저장 실패: " + e.message); }
}); });
@@ -343,6 +409,100 @@
}); });
}); });
} }
// ── 댓글 말풍선(카드/내 업무) — 더블클릭 시 댓글만 보는 채팅형 팝업 ──
const commentsModal = el("pj-modal-comments");
if (commentsModal) {
wireModalClose(commentsModal);
function loadSeenComments() {
try { return JSON.parse(localStorage.getItem("pj_seen_comments") || "{}"); }
catch (_) { return {}; }
}
function markSeen(taskId, count) {
const m = loadSeenComments();
m[taskId] = count;
try { localStorage.setItem("pj_seen_comments", JSON.stringify(m)); } catch (_) {}
}
// 페이지 로드 시 — 마지막으로 확인한 개수보다 늘었으면 반짝임(is-unread) 표시.
const seen = loadSeenComments();
document.querySelectorAll(".pj-cmt-badge").forEach(function (b) {
const n = parseInt(b.dataset.count, 10) || 0;
const seenN = seen[b.dataset.taskId] || 0;
if (n > seenN) b.classList.add("is-unread");
});
async function renderChat(taskId) {
const list = el("pj-chat-list");
list.innerHTML = "<li class='pj-empty-sm'>불러오는 중…</li>";
try {
const res = await api("GET", "/project/api/tasks/" + taskId + "/comments");
const comments = res.comments || [];
if (!comments.length) {
list.innerHTML = "<li class='pj-empty-sm'>댓글 없음</li>";
} else {
list.innerHTML = "";
comments.forEach(function (c) {
const mine = (c.author_email || "").toLowerCase() === me;
const li = document.createElement("li");
li.className = "pj-chat-msg" + (mine ? " mine" : "");
li.innerHTML =
(mine ? "" : '<span class="pj-chat-name">' + escapeHtml(idOf(c.author_email)) + "</span>") +
'<div class="pj-chat-bubble"></div>' +
'<span class="pj-chat-time">' + (c.created_at || "").slice(0, 16).replace("T", " ") + "</span>";
li.querySelector(".pj-chat-bubble").textContent = c.body;
list.appendChild(li);
});
list.scrollTop = list.scrollHeight;
}
return comments.length;
} catch (e) {
list.innerHTML = "<li class='pj-empty-sm'>불러오기 실패</li>";
return null;
}
}
function syncBadge(taskId, n) {
const badge = document.querySelector('.pj-cmt-badge[data-task-id="' + taskId + '"]');
if (!badge) return;
badge.dataset.count = n;
badge.title = "댓글 " + n + "개";
const numEl = badge.querySelector(".pj-cmt-n");
if (numEl) numEl.textContent = n;
badge.classList.remove("is-unread");
}
async function openCommentsModal(taskId) {
el("pj-cm-task-id").value = taskId;
openModal(commentsModal);
const n = await renderChat(taskId);
if (n !== null) { markSeen(taskId, n); syncBadge(taskId, n); }
}
document.querySelectorAll(".pj-cmt-badge").forEach(function (b) {
b.addEventListener("click", function (e) { e.preventDefault(); e.stopPropagation(); });
b.addEventListener("dblclick", function (e) {
e.preventDefault(); e.stopPropagation();
openCommentsModal(b.dataset.taskId);
});
});
el("pj-cm-send").addEventListener("click", async function () {
const taskId = el("pj-cm-task-id").value;
const inp = el("pj-cm-input");
const body = inp.value.trim();
if (!taskId || !body) return;
try {
await api("POST", "/project/api/tasks/" + taskId + "/comments", { body: body });
inp.value = "";
const n = await renderChat(taskId);
if (n !== null) { markSeen(taskId, n); syncBadge(taskId, n); }
} catch (e) { alert("등록 실패: " + e.message); }
});
el("pj-cm-input").addEventListener("keydown", function (e) {
if (e.key === "Enter") { e.preventDefault(); el("pj-cm-send").click(); }
});
}
} }
// ════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════
@@ -1430,10 +1590,36 @@
}); });
} }
// ── 실시간 새로고침 ──
// 다른 사용자가 프로젝트/업무/댓글/멤버/단계를 추가·수정하면, 같은 화면을 열어둔
// 모든 사용자가 자동으로 새로고침되도록 주기적으로 서버 버전을 확인한다.
// 모달을 열어 입력 중이면 방해하지 않고, 모달이 모두 닫힐 때(closeModal) 반영한다.
function initLiveRefresh() {
if (!document.querySelector(".pj-wrap, .pj-project-view")) return;
let baseline = null;
async function poll() {
try {
const res = await fetch("/project/api/live-version");
if (res.ok) {
const d = await res.json();
if (baseline === null) {
baseline = d.ts;
} else if (d.ts !== baseline) {
if (anyModalOpen()) { pjPendingReload = true; }
else { location.reload(); return; }
}
}
} catch (_) {}
setTimeout(poll, 8000);
}
setTimeout(poll, 8000);
}
document.addEventListener("DOMContentLoaded", function () { document.addEventListener("DOMContentLoaded", function () {
initHome(); initHome();
initProject(); initProject();
initBell(); initBell();
initInbox(); initInbox();
initLiveRefresh();
}); });
})(); })();
+8 -2
View File
@@ -27,11 +27,17 @@
## 3. 화면 / 뷰 ## 3. 화면 / 뷰
- **홈** (`/project/`): "진행중인 프로젝트" / "완료된 프로젝트" 두 섹션으로 구분된 카드 그리드 + "내 업무" 사이드. - **홈** (`/project/`): "진행중인 프로젝트" / "완료된 프로젝트" 두 섹션으로 구분된 카드 그리드 + "내 업무" 사이드.
관리자는 "새 프로젝트"(이름/설명/시작일/마감일/색상 지정). 완료 섹션은 접기/펼치기(localStorage 기억). 관리자는 "새 프로젝트"(이름/설명/시작일/마감일/색상 지정 + 멤버 다중 선택, 생성 직후 일괄 배정). 완료 섹션은 접기/펼치기(localStorage 기억).
각 카드에 진행률 바(완료/전체 업무)와 업무 미리보기(단계·담당자·마감일, 최대 8건 + 더보기)를 표시 — 각 카드에 이름 아래 배정 멤버(아이콘+이름), 진행률 바(완료/전체 업무)와 업무 미리보기(단계·담당자·마감일, 최대 8건 + 더보기)를 표시 —
완료된 업무도 취소선으로 계속 노출해 전체 현황을 한눈에 파악. 완료된 업무도 취소선으로 계속 노출해 전체 현황을 한눈에 파악.
카드 이름 옆에는 호버 시에만 보이는 "업무 추가" 아이콘 버튼(관리자 또는 해당 프로젝트 멤버/owner) — 같은 업무편집 팝업을 새 업무 작성 모드로 연다.
카드·내 업무의 업무를 클릭하면 페이지 이동 없이 그 자리에서 업무편집 팝업(담당자·단계·일정·첨부·댓글)이 뜬다 카드·내 업무의 업무를 클릭하면 페이지 이동 없이 그 자리에서 업무편집 팝업(담당자·단계·일정·첨부·댓글)이 뜬다
(`#pj-modal-task` + `home_tasks`/`#pj-home-data`, 담당자·단계 옵션은 해당 프로젝트를 API 로 즉시 조회). (`#pj-modal-task` + `home_tasks`/`#pj-home-data`, 담당자·단계 옵션은 해당 프로젝트를 API 로 즉시 조회).
업무에 댓글이 있으면 말풍선 아이콘+개수를 표시, 더블클릭하면 댓글만 보는 카톡 대화창 스타일 팝업(`#pj-modal-comments`)이 뜬다.
확인 안 한 새 댓글은 말풍선이 커지며 반짝이고(브라우저별 `localStorage: pj_seen_comments` 로 확인 여부 기억), 팝업을 열면(확인하면) 원래대로 돌아간다.
날짜는 전부 `mm/dd(요일)` 형식(`_fmt_date_kr`)으로 표시.
프로젝트/업무/댓글/멤버/단계가 추가·수정되면 `GET /project/api/live-version`(최근 변경 시각) 폴링으로 감지해
해당 화면을 열어둔 모든 사용자의 페이지가 자동 새로고침(모달을 열어 입력 중이면 닫힐 때까지 대기).
- **프로젝트** (`/project/p/{id}`): 좌측 서브프로젝트/멤버, 우측 뷰 토글. - **프로젝트** (`/project/p/{id}`): 좌측 서브프로젝트/멤버, 우측 뷰 토글.
좌측 트리의 업무 클릭도 페이지 이동 없이 바로 팝업(이미 로드된 전체 업무 데이터 사용). 좌측 트리의 업무 클릭도 페이지 이동 없이 바로 팝업(이미 로드된 전체 업무 데이터 사용).
- **달력** — FullCalendar. 업무를 시작~마감 기간으로 표시. 클릭 편집. - **달력** — FullCalendar. 업무를 시작~마감 기간으로 표시. 클릭 편집.