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 %}
@@ -64,6 +82,11 @@
{% if t.due_date %}
{{ t.due_label }}
{% endif %}
+ {% if t.comment_count %}
+
+ 💬{{ t.comment_count }}
+
+ {% endif %}
{% endfor %}
@@ -150,6 +173,11 @@
{% endif %}
+ {% if t.comment_count %}
+
+ 💬{{ t.comment_count }}
+
+ {% endif %}
{% endfor %}
@@ -176,6 +204,9 @@
+
@@ -273,11 +304,24 @@
+
+
+
-
+
{% endblock %}
diff --git a/app/modules/project/templates/project/project.html b/app/modules/project/templates/project/project.html
index b0ede0b..bd39d18 100644
--- a/app/modules/project/templates/project/project.html
+++ b/app/modules/project/templates/project/project.html
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %}
{% block head_extra %}
-
+
@@ -291,5 +291,5 @@
-
+
{% endblock %}
diff --git a/app/static/project.css b/app/static/project.css
index 5310c77..9aad470 100644
--- a/app/static/project.css
+++ b/app/static/project.css
@@ -44,18 +44,24 @@
.pj-project-card.is-done { background: #f6f7f8; opacity: .62; }
.pj-project-card.is-done:hover { opacity: .85; }
.pj-project-card.is-done .pj-project-dot { background: #9aa5b1; }
-/* 카드 수정 버튼 */
-.pj-card-edit {
- position: absolute; top: 8px; right: 8px; width: 28px; height: 28px;
+/* 카드 우측 상단 액션(업무 추가/수정) — 호버 시에만 노출 */
+.pj-card-actions {
+ 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;
border-radius: 8px; color: #6b7280; opacity: 0; transition: opacity .12s;
}
-.pj-card-edit .material-symbols-outlined { font-size: 18px; }
-.pj-project-card:hover .pj-card-edit { opacity: 1; }
-.pj-card-edit:hover { background: #eceef0; color: #1e1f21; }
+.pj-card-actions .pj-icon-btn .material-symbols-outlined { font-size: 18px; }
+.pj-project-card:hover .pj-card-actions .pj-icon-btn { opacity: 1; }
+.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-name { font-weight: 700; font-size: 15px; }
.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-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); }
@@ -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-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-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; }
diff --git a/app/static/project.js b/app/static/project.js
index abd2f27..1705890 100644
--- a/app/static/project.js
+++ b/app/static/project.js
@@ -23,7 +23,13 @@
function el(id) { return document.getElementById(id); }
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) {
if (!modal) return;
@@ -98,18 +104,42 @@
if (newBtn && modal) {
wireModalClose(modal);
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 '";
+ }).join("") : "배정 가능한 직원이 없습니다.";
+ } catch (e) {
+ membersBox.innerHTML = "불러오기 실패";
+ }
+ }
+ });
el("pj-save-project").addEventListener("click", async function () {
const name = el("pj-f-name").value.trim();
if (!name) { alert("이름을 입력하세요."); return; }
+ const memberEmails = membersBox
+ ? Array.from(membersBox.querySelectorAll("input[type=checkbox]:checked")).map(function (c) { return c.value; })
+ : [];
try {
- await api("POST", "/project/api/projects", {
+ const res = await api("POST", "/project/api/projects", {
name: name,
description: el("pj-f-desc").value.trim(),
start_date: el("pj-f-start").value || null,
due_date: el("pj-f-due").value || null,
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();
} catch (e) { alert("생성 실패: " + e.message); }
});
@@ -283,6 +313,10 @@
el("pj-t-stime").disabled = !useTime;
el("pj-t-dtime").disabled = !useTime;
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);
await Promise.all([
fillAssignee(task.project_id, task.assignee_email || ""),
@@ -292,6 +326,33 @@
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-stime").disabled = !this.checked;
el("pj-t-dtime").disabled = !this.checked;
@@ -303,7 +364,7 @@
el("pj-save-task").addEventListener("click", async function () {
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 assigneeName = assigneeSel.selectedOptions[0] ? (assigneeSel.selectedOptions[0].dataset.name || "") : "";
const useTime = el("pj-t-usetime").checked;
@@ -321,7 +382,12 @@
};
if (!payload.title) { alert("제목을 입력하세요."); return; }
try {
- await api("PUT", "/project/api/tasks/" + id, payload);
+ if (id) {
+ await api("PUT", "/project/api/tasks/" + id, payload);
+ } else {
+ if (!pid) return;
+ await api("POST", "/project/api/projects/" + pid + "/tasks", payload);
+ }
location.reload();
} 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 = "- 불러오는 중…
";
+ try {
+ const res = await api("GET", "/project/api/tasks/" + taskId + "/comments");
+ const comments = res.comments || [];
+ if (!comments.length) {
+ list.innerHTML = "- 댓글 없음
";
+ } 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 ? "" : '' + escapeHtml(idOf(c.author_email)) + "") +
+ '' +
+ '' + (c.created_at || "").slice(0, 16).replace("T", " ") + "";
+ li.querySelector(".pj-chat-bubble").textContent = c.body;
+ list.appendChild(li);
+ });
+ list.scrollTop = list.scrollHeight;
+ }
+ return comments.length;
+ } catch (e) {
+ list.innerHTML = "- 불러오기 실패
";
+ 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 () {
initHome();
initProject();
initBell();
initInbox();
+ initLiveRefresh();
});
})();
diff --git a/docs/PROJECT_MODULE.md b/docs/PROJECT_MODULE.md
index 814665a..1edc4a6 100644
--- a/docs/PROJECT_MODULE.md
+++ b/docs/PROJECT_MODULE.md
@@ -27,11 +27,17 @@
## 3. 화면 / 뷰
- **홈** (`/project/`): "진행중인 프로젝트" / "완료된 프로젝트" 두 섹션으로 구분된 카드 그리드 + "내 업무" 사이드.
- 관리자는 "새 프로젝트"(이름/설명/시작일/마감일/색상 지정). 완료 섹션은 접기/펼치기(localStorage 기억).
- 각 카드에 진행률 바(완료/전체 업무)와 업무 미리보기(단계·담당자·마감일, 최대 8건 + 더보기)를 표시 —
+ 관리자는 "새 프로젝트"(이름/설명/시작일/마감일/색상 지정 + 멤버 다중 선택, 생성 직후 일괄 배정). 완료 섹션은 접기/펼치기(localStorage 기억).
+ 각 카드에 이름 아래 배정 멤버(아이콘+이름), 진행률 바(완료/전체 업무)와 업무 미리보기(단계·담당자·마감일, 최대 8건 + 더보기)를 표시 —
완료된 업무도 취소선으로 계속 노출해 전체 현황을 한눈에 파악.
+ 카드 이름 옆에는 호버 시에만 보이는 "업무 추가" 아이콘 버튼(관리자 또는 해당 프로젝트 멤버/owner) — 같은 업무편집 팝업을 새 업무 작성 모드로 연다.
카드·내 업무의 업무를 클릭하면 페이지 이동 없이 그 자리에서 업무편집 팝업(담당자·단계·일정·첨부·댓글)이 뜬다
(`#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}`): 좌측 서브프로젝트/멤버, 우측 뷰 토글.
좌측 트리의 업무 클릭도 페이지 이동 없이 바로 팝업(이미 로드된 전체 업무 데이터 사용).
- **달력** — FullCalendar. 업무를 시작~마감 기간으로 표시. 클릭 편집.