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>
|
||||
<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 %}
|
||||
|
||||
+41
-7
@@ -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; }
|
||||
|
||||
+190
-4
@@ -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 '<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 () {
|
||||
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 {
|
||||
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 = "<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 () {
|
||||
initHome();
|
||||
initProject();
|
||||
initBell();
|
||||
initInbox();
|
||||
initLiveRefresh();
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -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. 업무를 시작~마감 기간으로 표시. 클릭 편집.
|
||||
|
||||
Reference in New Issue
Block a user