diff --git a/app/modules/project/db.py b/app/modules/project/db.py index 469568b..86a5e6f 100644 --- a/app/modules/project/db.py +++ b/app/modules/project/db.py @@ -801,6 +801,18 @@ class ProjectStore: raise KeyError(stage_id) return self._serialize(row) + def reorder_projects(self, *, ordered_ids: list[int]) -> None: + """홈 화면 프로젝트 카드 드래그 순서 변경. 최상위 프로젝트(parent_id + IS NULL)만 대상 — 서브프로젝트 정렬(부모 안에서의 순서)은 건드리지 않는다.""" + with self._pool.connection() as conn: + with conn.transaction(): + for i, pid in enumerate(ordered_ids): + conn.execute( + "UPDATE projects SET sort_order = %s " + "WHERE id = %s AND parent_id IS NULL", + (i, int(pid)), + ) + def reorder_stages(self, *, project_id: int, ordered_ids: list[int]) -> None: """주어진 순서대로 sort_order 재배정. 해당 프로젝트 단계만 갱신.""" with self._pool.connection() as conn: diff --git a/app/modules/project/router.py b/app/modules/project/router.py index ef05f8a..a61e379 100644 --- a/app/modules/project/router.py +++ b/app/modules/project/router.py @@ -732,6 +732,22 @@ async def api_delete_project(request: Request, project_id: int) -> JSONResponse: return JSONResponse({"ok": True}) +@router.put("/api/projects/order") +async def api_reorder_projects( + request: Request, payload: dict[str, Any] = Body(...) +) -> JSONResponse: + """홈 화면 프로젝트 카드 드래그 순서 변경. 최상위 프로젝트만 대상이며, + 순서는 이 화면을 보는 모든 사용자에게 공유되므로(세션 재정렬과 동일 원칙) + 관리자 전용으로 제한한다.""" + _require_admin(request) + st = _db_or_503(request) + ids = payload.get("ordered_ids") or [] + if not isinstance(ids, list) or not ids: + raise HTTPException(status_code=400, detail="ordered_ids 가 필요합니다.") + st.reorder_projects(ordered_ids=[int(i) for i in ids]) + return JSONResponse({"ok": True}) + + # ──────────────────────────────────────────────────────────── # JSON API — 멤버 (관리자가 사용자 배정) # ──────────────────────────────────────────────────────────── @@ -759,6 +775,38 @@ async def api_assignable_users(request: Request) -> JSONResponse: return JSONResponse({"users": out}) +@router.get("/api/all-users") +async def api_all_users(request: Request) -> JSONResponse: + """프로젝트 화면 좌측 "전체 멤버" 표시용 — project 모듈 권한 보유 등록 + 사용자 전원. assignable-users 와 후보군은 같지만, 이건 멤버 배정(쓰기)이 + 아니라 단순 열람용이라 관리자가 아니어도 볼 수 있다.""" + _require_user(request) + from app.main import user_store # noqa: WPS433 + from app.store import has_module # noqa: WPS433 + + out = [ + { + "email": u["email"], + "name": u.get("name") or u["email"].split("@")[0], + "id": u["email"].split("@")[0], + "avatar": u.get("picture") or "", + } + for u in user_store.list_all() + if has_module(u, "project") + ] + out.sort(key=lambda x: x["id"]) + return JSONResponse({"users": out}) + + +@router.get("/api/users/tasks") +async def api_user_tasks(request: Request, email: str) -> JSONResponse: + """'전체 멤버' 아이콘 클릭 팝업 — 지정한 사용자가 담당자인 업무 목록 + (프로젝트/세션/마감일). /api/my-tasks 와 같은 조회를 이메일만 바꿔 재사용.""" + _require_user(request) + st = _db_or_503(request) + return JSONResponse({"tasks": st.list_tasks(assignee_email=email, limit=500)}) + + @router.get("/api/projects/{project_id}/members") async def api_list_members(request: Request, project_id: int) -> JSONResponse: _require_user(request) diff --git a/app/modules/project/templates/project/inbox.html b/app/modules/project/templates/project/inbox.html index ae6b854..5067a6a 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 2b7331d..2ebe9d5 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 86a2512..cdcb5a6 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 %} - + @@ -66,7 +66,7 @@
- 멤버 + 프로젝트 멤버 {% if is_admin %} {% endif %} @@ -90,6 +90,13 @@ {% endfor %}
+ + +
+
전체 멤버
+
불러오는 중…
+
@@ -102,7 +109,9 @@
-

{{ project.name }}

+

+ {{ project.name }} +

@@ -451,6 +460,18 @@
{% endif %} + + + - + {% endblock %} diff --git a/app/static/project.css b/app/static/project.css index 150b7fd..ba4fd5e 100644 --- a/app/static/project.css +++ b/app/static/project.css @@ -66,6 +66,8 @@ background: #fff; box-shadow: var(--pj-card-shadow); transition: box-shadow .14s, transform .1s; } .pj-project-card:hover { box-shadow: var(--pj-card-shadow-hover); transform: translateY(-1px); } +.pj-project-card[draggable="true"] { cursor: grab; } +.pj-project-card.is-dragging { opacity: .45; } .pj-project-cardlink { display: flex; gap: 12px; padding: 16px; text-decoration: none; color: inherit; } /* 완료 프로젝트 — 비활성(흐리게) */ .pj-project-card.is-done { background: #f6f7f8; opacity: .62; } @@ -239,7 +241,11 @@ .pj-toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; flex: none; } .pj-project-title { flex: 1 1 auto; min-width: 0; margin: 0; padding: 0 16px; text-align: center; - font-size: 20px; font-weight: 800; color: var(--pj-ink); +} +.pj-project-title-pill { + display: inline-block; max-width: 100%; vertical-align: middle; + font-size: 22px; font-weight: 800; color: #fff; + background: var(--pj-color, var(--pj-accent)); border-radius: 14px; padding: 8px 24px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } /* 현재 활성 뷰(보드/리스트/달력/타임라인)가 pj-main 의 남은 세로 공간을 @@ -248,9 +254,17 @@ .pj-view { flex: 1 1 auto; min-height: 0; overflow-y: auto; } .pj-view[hidden] { display: none; } .pj-view[data-view="board"]:not([hidden]) { display: flex; flex-direction: column; overflow: hidden; } -.pj-view-tabs { display: inline-flex; flex: 0 0 auto; background: #eef0f2; border-radius: 10px; padding: 3px; gap: 2px; } -.pj-tab { border: none; background: transparent; padding: 7px 16px; border-radius: 8px; font-size: 13px; font-weight: 600; color: #525860; cursor: pointer; white-space: nowrap; } -.pj-tab.is-active { background: var(--pj-accent); color: #fff; box-shadow: 0 1px 3px rgba(123,104,238,.35); } +.pj-view-tabs { display: inline-flex; position: relative; flex: 0 0 auto; background: #eef0f2; border-radius: 10px; padding: 3px; gap: 2px; } +.pj-tab { position: relative; z-index: 1; border: none; background: transparent; padding: 7px 16px; border-radius: 8px; font-size: 13px; font-weight: 600; color: #525860; cursor: pointer; white-space: nowrap; } +.pj-tab.is-active { background: transparent; color: #fff; } +/* 뷰 탭이 바뀔 때 배경이 새 탭 위치로 미끄러지는 하이라이트 — is-active 의 + 단순 배경색 대신 이 절대배치 요소 하나가 실제로 움직인다. */ +.pj-tab-highlight { + position: absolute; top: 3px; left: 0; z-index: 0; + background: var(--pj-accent); border-radius: 8px; box-shadow: 0 1px 3px rgba(123,104,238,.35); + transition: transform .22s cubic-bezier(.4,0,.2,1), width .22s cubic-bezier(.4,0,.2,1); +} +.pj-tab-highlight.pj-no-anim { transition: none; } /* 알림 벨 흔들림(미읽음 있을 때) */ @keyframes pj-bell-ring { @@ -577,6 +591,32 @@ .pj-member-pick-avatar img.pj-member-pick-icon { width: 36px; height: 36px; } .pj-member-pick-stacked .pj-member-pick-name { font-size: 12.5px; } +/* ── 좌측 사이드바 "전체 멤버" — 4열 그리드, 아이콘 아래 이름(새 프로젝트 + 모달의 pj-member-pick-stacked 와 같은 아이콘 크기 재사용). ── */ +.pj-allmembers-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px 4px; } +.pj-allmember-item { + display: flex; flex-direction: column; align-items: center; gap: 4px; + padding: 6px 2px; border: none; background: none; border-radius: 8px; cursor: pointer; + font: inherit; width: 100%; +} +.pj-allmember-item:hover { background: #f5f6f8; } +.pj-allmember-item .pj-member-pick-icon.material-symbols-outlined { font-size: 40px; } +.pj-allmember-item img.pj-member-pick-icon { width: 36px; height: 36px; } +.pj-allmember-name { font-size: 11.5px; color: #333; text-align: center; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +/* ── 전체 멤버 클릭 팝업 — 담당 업무 목록(프로젝트/세션/제목/마감일) ── */ +.pj-mt-card { width: min(600px, 94vw); } +.pj-mt-list { list-style: none; margin: 0 0 10px; padding: 0; max-height: 50vh; overflow-y: auto; } +.pj-mt-row { + display: grid; grid-template-columns: 100px 90px 1fr 80px; gap: 8px; align-items: center; + padding: 8px 6px; border-bottom: 1px solid #f1f2f4; font-size: 12.5px; +} +.pj-mt-row.is-done { color: #9aa1a9; text-decoration: line-through; } +.pj-mt-proj { font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.pj-mt-stage { color: #6b7280; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.pj-mt-title2 { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.pj-mt-due { text-align: right; color: var(--pj-ink-soft); font-weight: 600; white-space: nowrap; } + /* ── 댓글만 보는 채팅형 팝업(카톡 대화창 느낌) ── */ /* 크기 고정(500x850) — 내용이 많아도 팝업 크기는 그대로, 목록만 스크롤. */ .pj-modal-chat { diff --git a/app/static/project.js b/app/static/project.js index b24999a..370e373 100644 --- a/app/static/project.js +++ b/app/static/project.js @@ -114,6 +114,37 @@ return mm + "/" + dd + "(" + wd + ")"; } + // ── 탭 그룹(뷰 탭/스코프 토글, 둘 다 .pj-view-tabs 마크업 공유)에 슬라이드 + // 하이라이트를 붙인다 — is-active 를 단순 배경색 대신, 실제 위치가 움직이는 + // 절대배치 요소 하나로 표현한다. 탭 클릭·초기 렌더·리사이즈마다 위치 재계산. + function initTabSlider(container) { + if (!container || container.dataset.pjSlider) return null; + container.dataset.pjSlider = "1"; + const hl = document.createElement("div"); + hl.className = "pj-tab-highlight"; + container.insertBefore(hl, container.firstChild); + function move(animate) { + const active = container.querySelector(".pj-tab.is-active"); + if (!active) { hl.style.opacity = "0"; return; } + hl.style.opacity = "1"; + if (!animate) hl.classList.add("pj-no-anim"); + hl.style.width = active.offsetWidth + "px"; + hl.style.height = active.offsetHeight + "px"; + hl.style.transform = "translateX(" + active.offsetLeft + "px)"; + if (!animate) { + // 강제 리플로우로 즉시 반영시킨 뒤 트랜지션을 다시 켠다(다음 클릭부터 애니메이션). + void hl.offsetWidth; + hl.classList.remove("pj-no-anim"); + } + } + move(false); + container.addEventListener("click", function (e) { + if (e.target.closest(".pj-tab")) requestAnimationFrame(function () { move(true); }); + }); + window.addEventListener("resize", function () { move(false); }); + return move; + } + // 멤버 선택 후보 카드(아이콘 위 / 이름 가운데 / 체크박스 아래) — 새 프로젝트 // 모달(홈·프로젝트 페이지)과 멤버 배정 모달 3곳이 전부 이 함수 하나를 쓴다. // /api/assignable-users 응답의 avatar 필드를 그대로 쓰므로 페이지별 @@ -286,6 +317,13 @@ // 카드 하나에 수정/업무추가/우클릭 메뉴를 건다 — 최초 로드된 카드·새로 // 만든 카드 모두 이 함수 하나로 통일(initHome 맨 끝에서 전체 카드에 호출). function wireProjectCard(card) { + if (isAdmin) { + card.draggable = true; + // 는 브라우저 기본값이 draggable="true" — 그대로 두면 카드 대신 + // 링크(URL) 드래그가 시작돼 우리 dragstart 핸들러가 무력화된다. + const link = card.querySelector(".pj-project-cardlink"); + if (link) link.draggable = false; + } const editBtn = card.querySelector(".pj-card-edit"); if (editBtn) editBtn.addEventListener("click", function (e) { e.preventDefault(); e.stopPropagation(); @@ -1039,6 +1077,74 @@ // 최초 로드된 카드 + 이후 새로 만든 카드 모두 여기서 한 번에 배선. document.querySelectorAll(".pj-project-card").forEach(wireProjectCard); + + // 프로젝트 카드 드래그로 순서 변경 — 정렬은 화면을 보는 모두에게 공유되므로 + // (세션 재정렬과 같은 원칙) 관리자만 드래그할 수 있다(wireProjectCard 가 + // isAdmin 일 때만 draggable 을 켠다). + if (isAdmin) wireProjectGridDrag(); + + // ── 실시간 동기화: "내 업무" 패널만 8초 안에 반영(카드 미리보기 목록은 + // 기존처럼 다음 새로고침까지 유지 — initLiveRefresh 주석 참고). 다른 + // 사용자가 내 업무를 수정/완료/재배정해도 여기서 바로 patch 된다. + softSyncProjectRef = async function () { + try { + const res = await api("GET", "/project/api/my-tasks"); + const fresh = res.tasks || []; + const freshIds = new Set(fresh.map(function (t) { return t.id; })); + document.querySelectorAll(".pj-mytree-group .pj-mytree-tasks .pj-task-popup-link[data-task-id]").forEach(function (a) { + const id = Number(a.dataset.taskId); + if (freshIds.has(id)) return; + const group = a.closest(".pj-mytree-group"); + const pid = group ? Number(group.dataset.groupId) : null; + syncMyTaskPanel({ id: id, project_id: pid }, true); + }); + fresh.forEach(function (t) { homeTasks[t.id] = t; syncMyTaskPanel(t); }); + } catch (_) {} + }; + } + + // 프로젝트 카드 드래그 순서 변경 — 진행중/완료 그리드 각각 안에서만 + // 재정렬(그리드 사이 드래그로 옮기면 상태가 바뀐 것처럼 보이니 막는다). + function wireProjectGridDrag() { + const grids = [el("pj-project-grid"), el("pj-project-grid-done")].filter(Boolean); + if (!grids.length) return; + let dragCard = null, sourceGrid = null; + grids.forEach(function (grid) { + grid.addEventListener("dragstart", function (e) { + const card = e.target.closest(".pj-project-card"); + if (!card || card.draggable !== true) return; + dragCard = card; + sourceGrid = grid; + e.dataTransfer.setData("application/x-pj-project", String(card.dataset.id)); + e.dataTransfer.effectAllowed = "move"; + card.classList.add("is-dragging"); + }); + grid.addEventListener("dragend", function () { + if (dragCard) dragCard.classList.remove("is-dragging"); + dragCard = null; sourceGrid = null; + }); + grid.addEventListener("dragover", function (e) { + if (!dragCard || grid !== sourceGrid) return; + const target = e.target.closest(".pj-project-card"); + if (!target || target === dragCard) { e.preventDefault(); return; } + e.preventDefault(); + const r = target.getBoundingClientRect(); + const before = (e.clientX - r.left) < r.width / 2; + grid.insertBefore(dragCard, before ? target : target.nextSibling); + }); + grid.addEventListener("drop", async function (e) { + if (!dragCard || grid !== sourceGrid) return; + e.preventDefault(); + const ids = Array.prototype.slice.call(grid.querySelectorAll(".pj-project-card")) + .map(function (c) { return parseInt(c.dataset.id, 10); }); + try { + await api("PUT", "/project/api/projects/order", { ordered_ids: ids }); + } catch (err) { + showToast("순서 변경 실패: " + err.message, "error"); + location.reload(); + } + }); + }); } // ════════════════════════════════════════════════════════════ @@ -1209,6 +1315,7 @@ // ── 뷰 토글 ── const tabs = el("pj-view-tabs"); + const moveViewHighlight = initTabSlider(tabs); tabs.addEventListener("click", function (e) { const btn = e.target.closest(".pj-tab"); if (!btn) return; @@ -1231,10 +1338,12 @@ document.querySelectorAll(".pj-view").forEach(function (v) { v.hidden = v.dataset.view !== currentView; }); + if (moveViewHighlight) moveViewHighlight(false); } // ── 프로젝트 스코프 토글 ── const scopeToggle = el("pj-scope-toggle"); + const moveScopeHighlight = initTabSlider(scopeToggle); if (scopeToggle) { scopeToggle.addEventListener("click", function (e) { const btn = e.target.closest("[data-scope]"); @@ -1246,6 +1355,7 @@ renderView(currentView); }); } + if (moveScopeHighlight) moveScopeHighlight(false); function renderView(view) { if (view === "calendar") renderCalendar(); @@ -1867,6 +1977,7 @@ enableDropByStageId(body, s.id); wireColumnControls(col, s); wireColumnDrag(col, s); + wireStageAddTask(body, s); } board.appendChild(col); }); @@ -1931,6 +2042,7 @@ if (head) head.addEventListener("contextmenu", function (e) { e.preventDefault(); showContextMenu(e.clientX, e.clientY, [ + { label: "업무 추가", onClick: function () { openTaskModal(null, s.id); } }, { label: "이름 변경", onClick: renameStage }, { label: s.is_done_stage ? "완료 세션 해제" : "완료 세션으로 지정", onClick: toggleDoneStage }, null, @@ -1939,6 +2051,18 @@ }); } + // 세션 본문(카드 사이 빈 공간) 우클릭 — "업무 추가"만. 카드 위에서 우클릭한 + // 경우는 taskCard() 쪽 컨텍스트 메뉴(수정/완료/삭제 등)가 대신 뜨도록 건너뛴다. + function wireStageAddTask(body, s) { + body.addEventListener("contextmenu", function (e) { + if (e.target.closest(".pj-card")) return; + e.preventDefault(); + showContextMenu(e.clientX, e.clientY, [ + { label: "업무 추가", onClick: function () { openTaskModal(null, s.id); } }, + ]); + }); + } + // 세션(컬럼) 드래그 재정렬 — 카드 드래그와 같은 네이티브 HTML5 DnD 방식이되, // dataTransfer 타입을 달리 써서(application/x-pj-stage) 카드 드롭과 안 섞인다. function wireColumnDrag(col, s) { @@ -2604,7 +2728,7 @@ sel.value = currentEmail || ""; } - function openTaskModal(task) { + function openTaskModal(task, presetStageId) { const projName = task ? (task.project_name || currentProject.name || "") : (currentProject.name || ""); el("pj-task-modal-title").textContent = "[" + projName + "]의 " + (task ? "업무 편집" : "새 업무"); el("pj-t-id").value = task ? task.id : ""; @@ -2612,7 +2736,8 @@ if (descEditor) { descEditor.setTaskId(task ? task.id : null); descEditor.setHtml(task ? (task.description || "") : ""); } fillAssigneeSelect(task ? task.project_id : projectId, task ? (task.assignee_email || "") : ""); fillStageSelect(task ? task.project_id : projectId); - el("pj-t-stage").value = task && task.stage_id ? task.stage_id : (stages[0] ? stages[0].id : ""); + el("pj-t-stage").value = task && task.stage_id ? task.stage_id + : (presetStageId != null ? presetStageId : (stages[0] ? stages[0].id : "")); el("pj-t-priority").value = task ? (task.priority || "normal") : "normal"; el("pj-t-start").value = task ? (task.start_date || "") : ""; el("pj-t-due").value = task ? (task.due_date || "") : ""; @@ -3066,6 +3191,56 @@ }); } renderMemberList(); + + // ── 전체 멤버(프로젝트 권한 보유 등록 사용자 전원) — 아이콘 클릭 시 + // 그 사람이 담당자인 업무를 팝업으로 보여준다(프로젝트/세션/마감일). + function allMemberItemHtml(u) { + const icon = u.avatar + ? '' + : 'account_circle'; + return '"; + } + const memberTasksModal = el("pj-modal-member-tasks"); + if (memberTasksModal) wireModalClose(memberTasksModal); + async function openMemberTasksModal(email, name) { + if (!memberTasksModal) return; + el("pj-mt-title").textContent = (name || email) + "님의 업무"; + const list = el("pj-mt-list"); + list.innerHTML = "
  • 불러오는 중…
  • "; + openModal(memberTasksModal); + try { + const res = await api("GET", "/project/api/users/tasks?email=" + encodeURIComponent(email)); + const items = res.tasks || []; + list.innerHTML = items.length + ? items.map(function (t) { + return '
  • ' + + '' + escapeHtml(t.project_name || "-") + "" + + '' + escapeHtml(t.stage_name || "미지정") + "" + + '' + escapeHtml(t.title || "") + "" + + '' + (t.due_date ? fmtDateKr(t.due_date) : "-") + "" + + "
  • "; + }).join("") + : "
  • 배정된 업무가 없습니다.
  • "; + } catch (e) { + list.innerHTML = "
  • 불러오기 실패: " + escapeHtml(e.message) + "
  • "; + } + } + const allMembersGrid = el("pj-allmembers-grid"); + if (allMembersGrid) { + api("GET", "/project/api/all-users").then(function (res) { + const users = res.users || []; + allMembersGrid.innerHTML = users.length + ? users.map(allMemberItemHtml).join("") + : "없음"; + allMembersGrid.querySelectorAll(".pj-allmember-item").forEach(function (btn) { + btn.addEventListener("click", function () { openMemberTasksModal(btn.dataset.email, btn.dataset.name); }); + }); + }).catch(function (e) { + allMembersGrid.innerHTML = "불러오기 실패: " + escapeHtml(e.message) + ""; + }); + } + const addMemberBtn = el("pj-add-member"); if (addMemberBtn) addMemberBtn.addEventListener("click", async function () { const box = el("pj-m-users"); @@ -3126,10 +3301,11 @@ // live-version 자체가 감지 못하는 기존 사각지대 — 다음 새로고침까지 반영 안 됨. softSyncProjectRef = async function () { try { - const [tRes, sRes, mRes] = await Promise.all([ + const [tRes, sRes, mRes, myRes] = await Promise.all([ api("GET", "/project/api/projects/" + projectId + "/tasks"), api("GET", "/project/api/projects/" + projectId + "/stages"), api("GET", "/project/api/projects/" + projectId + "/members"), + api("GET", "/project/api/my-tasks"), ]); // 서버 페이지 렌더 시엔 router.py 가 각 업무에 context_project_id(=지금 // 보는 프로젝트 id)를 붙여준다(멀티호밍 스코프 필터의 기준) — 이 API @@ -3144,6 +3320,21 @@ members = mRes.members || []; renderMemberList(); refreshActiveView(); + // 우측 "내 업무" 패널 — 다른 사람이 내 업무를 수정/완료/재배정해도 + // 다음 전체 새로고침까지 기다리지 않고 8초 안에 반영되게 patch. + const freshMy = myRes.tasks || []; + const freshIds = new Set(freshMy.map(function (t) { return t.id; })); + Object.keys(myTasksById).forEach(function (idStr) { + const id = Number(idStr); + if (freshIds.has(id)) return; + const old = myTasksById[id]; + delete myTasksById[id]; + syncProjectMyTaskPanel(old, true); + }); + freshMy.forEach(function (t) { + myTasksById[t.id] = t; + syncProjectMyTaskPanel(t); + }); } catch (_) {} }; } @@ -3672,9 +3863,10 @@ // ── 실시간 새로고침 ── // 다른 사용자가 프로젝트/업무/댓글/멤버/단계를 추가·수정하면, 같은 화면을 열어둔 // 모든 사용자에게 8초 주기로 반영한다. 전체 새로고침 대신 배경 목록만 조용히 - // 다시 받아 patch 한다(입력 중인 모달 폼은 건드리지 않음) — initProject() 가 - // 끝나면서 softSyncProjectRef 를 채워준다(홈 페이지는 채우지 않아 벨 배지만 - // 갱신). initLiveRefresh 는 어느 페이지든(프로젝트/홈) 항상 이 하나로 처리. + // 다시 받아 patch 한다(입력 중인 모달 폼은 건드리지 않음) — initHome()/ + // initProject() 가 끝나면서 각자 softSyncProjectRef 를 채워준다(홈은 "내 + // 업무" 패널 + 벨 배지, 프로젝트 화면은 보드/리스트/멤버 + "내 업무" 패널). + // initLiveRefresh 는 어느 페이지든(프로젝트/홈) 항상 이 하나로 처리. let softSyncProjectRef = null; function initLiveRefresh() { if (!document.querySelector(".pj-wrap, .pj-project-view")) return;