diff --git a/app/modules/project/templates/project/inbox.html b/app/modules/project/templates/project/inbox.html index 0795d30..b78024d 100644 --- a/app/modules/project/templates/project/inbox.html +++ b/app/modules/project/templates/project/inbox.html @@ -1,7 +1,7 @@ {% extends "erp_base.html" %} {% block head_extra %} - + {% endblock %} @@ -45,5 +45,5 @@ {% endif %} - + {% endblock %} diff --git a/app/modules/project/templates/project/index.html b/app/modules/project/templates/project/index.html index 10d282c..83d0567 100644 --- a/app/modules/project/templates/project/index.html +++ b/app/modules/project/templates/project/index.html @@ -1,7 +1,7 @@ {% extends "erp_base.html" %} {% block head_extra %} - + {% endblock %} @@ -393,5 +393,5 @@ - + {% endblock %} diff --git a/app/modules/project/templates/project/project.html b/app/modules/project/templates/project/project.html index d96ed96..d7060ec 100644 --- a/app/modules/project/templates/project/project.html +++ b/app/modules/project/templates/project/project.html @@ -1,7 +1,7 @@ {% extends "erp_base.html" %} {% block head_extra %} - + @@ -438,5 +438,5 @@ - + {% endblock %} diff --git a/app/static/project.css b/app/static/project.css index 5ea68f9..989d3e3 100644 --- a/app/static/project.css +++ b/app/static/project.css @@ -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; } diff --git a/app/static/project.js b/app/static/project.js index f21d456..f86013b 100644 --- a/app/static/project.js +++ b/app/static/project.js @@ -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 = + '
' + + '' + + '' + + '
' + + '
' + escapeHtml(p.name || "") + "
" + + (p.description ? '
' + escapeHtml(p.description) + "
" : "") + + '
' + + (p.due_date ? '마감 ' + fmtDateKr(p.due_date) + "" : "") + + "
" + + '
' + + '' + + '
' + + '
진행 중인 업무가 없습니다.
'; + 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 '
  • ' + + '' + + '' + + (t.comment_count + ? '' + + '💬' + t.comment_count + "" + : "") + + "" + + '' + escapeHtml(t.title || "") + "" + + (t.stage_name ? '' + escapeHtml(t.stage_name) + "" : "") + + (assigneeDisplay + ? 'account_circle' + + escapeHtml(assigneeDisplay) + "" + : "") + + (t.due_date ? '' + fmtDateKr(t.due_date) + "" : "") + + "
  • "; + } + 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 = '
    '; + 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); + }); + } + // 서버가 처음 그린
  • (macro)는 data-task-id 를
  • 자체가 아니라 + // 안쪽 .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 + ? '' + + (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) + " ~") + + "" + : ""; + const html = '
  • ' + + '' + + '' + + (t.comment_count + ? '' + + '💬' + t.comment_count + "" + : "") + + "" + + '' + escapeHtml(t.title || "") + "" + dates + "" + + "
  • "; + 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 "" + inner + ""; }).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 @@ '' + initial + "" + '' + escapeHtml(p.name) + "" + acts + ""; } + 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 "
  • " + + (url + ? '' + : 'account_circle') + + '' + escapeHtml(idOf(m.user_email)) + "" + + (m.role === "manager" ? '관리' : "") + + (isAdmin ? '' : "") + + "
  • "; + } + 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("") : "
  • 없음
  • "; + 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 (_) {}