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:
+646
-113
@@ -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); }
|
||||
});
|
||||
if (inbox) inbox.querySelectorAll(".pj-inbox-item").forEach(function (li) {
|
||||
li.addEventListener("click", async function (e) {
|
||||
if (e.target.closest("a")) return; // '이동' 링크는 통과
|
||||
if (!li.classList.contains("is-unread")) return;
|
||||
try {
|
||||
await fetch("/project/api/notifications/" + li.dataset.id + "/read", { method: "POST" });
|
||||
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");
|
||||
} catch (_) {}
|
||||
});
|
||||
}
|
||||
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 (_) {}
|
||||
|
||||
Reference in New Issue
Block a user