feat(project): 전체 뷰에 우클릭 컨텍스트 메뉴 + optimistic update 전면 적용

- 공용 우클릭 메뉴(showContextMenu)와 토스트(showToast)를 도입해 보드 카드,
  리스트 행, 달력 막대, 타임라인 아이템, 세션 컬럼 헤더, 홈 프로젝트 카드,
  사이드바 프로젝트 트리, 멤버 행, 알림 행까지 9곳에 열기/완료토글/삭제 등
  빠른 조작 메뉴를 추가.
- 프로젝트 생성/수정/삭제, 멤버 추가/삭제, 홈 업무모달 저장/삭제/완료토글,
  알림 모두읽음 등 남아있던 location.reload() 를 모두 로컬 DOM/상태 patch
  기반 optimistic update 로 전환.
- 8초 주기 실시간 동기화 폴러도 전체 새로고침 대신 업무/세션/멤버만 조용히
  재조회해 병합하도록 변경(softSyncProjectRef).
- 로컬에 Postgres+세션 우회 로그인으로 실제 서버 붙여 헤드리스 크롬으로
  검증하는 과정에서 실사용 버그 2건 발견해 같이 수정: 폴러가 재조회한
  업무에 context_project_id 가 없어 스코프 필터에 걸려 화면에서 사라지던
  문제, 서버가 최초 렌더한 업무 행은 data-task-id 가 <li> 가 아니라 안쪽
  <a> 에만 있어 수정/삭제가 조용히 씹히던 문제.
This commit is contained in:
2026-09-16 12:31:01 +09:00
parent 59b7f904bf
commit 27d91b9f6b
5 changed files with 670 additions and 119 deletions
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/project.css?v=20260917e" />
<link rel="stylesheet" href="/static/project.css?v=20260917f" />
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
{% endblock %}
@@ -45,5 +45,5 @@
{% endif %}
</div>
<script src="/static/project.js?v=20260917d" defer></script>
<script src="/static/project.js?v=20260917e" defer></script>
{% endblock %}
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/project.css?v=20260917e" />
<link rel="stylesheet" href="/static/project.css?v=20260917f" />
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
{% endblock %}
@@ -393,5 +393,5 @@
<script>
window.PJ_COLORS = {{ ["#4573d2","#37a3a3","#62a420","#e8a33d","#e8384f","#aa62e3","#f06a6a","#5a6772"] | tojson }};
</script>
<script src="/static/project.js?v=20260917d" defer></script>
<script src="/static/project.js?v=20260917e" defer></script>
{% endblock %}
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/project.css?v=20260917e" />
<link rel="stylesheet" href="/static/project.css?v=20260917f" />
<!-- 구글 머티리얼 심볼(담당자 아이콘 등) — self-host -->
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
<!-- 타임라인 vis-timeline — self-host -->
@@ -438,5 +438,5 @@
</script>
<script src="/static/vendor/vis-timeline/vis-timeline-graph2d.min.js"></script>
<script src="/static/project.js?v=20260917d" defer></script>
<script src="/static/project.js?v=20260917e" defer></script>
{% endblock %}
+18
View File
@@ -701,3 +701,21 @@ label.pj-desc-fmtbtn { line-height: 26px; }
.pj-inbox-sub { font-size: 12.5px; color: #525860; margin-top: 2px; }
.pj-inbox-meta { font-size: 11px; color: #9aa1a9; margin-top: 4px; }
.pj-inbox-go { align-self: center; padding: 4px 12px; font-size: 12px; }
/* ── 우클릭 컨텍스트 메뉴 — 클릭 좌표 기준 고정 위치(.pj-daypop 은 앵커
엘리먼트 기준이라 좌표 배치가 다름). z-index 는 .pj-daypop(2100) 보다
위, .erp-dialog(9999) 보다 아래. ── */
.pj-ctxmenu {
position: fixed; z-index: 2200; min-width: 180px;
background: #fff; border: 1px solid var(--pj-border);
border-radius: 10px; box-shadow: var(--pj-card-shadow-hover); padding: 6px;
}
.pj-ctxmenu-item {
display: flex; align-items: center; gap: 8px; width: 100%; text-align: left;
border: none; background: transparent; padding: 8px 10px; border-radius: 7px;
font-size: 13px; color: var(--pj-ink); cursor: pointer;
}
.pj-ctxmenu-item:hover { background: var(--pj-accent-tint); }
.pj-ctxmenu-item.is-danger { color: #c22b10; }
.pj-ctxmenu-item.is-danger:hover { background: #fbecea; }
.pj-ctxmenu-sep { height: 1px; background: var(--pj-border); margin: 4px 2px; }
+641 -108
View File
@@ -23,13 +23,7 @@
function el(id) { return document.getElementById(id); }
function openModal(m) { if (m) m.hidden = false; }
// 실시간 새로고침 대기 중이던 변경사항 — 모든 모달이 닫힌 시점에 반영(입력 중 방해 방지).
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 closeModal(m) { if (m) m.hidden = true; }
function wireModalClose(modal) {
if (!modal) return;
@@ -38,6 +32,88 @@
});
}
// ── 토스트 알림 — alert() 대신 쓰는 비차단 피드백 ──
let toastEl = null, toastTimer = null;
function showToast(msg, type) {
if (!toastEl) {
toastEl = document.createElement("div");
toastEl.className = "erp-toast";
toastEl.setAttribute("role", "status");
toastEl.setAttribute("aria-live", "polite");
document.body.appendChild(toastEl);
}
toastEl.textContent = msg;
toastEl.dataset.type = type || "info";
toastEl.dataset.visible = "true";
clearTimeout(toastTimer);
toastTimer = setTimeout(function () { toastEl.dataset.visible = "false"; }, 2400);
}
// ── 우클릭 컨텍스트 메뉴 — 하나만 열리는 공용 컴포넌트.
// openDayPopover(아래)의 위치잡기/외부클릭 닫기 방식을 그대로 따르되, 앵커
// 엘리먼트 대신 클릭 좌표(clientX/Y) 기준으로 뷰포트에 클램프한다.
let ctxMenuEl = null;
function closeContextMenu() {
if (!ctxMenuEl) return;
ctxMenuEl.remove();
ctxMenuEl = null;
document.removeEventListener("mousedown", onCtxDocDown, true);
document.removeEventListener("keydown", onCtxKeyDown, true);
window.removeEventListener("scroll", closeContextMenu, true);
window.removeEventListener("resize", closeContextMenu, true);
}
function onCtxDocDown(e) {
if (ctxMenuEl && !ctxMenuEl.contains(e.target)) closeContextMenu();
}
function onCtxKeyDown(e) {
if (e.key === "Escape") closeContextMenu();
}
// items: [{label, danger?, onClick} | null(구분선)]
function showContextMenu(x, y, items) {
closeContextMenu();
const menu = document.createElement("div");
menu.className = "pj-ctxmenu";
items.forEach(function (item) {
if (!item) {
const sep = document.createElement("div");
sep.className = "pj-ctxmenu-sep";
menu.appendChild(sep);
return;
}
const b = document.createElement("button");
b.type = "button";
b.className = "pj-ctxmenu-item" + (item.danger ? " is-danger" : "");
b.textContent = item.label;
b.addEventListener("click", function () { closeContextMenu(); item.onClick(); });
menu.appendChild(b);
});
document.body.appendChild(menu);
ctxMenuEl = menu;
let left = window.scrollX + x;
let top = window.scrollY + y;
left = Math.min(left, window.scrollX + document.documentElement.clientWidth - menu.offsetWidth - 8);
top = Math.min(top, window.scrollY + document.documentElement.clientHeight - menu.offsetHeight - 8);
menu.style.left = Math.max(8, left) + "px";
menu.style.top = Math.max(8, top) + "px";
setTimeout(function () {
document.addEventListener("mousedown", onCtxDocDown, true);
document.addEventListener("keydown", onCtxKeyDown, true);
window.addEventListener("scroll", closeContextMenu, true);
window.addEventListener("resize", closeContextMenu, true);
}, 0);
}
// ── 날짜 표기 'YYYY-MM-DD' → 'mm/dd(요일)' — 서버 _fmt_date_kr 과 동일 규칙 ──
function fmtDateKr(dstr) {
if (!dstr) return "";
const d = new Date(String(dstr).slice(0, 10) + "T00:00:00");
if (isNaN(d.getTime())) return dstr;
const mm = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
const wd = ["월", "화", "수", "목", "금", "토", "일"][(d.getDay() + 6) % 7];
return mm + "/" + dd + "(" + wd + ")";
}
// ── 색상 팔레트 렌더 ───────────────────────────────────────
function renderColorPalette(container, onPick, initial) {
const colors = window.PJ_COLORS || ["#4573d2"];
@@ -78,6 +154,288 @@
return !!cb && cb === me;
}
// 나중에 정의되는(블록 스코프) 모달 오픈 함수를 우클릭 메뉴 등 바깥에서도
// 부를 수 있도록 참조만 미리 선언해둔다(정의되는 즉시 아래서 채워짐).
let openNewTaskModalRef = null;
let openHomeTaskModalRef = null;
let openCommentsModalRef = null;
// ── 프로젝트 카드 수정/삭제 (카드 ✎ 버튼·우클릭 메뉴 공용) ──
const editModal = el("pj-modal-edit");
let getEditColor = null;
function openCardEditModal(card) {
el("pj-e-id").value = card.dataset.id;
el("pj-e-name").value = card.dataset.name || "";
el("pj-e-desc").value = card.dataset.desc || "";
el("pj-e-start").value = card.dataset.start || "";
el("pj-e-due").value = card.dataset.due || "";
el("pj-e-done").checked = card.dataset.status === "completed";
getEditColor = renderColorPalette(el("pj-e-colors"), null, card.dataset.color);
openModal(editModal);
}
async function deleteProjectCard(card) {
if (!canDelete(card.dataset.creator)) { alert("삭제는 만든 사람만 할 수 있습니다."); return; }
if (!confirm("이 프로젝트를 삭제할까요? 안의 업무도 모두 삭제됩니다.")) return;
try {
await api("DELETE", "/project/api/projects/" + card.dataset.id);
card.remove();
} catch (e) { showToast("삭제 실패: " + e.message, "error"); }
}
// 프로젝트 수정 저장 결과(row)를 기존 카드 DOM에 그대로 반영 — 새로고침 없음.
function patchProjectCardDom(card, p) {
card.dataset.name = p.name || "";
card.dataset.desc = p.description || "";
card.dataset.start = p.start_date || "";
card.dataset.due = p.due_date || "";
card.dataset.color = p.color || "#4573d2";
card.dataset.status = p.status || "active";
card.style.setProperty("--pj-color", p.color || "#4573d2");
card.classList.toggle("is-done", p.status === "completed");
const nameEl = card.querySelector(".pj-project-name");
if (nameEl) {
if (nameEl.firstChild && nameEl.firstChild.nodeType === Node.TEXT_NODE) {
nameEl.firstChild.textContent = p.name || "";
} else {
nameEl.insertBefore(document.createTextNode(p.name || ""), nameEl.firstChild);
}
let doneChip = nameEl.querySelector(".pj-chip");
if (p.status === "completed") {
if (!doneChip) {
doneChip = document.createElement("span");
doneChip.className = "pj-chip pj-chip-sm";
doneChip.textContent = "완료";
nameEl.appendChild(doneChip);
}
} else if (doneChip) doneChip.remove();
}
let descEl = card.querySelector(".pj-project-desc");
if (p.description) {
if (!descEl) {
descEl = document.createElement("div");
descEl.className = "pj-project-desc";
if (nameEl) nameEl.insertAdjacentElement("afterend", descEl);
}
descEl.textContent = p.description;
} else if (descEl) descEl.remove();
const meta = card.querySelector(".pj-project-meta");
if (meta) {
let dueChip = null;
meta.querySelectorAll(".pj-chip").forEach(function (c) {
if (c.textContent.indexOf("마감") === 0) dueChip = c;
});
if (p.due_date) {
const label = "마감 " + fmtDateKr(p.due_date);
if (dueChip) dueChip.textContent = label;
else {
dueChip = document.createElement("span");
dueChip.className = "pj-chip";
dueChip.textContent = label;
meta.insertBefore(dueChip, meta.firstChild);
}
} else if (dueChip) dueChip.remove();
}
// 진행중 ↔ 완료 그리드 이동. 완료 그리드가 화면에 아직 없으면(첫 완료
// 프로젝트) 다음 새로고침까지 원래 위치에 남는다 — 드문 경우라 허용.
const targetGrid = el(p.status === "completed" ? "pj-project-grid-done" : "pj-project-grid");
if (targetGrid && card.parentElement !== targetGrid) targetGrid.appendChild(card);
}
// 새 프로젝트 카드 DOM — project_card(p) 매크로(index.html)를 그대로 미러링.
// 방금 만든 프로젝트라 멤버/업무는 아직 없다(멤버는 생성 직후 별도 배정,
// 업무는 카드의 "업무 추가"로 나중에). 만든 사람은 자동 owner 라 관리 가능.
function projectCardHtml(p) {
const color = p.color || "#4573d2";
const html =
'<div class="pj-project-card" style="--pj-color: ' + color + '"' +
' data-id="' + p.id + '" data-name="' + escapeHtml(p.name || "") + '"' +
' data-desc="' + escapeHtml(p.description || "") + '"' +
' data-start="' + (p.start_date || "") + '" data-due="' + (p.due_date || "") + '"' +
' data-color="' + color + '" data-status="active" data-creator="' + escapeHtml(p.created_by || "") + '">' +
'<a class="pj-project-cardlink" href="/project/p/' + p.id + '">' +
'<span class="pj-project-dot"></span>' +
'<div class="pj-project-body">' +
'<div class="pj-project-name">' + escapeHtml(p.name || "") + "</div>" +
(p.description ? '<div class="pj-project-desc">' + escapeHtml(p.description) + "</div>" : "") +
'<div class="pj-project-meta">' +
(p.due_date ? '<span class="pj-chip">마감 ' + fmtDateKr(p.due_date) + "</span>" : "") +
"</div></div></a>" +
'<div class="pj-card-actions">' +
'<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>' +
'<button type="button" class="pj-icon-btn pj-card-edit" title="프로젝트 수정">' +
'<span class="material-symbols-outlined">edit</span></button></div>' +
'<div class="pj-project-tasks-empty">진행 중인 업무가 없습니다.</div></div>';
const wrap = document.createElement("div");
wrap.innerHTML = html;
return wrap.firstElementChild;
}
// 카드 하나에 수정/업무추가/우클릭 메뉴를 건다 — 최초 로드된 카드·새로
// 만든 카드 모두 이 함수 하나로 통일(initHome 맨 끝에서 전체 카드에 호출).
function wireProjectCard(card) {
const editBtn = card.querySelector(".pj-card-edit");
if (editBtn) editBtn.addEventListener("click", function (e) {
e.preventDefault(); e.stopPropagation();
openCardEditModal(card);
});
const addBtn = card.querySelector(".pj-card-addtask");
if (addBtn) addBtn.addEventListener("click", function (e) {
e.preventDefault(); e.stopPropagation();
if (openNewTaskModalRef) openNewTaskModalRef(card.dataset.id, card.dataset.name);
});
card.addEventListener("contextmenu", function (e) {
if (!card.querySelector(".pj-card-edit")) return; // 관리 권한 없는 카드는 메뉴 없음
e.preventDefault();
showContextMenu(e.clientX, e.clientY, [
{ label: "열기", onClick: function () { location.href = "/project/p/" + card.dataset.id; } },
{ label: "업무 추가", onClick: function () { if (openNewTaskModalRef) openNewTaskModalRef(card.dataset.id, card.dataset.name); } },
{ label: "수정", onClick: function () { openCardEditModal(card); } },
null,
{ label: "삭제", danger: true, onClick: function () { deleteProjectCard(card); } },
]);
});
}
// ── 홈 카드 안 업무 리스트 patch (서버 재조회 없이) ──
function homeTaskRowHtml(t) {
const assigneeDisplay = t.assignee_name || (t.assignee_email ? idOf(t.assignee_email) : "");
const today = new Date().toISOString().slice(0, 10);
const overdue = !!(t.due_date && !t.completed_at && String(t.due_date).slice(0, 10) < today);
return '<li class="pj-project-taskrow' + (t.completed_at ? " is-done" : "") + '" data-task-id="' + t.id + '">' +
'<a href="/project/p/' + t.project_id + '?task=' + t.id + '" data-task-id="' + t.id + '" class="pj-task-popup-link">' +
'<span class="pj-project-task-lead">' +
(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>"
: "") +
"</span>" +
'<span class="pj-project-task-title">' + escapeHtml(t.title || "") + "</span>" +
(t.stage_name ? '<span class="pj-project-task-stage">' + escapeHtml(t.stage_name) + "</span>" : "") +
(assigneeDisplay
? '<span class="pj-project-task-assignee"><span class="material-symbols-outlined pj-gicon pj-gicon-sm">account_circle</span>' +
escapeHtml(assigneeDisplay) + "</span>"
: "") +
(t.due_date ? '<span class="pj-project-task-due' + (overdue ? " is-overdue" : "") + '">' + fmtDateKr(t.due_date) + "</span>" : "") +
"</a></li>";
}
function updateHomeCardProgress(projectId) {
const card = document.querySelector('.pj-project-card[data-id="' + projectId + '"]');
if (!card) return;
const total = card.querySelectorAll(".pj-project-taskrow").length;
const done = card.querySelectorAll(".pj-project-taskrow.is-done").length;
const meta = card.querySelector(".pj-project-meta");
if (meta) {
let chip = null;
meta.querySelectorAll(".pj-chip").forEach(function (c) { if (c.textContent.indexOf("업무") === 0) chip = c; });
if (total) {
const label = "업무 " + done + "/" + total;
if (chip) chip.textContent = label;
else { chip = document.createElement("span"); chip.className = "pj-chip"; chip.textContent = label; meta.appendChild(chip); }
} else if (chip) chip.remove();
}
const body = card.querySelector(".pj-project-body");
let bar = card.querySelector(".pj-project-progress-bar");
if (total) {
if (!bar && body) {
const w = document.createElement("div");
w.className = "pj-project-progress";
w.innerHTML = '<div class="pj-project-progress-bar"></div>';
body.appendChild(w);
bar = w.firstElementChild;
}
if (bar) bar.style.width = Math.round(done * 100 / total) + "%";
} else if (bar) {
const prog = bar.closest(".pj-project-progress");
if (prog) prog.remove();
}
}
function upsertHomeTaskRow(t) {
const card = document.querySelector('.pj-project-card[data-id="' + t.project_id + '"]');
if (!card) { syncMyTaskPanel(t); return; }
homeTasks[t.id] = t;
let list = card.querySelector(".pj-project-tasklist");
const emptyMsg = card.querySelector(".pj-project-tasks-empty");
if (!list) {
list = document.createElement("ul");
list.className = "pj-project-tasklist";
if (emptyMsg) emptyMsg.replaceWith(list);
else { const body = card.querySelector(".pj-project-body"); if (body) body.appendChild(list); }
}
const wrap = document.createElement("div");
wrap.innerHTML = homeTaskRowHtml(t);
const newLi = wrap.firstElementChild;
newLi.querySelector(".pj-task-popup-link").addEventListener("click", function (e) {
e.preventDefault();
if (openHomeTaskModalRef) openHomeTaskModalRef(t);
});
const badge = newLi.querySelector(".pj-cmt-badge");
if (badge) {
badge.addEventListener("click", function (e) { e.preventDefault(); e.stopPropagation(); });
badge.addEventListener("dblclick", function (e) {
e.preventDefault(); e.stopPropagation();
if (openCommentsModalRef) openCommentsModalRef(t.id);
});
}
// 서버가 처음 그린 <li>(macro)는 data-task-id 를 <li> 자체가 아니라
// 안쪽 .pj-task-popup-link 에만 붙인다 — 그 기준으로 찾아야 최초
// 페이지 로드 때부터 있던 행(아직 한 번도 patch 안 된 행)도 잡힌다.
const existingLink = list.querySelector('.pj-task-popup-link[data-task-id="' + t.id + '"]');
const existing = existingLink ? existingLink.closest("li") : null;
if (existing) existing.replaceWith(newLi); else list.appendChild(newLi);
updateHomeCardProgress(t.project_id);
syncMyTaskPanel(t);
}
function removeHomeTaskRow(id, projectId) {
const link = document.querySelector('.pj-project-taskrow .pj-task-popup-link[data-task-id="' + id + '"]');
const li = link ? link.closest("li") : null;
const pid = projectId || (li && li.closest(".pj-project-card") && li.closest(".pj-project-card").dataset.id);
if (li) {
const list = li.closest(".pj-project-tasklist");
li.remove();
if (list && !list.children.length) {
const empty = document.createElement("div");
empty.className = "pj-project-tasks-empty";
empty.textContent = "진행 중인 업무가 없습니다.";
list.replaceWith(empty);
}
}
delete homeTasks[id];
if (pid) updateHomeCardProgress(pid);
syncMyTaskPanel({ id: id, project_id: pid }, true);
}
// "내 업무" 패널(우측) 동기화 — 이 프로젝트에 내 업무 그룹이 이미 있을
// 때만 patch 한다(그룹 자체가 없던 경우는 드물어 다음 새로고침까지 생략).
function syncMyTaskPanel(t, removed) {
const group = document.querySelector('.pj-mytree-group[data-group-id="' + t.project_id + '"] .pj-mytree-tasks');
if (!group) return;
const isMine = !removed && t.assignee_email && String(t.assignee_email).toLowerCase() === me;
const existingLink = group.querySelector('.pj-task-popup-link[data-task-id="' + t.id + '"]');
const existingLi = existingLink ? existingLink.closest("li") : null;
if (!isMine) { if (existingLi) existingLi.remove(); return; }
const dates = t.start_date || t.due_date
? '<span class="pj-mytree-dates">' +
(t.start_date && t.due_date ? fmtDateKr(t.start_date) + " ~ " + fmtDateKr(t.due_date)
: t.due_date ? "~ " + fmtDateKr(t.due_date) : fmtDateKr(t.start_date) + " ~") +
"</span>"
: "";
const html = '<li class="pj-mytree-task' + (t.completed_at ? " is-done" : "") + '" data-task-id="' + t.id + '">' +
'<a href="/project/p/' + t.project_id + '?task=' + t.id + '" data-task-id="' + t.id + '" class="pj-task-popup-link">' +
'<span class="pj-mytree-lead">' +
(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>"
: "") +
"</span>" +
'<span class="pj-mytree-body"><span class="pj-mytree-title">' + escapeHtml(t.title || "") + "</span>" + dates + "</span>" +
"</a></li>";
const wrap = document.createElement("div");
wrap.innerHTML = html;
const newLi = wrap.firstElementChild;
newLi.querySelector(".pj-task-popup-link").addEventListener("click", function (e) {
e.preventDefault();
if (openHomeTaskModalRef) openHomeTaskModalRef(t);
});
if (existingLi) existingLi.replaceWith(newLi); else group.appendChild(newLi);
}
// ── 완료 프로젝트 섹션 접기/펼치기 (localStorage 기억) ──
// display 를 인라인 스타일로 직접 제어 — 외부 CSS 규칙과의 특이도/우선순위
// 문제를 원천 차단(어떤 스타일시트보다 인라인 style 이 항상 우선한다).
@@ -148,36 +506,25 @@
try { await api("POST", "/project/api/projects/" + pid + "/members", { user_email: email }); }
catch (_) {}
}
location.reload();
} catch (e) { alert("생성 실패: " + e.message); }
closeModal(modal);
if (grid) {
const card = projectCardHtml(res.project);
grid.insertBefore(card, grid.firstChild);
wireProjectCard(card);
}
} catch (e) { showToast("생성 실패: " + e.message, "error"); }
});
}
// ── 프로젝트 수정 모달 (진행중/완료 두 그리드 모두 대상) ──
const editModal = el("pj-modal-edit");
if (editModal && grid) {
wireModalClose(editModal);
let getEditColor = null;
document.querySelectorAll(".pj-project-card .pj-card-edit").forEach(function (btn) {
btn.addEventListener("click", function (e) {
e.preventDefault(); e.stopPropagation();
const card = btn.closest(".pj-project-card");
el("pj-e-id").value = card.dataset.id;
el("pj-e-name").value = card.dataset.name || "";
el("pj-e-desc").value = card.dataset.desc || "";
el("pj-e-start").value = card.dataset.start || "";
el("pj-e-due").value = card.dataset.due || "";
el("pj-e-done").checked = card.dataset.status === "completed";
getEditColor = renderColorPalette(el("pj-e-colors"), null, card.dataset.color);
openModal(editModal);
});
});
el("pj-save-edit").addEventListener("click", async function () {
const id = el("pj-e-id").value;
const name = el("pj-e-name").value.trim();
if (!name) { alert("이름을 입력하세요."); return; }
try {
await api("PUT", "/project/api/projects/" + id, {
const res = await api("PUT", "/project/api/projects/" + id, {
name: name,
description: el("pj-e-desc").value.trim(),
start_date: el("pj-e-start").value || null,
@@ -185,16 +532,22 @@
color: getEditColor ? getEditColor() : undefined,
status: el("pj-e-done").checked ? "completed" : "active",
});
location.reload();
} catch (e) { alert("저장 실패: " + e.message); }
const card = document.querySelector('.pj-project-card[data-id="' + id + '"]');
if (card) patchProjectCardDom(card, res.project);
closeModal(editModal);
} catch (e) { showToast("저장 실패: " + e.message, "error"); }
});
el("pj-delete-project").addEventListener("click", async function () {
const id = el("pj-e-id").value;
const card = document.querySelector('.pj-project-card[data-id="' + id + '"]');
if (card && !canDelete(card.dataset.creator)) { alert("삭제는 만든 사람만 할 수 있습니다."); return; }
if (!confirm("이 프로젝트를 삭제할까요? 안의 업무도 모두 삭제됩니다.")) return;
try { await api("DELETE", "/project/api/projects/" + id); location.reload(); }
catch (e) { alert("삭제 실패: " + e.message); }
try {
await api("DELETE", "/project/api/projects/" + id);
if (card) card.remove();
closeModal(editModal);
}
catch (e) { showToast("삭제 실패: " + e.message, "error"); }
});
}
@@ -414,6 +767,7 @@
loadTaskExtras(task);
homeTaskSnapshot = homeTaskFormSnapshot();
}
openHomeTaskModalRef = openHomeTaskModal;
// 카드 이름 옆 "업무 추가" 버튼 — 같은 모달을 새 업무 작성용으로 초기화해 연다.
async function openHomeNewTaskModal(projectId, projectName) {
@@ -439,6 +793,7 @@
await Promise.all([fillAssignee(projectId, ""), fillStage(projectId, null)]);
homeTaskSnapshot = homeTaskFormSnapshot();
}
openNewTaskModalRef = openHomeNewTaskModal;
function setHomeCompleteBtn(done) {
const btn = el("pj-toggle-complete");
btn.textContent = done ? "↩ 완료 취소" : "✓ 완료로 표시";
@@ -450,16 +805,10 @@
if (!id) return;
const done = el("pj-toggle-complete").dataset.completed === "1";
try {
await api("PUT", "/project/api/tasks/" + id, { completed: !done });
location.reload();
} catch (e) { alert("처리 실패: " + e.message); }
});
document.querySelectorAll(".pj-card-addtask").forEach(function (btn) {
btn.addEventListener("click", function (e) {
e.preventDefault(); e.stopPropagation();
const card = btn.closest(".pj-project-card");
openHomeNewTaskModal(btn.dataset.projectId, card ? card.dataset.name : "");
});
const res = await api("PUT", "/project/api/tasks/" + id, { completed: !done });
setHomeCompleteBtn(!!res.task.completed_at);
upsertHomeTaskRow(res.task);
} catch (e) { showToast("처리 실패: " + e.message, "error"); }
});
el("pj-t-usetime").addEventListener("change", function () {
@@ -492,21 +841,29 @@
};
if (!payload.title) { alert("제목을 입력하세요."); return; }
try {
let res;
if (id) {
await api("PUT", "/project/api/tasks/" + id, payload);
res = await api("PUT", "/project/api/tasks/" + id, payload);
} else {
if (!pid) return;
await api("POST", "/project/api/projects/" + pid + "/tasks", payload);
res = await api("POST", "/project/api/projects/" + pid + "/tasks", payload);
}
location.reload();
} catch (e) { alert("저장 실패: " + e.message); }
upsertHomeTaskRow(res.task);
homeTaskSnapshot = null;
closeModal(homeTaskModal);
} catch (e) { showToast("저장 실패: " + e.message, "error"); }
});
el("pj-delete-task").addEventListener("click", async function () {
const id = el("pj-t-id").value;
if (!id) return;
const pid = el("pj-t-project-id").value;
if (!confirm("이 업무를 삭제할까요?")) return;
try { await api("DELETE", "/project/api/tasks/" + id); location.reload(); }
catch (e) { alert("삭제 실패: " + e.message); }
try {
await api("DELETE", "/project/api/tasks/" + id);
removeHomeTaskRow(id, pid);
closeModal(homeTaskModal);
}
catch (e) { showToast("삭제 실패: " + e.message, "error"); }
});
document.querySelectorAll(".pj-task-popup-link").forEach(function (a) {
@@ -603,6 +960,7 @@
const n = await renderChat(taskId);
if (n !== null) { markSeen(taskId, n); syncBadge(taskId, n); }
}
openCommentsModalRef = openCommentsModal;
document.querySelectorAll(".pj-cmt-badge").forEach(function (b) {
b.addEventListener("click", function (e) { e.preventDefault(); e.stopPropagation(); });
@@ -628,6 +986,9 @@
if (e.key === "Enter") { e.preventDefault(); el("pj-cm-send").click(); }
});
}
// 최초 로드된 카드 + 이후 새로 만든 카드 모두 여기서 한 번에 배선.
document.querySelectorAll(".pj-project-card").forEach(wireProjectCard);
}
// ════════════════════════════════════════════════════════════
@@ -661,10 +1022,10 @@
const currentProject = data.project || {};
let stages = data.stages || [];
let tasks = data.tasks || [];
const members = data.members || [];
let members = data.members || [];
const priorityLabels = data.priorityLabels || {};
// 전체 프로젝트 집계 데이터 — 4개 뷰는 전 프로젝트를 보여준다.
const projectsMeta = data.projects || [];
let projectsMeta = data.projects || [];
const stagesByProject = data.stagesByProject || {};
const boardStages = data.boardStages || [];
const avatars = data.avatars || {};
@@ -892,6 +1253,11 @@
// 드래그(포인터 기반). 안 움직이면 클릭=모달, +N 칩 클릭=그날 팝오버.
function wireCalendarDrag(cal) {
cal.querySelectorAll(".pj-bar[data-task-id]").forEach(function (bar) {
if (canManage) bar.addEventListener("contextmenu", function (e) {
e.preventDefault();
const t = tasks.find(function (x) { return String(x.id) === String(bar.dataset.taskId); });
if (t) showContextMenu(e.clientX, e.clientY, taskContextItems(t));
});
bar.addEventListener("mousedown", function (e) {
if (e.button !== 0) return;
e.preventDefault();
@@ -1118,6 +1484,13 @@
const t = tasks.find(function (x) { return x.id === props.items[0]; });
if (t) openTaskModal(t);
});
// 배경 블록("bg"+그룹id)이나 빈 공간 우클릭은 무시 — 업무 아이템일 때만 메뉴.
timeline.on("contextmenu", function (props) {
if (props.event && props.event.preventDefault) props.event.preventDefault();
if (!canManage || props.item == null || String(props.item).indexOf("bg") === 0) return;
const t = tasks.find(function (x) { return x.id === props.item; });
if (t) showContextMenu(props.event.clientX, props.event.clientY, taskContextItems(t));
});
// 사용자가 직접 확대/이동했을 때만 저장(autofit 등 프로그램 변경은 무시).
timeline.on("rangechanged", function (p) { if (p.byUser) saveTLWindow(p.start, p.end); });
}
@@ -1315,37 +1688,47 @@
}
function wireColumnControls(col, s) {
const renameBtn = col.querySelector(".pj-col-rename");
if (renameBtn) renameBtn.addEventListener("click", async function (e) {
e.stopPropagation();
async function renameStage() {
const name = prompt("세션 이름", s.name);
if (!name || !name.trim() || name.trim() === s.name) return;
try {
await api("PUT", "/project/api/projects/" + projectId + "/stages/" + s.id, { name: name.trim() });
await reloadStagesForCurrentProject();
renderBoard();
} catch (err) { alert("이름 변경 실패: " + err.message); }
});
const doneBtn = col.querySelector(".pj-col-toggledone");
if (doneBtn) doneBtn.addEventListener("click", async function (e) {
e.stopPropagation();
} catch (err) { showToast("이름 변경 실패: " + err.message, "error"); }
}
async function toggleDoneStage() {
try {
await api("PUT", "/project/api/projects/" + projectId + "/stages/" + s.id,
{ is_done_stage: !s.is_done_stage });
await reloadStagesForCurrentProject();
renderBoard();
} catch (err) { alert("변경 실패: " + err.message); }
});
const delBtn = col.querySelector(".pj-col-del");
if (delBtn) delBtn.addEventListener("click", async function (e) {
e.stopPropagation();
} catch (err) { showToast("변경 실패: " + err.message, "error"); }
}
async function deleteStage() {
if (!canDelete(s.created_by)) { alert(DELETE_DENIED); return; }
if (!confirm("'" + s.name + "' 세션을 삭제할까요? 이 세션의 업무는 '미지정'이 됩니다.")) return;
try {
await api("DELETE", "/project/api/projects/" + projectId + "/stages/" + s.id);
await reloadStagesForCurrentProject();
renderBoard();
} catch (err) { alert("삭제 실패: " + err.message); }
} catch (err) { showToast("삭제 실패: " + err.message, "error"); }
}
const renameBtn = col.querySelector(".pj-col-rename");
if (renameBtn) renameBtn.addEventListener("click", function (e) { e.stopPropagation(); renameStage(); });
const doneBtn = col.querySelector(".pj-col-toggledone");
if (doneBtn) doneBtn.addEventListener("click", function (e) { e.stopPropagation(); toggleDoneStage(); });
const delBtn = col.querySelector(".pj-col-del");
if (delBtn) delBtn.addEventListener("click", function (e) { e.stopPropagation(); deleteStage(); });
const head = col.querySelector(".pj-col-head");
if (head) head.addEventListener("contextmenu", function (e) {
e.preventDefault();
showContextMenu(e.clientX, e.clientY, [
{ label: "이름 변경", onClick: renameStage },
{ label: s.is_done_stage ? "완료 세션 해제" : "완료 세션으로 지정", onClick: toggleDoneStage },
null,
{ label: "삭제", danger: true, onClick: deleteStage },
]);
});
}
@@ -1455,6 +1838,10 @@
card.classList.add("is-dragging");
});
card.addEventListener("dragend", function () { card.classList.remove("is-dragging"); });
card.addEventListener("contextmenu", function (e) {
e.preventDefault();
showContextMenu(e.clientX, e.clientY, taskContextItems(t));
});
}
return card;
}
@@ -1547,7 +1934,14 @@
}
return "<td>" + inner + "</td>";
}).join("");
if (canManage) { tr.style.cursor = "pointer"; tr.addEventListener("click", function () { openTaskModal(t); }); }
if (canManage) {
tr.style.cursor = "pointer";
tr.addEventListener("click", function () { openTaskModal(t); });
tr.addEventListener("contextmenu", function (e) {
e.preventDefault();
showContextMenu(e.clientX, e.clientY, taskContextItems(t));
});
}
return tr;
}
@@ -1631,6 +2025,38 @@
if (typeof renderTree === "function") renderTree();
}
// 업무 우클릭 메뉴 공용 항목 — 보드 카드·리스트 행·달력 막대·타임라인
// 아이템 4곳이 전부 이 함수 하나를 쓴다(열기/완료토글/삭제, 각각 기존
// 버튼과 동일한 API 경로·권한 검사를 그대로 재사용).
function taskContextItems(t) {
return [
{ label: "열기", onClick: function () { openTaskModal(t); } },
{
label: t.completed_at ? "완료 취소" : "완료로 표시",
onClick: async function () {
try {
const res = await api("PUT", "/project/api/tasks/" + t.id, { completed: !t.completed_at });
mergeTask(res.task);
refreshActiveView();
} catch (e) { showToast("처리 실패: " + e.message, "error"); }
},
},
null,
{
label: "삭제", danger: true,
onClick: async function () {
if (!canDelete(t.created_by)) { alert(DELETE_DENIED); return; }
if (!confirm("이 업무를 삭제할까요?")) return;
try {
await api("DELETE", "/project/api/tasks/" + t.id);
tasks = tasks.filter(function (x) { return x.id !== t.id; });
refreshActiveView();
} catch (e) { showToast("삭제 실패: " + e.message, "error"); }
},
},
];
}
// ── 업무 모달 ──
const taskModal = el("pj-modal-task");
let currentTaskId = null;
@@ -1998,17 +2424,21 @@
'<span class="pj-tree-avatar" style="background:' + (p.color || "#4573d2") + '">' + initial + "</span>" +
'<span class="pj-tree-name">' + escapeHtml(p.name) + "</span></a>" + acts + "</div></li>";
}
async function deleteTreeProject(id, name, createdBy) {
if (!canDelete(createdBy)) { alert(DELETE_DENIED); return; }
if (!confirm("'" + (name || "") + "' 프로젝트를 삭제할까요? 안의 업무도 모두 삭제됩니다.")) return;
try {
await api("DELETE", "/project/api/projects/" + id);
if (String(id) === String(projectId)) { location.href = "/project/"; return; }
projectsMeta = projectsMeta.filter(function (x) { return String(x.id) !== String(id); });
renderTree();
} catch (err) { showToast("삭제 실패: " + err.message, "error"); }
}
function wireTreeHandlers(rootUl) {
rootUl.querySelectorAll(".pj-tree-del").forEach(function (b) {
b.addEventListener("click", async function (e) {
b.addEventListener("click", function (e) {
e.preventDefault(); e.stopPropagation();
if (!canDelete(b.dataset.creator)) { alert(DELETE_DENIED); return; }
if (!confirm("'" + (b.dataset.name || "") + "' 프로젝트를 삭제할까요? 안의 업무도 모두 삭제됩니다.")) return;
try {
await api("DELETE", "/project/api/projects/" + b.dataset.id);
if (String(b.dataset.id) === String(projectId)) location.href = "/project/";
else location.reload();
} catch (err) { alert("삭제 실패: " + err.message); }
deleteTreeProject(b.dataset.id, b.dataset.name, b.dataset.creator);
});
});
rootUl.querySelectorAll(".pj-tree-edit").forEach(function (b) {
@@ -2017,6 +2447,24 @@
openProjectEditModal(b.dataset);
});
});
if (isAdmin) rootUl.querySelectorAll(".pj-tree-li").forEach(function (li) {
li.addEventListener("contextmenu", function (e) {
e.preventDefault();
const editBtn = li.querySelector(".pj-tree-edit");
const delBtn = li.querySelector(".pj-tree-del");
showContextMenu(e.clientX, e.clientY, [
{ label: "열기", onClick: function () { location.href = "/project/p/" + li.dataset.id; } },
{ label: "수정", onClick: function () { if (editBtn) openProjectEditModal(editBtn.dataset); } },
null,
{
label: "삭제", danger: true,
onClick: function () {
if (delBtn) deleteTreeProject(delBtn.dataset.id, delBtn.dataset.name, delBtn.dataset.creator);
},
},
]);
});
});
}
function renderTree() {
const rootUl = el("pj-tree-root");
@@ -2024,6 +2472,17 @@
rootUl.innerHTML = projectsMeta.map(treeProjLi).join("");
wireTreeHandlers(rootUl);
}
function mergeProject(p) {
const i = projectsMeta.findIndex(function (x) { return String(x.id) === String(p.id); });
if (i >= 0) projectsMeta[i] = Object.assign({}, projectsMeta[i], p);
else projectsMeta.push(p);
renderTree();
if (String(p.id) === String(projectId)) {
Object.assign(currentProject, p);
const titleEl = document.querySelector(".pj-project-title");
if (titleEl) titleEl.textContent = currentProject.name || "";
}
}
renderTree();
// 새 최상위 프로젝트 (관리자)
@@ -2033,9 +2492,10 @@
if (!name || !name.trim()) return;
const due = prompt("마감일 (YYYY-MM-DD, 없으면 취소 없이 비워두고 확인)") || "";
try {
await api("POST", "/project/api/projects", { name: name.trim(), due_date: due.trim() || null });
location.reload();
} catch (e) { alert("생성 실패: " + e.message); }
const res = await api("POST", "/project/api/projects", { name: name.trim(), due_date: due.trim() || null });
stagesByProject[res.project.id] = [];
mergeProject(res.project);
} catch (e) { showToast("생성 실패: " + e.message, "error"); }
});
// ── 좌측 프로젝트 목록 ✎ 버튼 — 프로젝트 수정 모달(홈 화면과 동일).
@@ -2060,7 +2520,7 @@
const name = el("pj-e-name").value.trim();
if (!name) { alert("이름을 입력하세요."); return; }
try {
await api("PUT", "/project/api/projects/" + id, {
const res = await api("PUT", "/project/api/projects/" + id, {
name: name,
description: el("pj-e-desc").value.trim(),
start_date: el("pj-e-start").value || null,
@@ -2068,25 +2528,58 @@
color: getEditColor ? getEditColor() : undefined,
status: el("pj-e-done").checked ? "completed" : "active",
});
location.reload();
} catch (e) { alert("저장 실패: " + e.message); }
mergeProject(res.project);
closeModal(editModal);
} catch (e) { showToast("저장 실패: " + e.message, "error"); }
});
el("pj-delete-project").addEventListener("click", async function () {
el("pj-delete-project").addEventListener("click", function () {
const id = el("pj-e-id").value;
const p = projectsMeta.find(function (x) { return String(x.id) === String(id); });
if (p && !canDelete(p.created_by)) { alert(DELETE_DENIED); return; }
if (!confirm("이 프로젝트를 삭제할까요? 안의 업무도 모두 삭제됩니다.")) return;
try {
await api("DELETE", "/project/api/projects/" + id);
if (String(id) === String(projectId)) location.href = "/project/";
else location.reload();
} catch (e) { alert("삭제 실패: " + e.message); }
closeModal(editModal);
deleteTreeProject(id, p && p.name, p && p.created_by);
});
}
// ── 멤버 배정/제외 (관리자) ──
const memberModal = el("pj-modal-member");
wireModalClose(memberModal);
function memberRowHtml(m) {
const url = avatars[String(m.user_email).toLowerCase()];
return "<li data-email=\"" + escapeHtml(m.user_email) + "\">" +
(url
? '<img class="pj-avatar pj-gicon-sm" src="' + escapeHtml(url) + '" alt="" referrerpolicy="no-referrer" />'
: '<span class="material-symbols-outlined pj-gicon">account_circle</span>') +
'<span class="pj-member-name">' + escapeHtml(idOf(m.user_email)) + "</span>" +
(m.role === "manager" ? '<span class="pj-chip pj-chip-sm">관리</span>' : "") +
(isAdmin ? '<button type="button" class="pj-icon-btn pj-del-member" data-email="' +
escapeHtml(m.user_email) + '" title="제외">×</button>' : "") +
"</li>";
}
async function removeMember(email) {
if (!confirm("이 멤버를 프로젝트에서 제외할까요?")) return;
try {
await api("DELETE", "/project/api/projects/" + projectId + "/members/" + encodeURIComponent(email));
members = members.filter(function (m) { return m.user_email !== email; });
renderMemberList();
} catch (e) { showToast("제외 실패: " + e.message, "error"); }
}
function renderMemberList() {
const ul = el("pj-member-list");
if (!ul) return;
ul.innerHTML = members.length ? members.map(memberRowHtml).join("") : "<li class='pj-empty-sm'>없음</li>";
ul.querySelectorAll(".pj-del-member").forEach(function (b) {
b.addEventListener("click", function () { removeMember(b.dataset.email); });
});
if (isAdmin) ul.querySelectorAll("li[data-email]").forEach(function (li) {
li.addEventListener("contextmenu", function (e) {
e.preventDefault();
showContextMenu(e.clientX, e.clientY, [
{ label: "제외", danger: true, onClick: function () { removeMember(li.dataset.email); } },
]);
});
});
}
renderMemberList();
const addMemberBtn = el("pj-add-member");
if (addMemberBtn) addMemberBtn.addEventListener("click", async function () {
const sel = el("pj-m-user");
@@ -2114,20 +2607,13 @@
if (!sel.value) { alert("직원을 선택하세요."); return; }
const name = sel.selectedOptions[0] ? (sel.selectedOptions[0].dataset.name || "") : "";
try {
await api("POST", "/project/api/projects/" + projectId + "/members", {
const res = await api("POST", "/project/api/projects/" + projectId + "/members", {
user_email: sel.value, user_name: name, role: el("pj-m-role").value,
});
location.reload();
} catch (e) { alert("배정 실패: " + e.message); }
});
document.querySelectorAll(".pj-del-member").forEach(function (b) {
b.addEventListener("click", async function () {
if (!confirm("이 멤버를 프로젝트에서 제외할까요?")) return;
try {
await api("DELETE", "/project/api/projects/" + projectId + "/members/" + encodeURIComponent(b.dataset.email));
location.reload();
} catch (e) { alert("제외 실패: " + e.message); }
});
members.push(res.member);
renderMemberList();
closeModal(memberModal);
} catch (e) { showToast("배정 실패: " + e.message, "error"); }
});
// 최초 렌더 — 아사나처럼 프로젝트에 들어오면 보드(세션)가 기본 화면이다
@@ -2135,8 +2621,8 @@
renderView(currentView);
// 사이드 트리에서 업무 클릭(?task=ID)으로 들어오면 해당 업무 모달 자동 열기.
// 단, 처리 후 URL 에서 task 파라미터를 제거한다 — 드래그 이동 후 location.reload()
// 시 이 파라미터가 남아 모달이 다시 뜨는 것을 방지.
// 단, 처리 후 URL 에서 task 파라미터를 제거한다 — 같은 페이지에 머무는 동안
// 다시 열리지 않도록.
const params = new URLSearchParams(location.search);
const taskParam = params.get("task");
if (taskParam) {
@@ -2146,6 +2632,34 @@
const t = tasks.find(function (x) { return String(x.id) === String(taskParam); });
if (t) openTaskModal(t);
}
// 8초 주기 실시간 동기화(initLiveRefresh)가 전체 새로고침 대신 부를 함수 —
// 이 프로젝트의 업무·세션·멤버만 재조회해 로컬 상태에 patch(입력 중인
// 모달 폼은 건드리지 않는다). "전체 프로젝트" 스코프에 걸린 다른
// 프로젝트의 변경이나 세션 이름변경/재정렬/삭제·멤버 제외·댓글 수정/삭제는
// live-version 자체가 감지 못하는 기존 사각지대 — 다음 새로고침까지 반영 안 됨.
softSyncProjectRef = async function () {
try {
const [tRes, sRes, mRes] = await Promise.all([
api("GET", "/project/api/projects/" + projectId + "/tasks"),
api("GET", "/project/api/projects/" + projectId + "/stages"),
api("GET", "/project/api/projects/" + projectId + "/members"),
]);
// 서버 페이지 렌더 시엔 router.py 가 각 업무에 context_project_id(=지금
// 보는 프로젝트 id)를 붙여준다(멀티호밍 스코프 필터의 기준) — 이 API
// 응답엔 그 필드가 없으므로 직접 붙여야 visibleTasks() 필터에서
// 안 사라진다.
(tRes.tasks || []).forEach(function (t) {
t.context_project_id = projectId;
mergeTask(t);
});
stages = sRes.stages || [];
stagesByProject[projectId] = stages;
members = mRes.members || [];
renderMemberList();
refreshActiveView();
} catch (_) {}
};
}
// ════════════════════════════════════════════════════════════
@@ -2639,25 +3153,43 @@
const readAll = document.getElementById("pj-readall");
if (!inbox && !readAll) return;
if (readAll) readAll.addEventListener("click", async function () {
try { await fetch("/project/api/notifications/read-all", { method: "POST" }); location.reload(); }
catch (e) { alert("실패: " + e.message); }
try {
await fetch("/project/api/notifications/read-all", { method: "POST" });
if (inbox) inbox.querySelectorAll(".pj-inbox-item.is-unread").forEach(function (li) {
li.classList.remove("is-unread");
});
if (inbox) inbox.querySelectorAll(".pj-inbox-item").forEach(function (li) {
li.addEventListener("click", async function (e) {
if (e.target.closest("a")) return; // '이동' 링크는 통과
}
catch (e) { showToast("실패: " + e.message, "error"); }
});
async function markRead(li) {
if (!li.classList.contains("is-unread")) return;
try {
await fetch("/project/api/notifications/" + li.dataset.id + "/read", { method: "POST" });
li.classList.remove("is-unread");
} catch (_) {}
}
if (inbox) inbox.querySelectorAll(".pj-inbox-item").forEach(function (li) {
li.addEventListener("click", function (e) {
if (e.target.closest("a")) return; // '이동' 링크는 통과
markRead(li);
});
li.addEventListener("contextmenu", function (e) {
if (!li.classList.contains("is-unread")) return; // 이미 읽었으면 메뉴 없음
e.preventDefault();
showContextMenu(e.clientX, e.clientY, [
{ label: "읽음으로 표시", onClick: function () { markRead(li); } },
]);
});
});
}
// ── 실시간 새로고침 ──
// 다른 사용자가 프로젝트/업무/댓글/멤버/단계를 추가·수정하면, 같은 화면을 열어둔
// 모든 사용자가 자동으로 새로고침되도록 주기적으로 서버 버전을 확인한다.
// 모달을 열어 입력 중이면 방해하지 않고, 모달이 모두 닫힐 때(closeModal) 반영한다.
// 모든 사용자에게 8초 주기로 반영한다. 전체 새로고침 대신 배경 목록만 조용히
// 다시 받아 patch 한다(입력 중인 모달 폼은 건드리지 않음) — initProject() 가
// 끝나면서 softSyncProjectRef 를 채워준다(홈 페이지는 채우지 않아 벨 배지만
// 갱신). initLiveRefresh 는 어느 페이지든(프로젝트/홈) 항상 이 하나로 처리.
let softSyncProjectRef = null;
function initLiveRefresh() {
if (!document.querySelector(".pj-wrap, .pj-project-view")) return;
let baseline = null;
@@ -2669,8 +3201,9 @@
if (baseline === null) {
baseline = d.ts;
} else if (d.ts !== baseline) {
if (anyModalOpen()) { pjPendingReload = true; }
else { location.reload(); return; }
baseline = d.ts;
initBell();
if (softSyncProjectRef) softSyncProjectRef();
}
}
} catch (_) {}