diff --git a/backend/app/main.py b/backend/app/main.py index da4b922..33b3ae0 100755 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1020,13 +1020,205 @@ def build_center_chair_viewer_html(layout: dict[str, object]) -> str: f"const STORAGE_KEY = null;\n const placed = new Set({placed_literal});", 1, ) + html = html.replace( + """ ctx.strokeStyle = selected + ? "rgba(220, 38, 38, 0.98)" + : active + ? "rgba(15, 118, 110, 0.98)" + : chair.kind === "group" + ? "rgba(16, 134, 149, 0.74)" + : "rgba(21, 149, 142, 0.8)"; + ctx.lineWidth = (selected ? 2.6 : active ? 2.1 : baseWidth) / camera.scale;""", + """ ctx.strokeStyle = selected + ? "rgba(220, 38, 38, 0.98)" + : "rgba(15, 118, 110, 0.88)"; + ctx.lineWidth = (selected ? 2.6 : active ? 2.0 : 1.6) / camera.scale;""", + 1, + ) html = html.replace( "function persistPlaced() {\n localStorage.setItem(STORAGE_KEY, JSON.stringify([...placed]));\n }", "function persistPlaced() {\n return;\n }", 1, ) + html = html.replace( + """ window.addEventListener("pointerup", (event) => { + if (dragging && dragStart) { + const move = Math.hypot(event.clientX - dragStart.x, event.clientY - dragStart.y); + if (move < 4) { + const rect = canvas.getBoundingClientRect(); + const picked = pickChair(event.clientX - rect.left, event.clientY - rect.top); + if (picked) { + if (placed.has(picked.key)) placed.delete(picked.key); + else placed.add(picked.key); + persistPlaced(); + } + } + } + dragging = false; + dragStart = null; + canvas.classList.remove("dragging"); + requestDraw(); + });""", + """ window.addEventListener("pointerup", () => { + dragging = false; + dragStart = null; + canvas.classList.remove("dragging"); + requestDraw(); + });""", + 1, + ) + html = html.replace( + """ document.getElementById("clear-btn").addEventListener("click", () => { + placed.clear(); + persistPlaced(); + requestDraw(); + });""", + """ document.getElementById("clear-btn").addEventListener("click", () => { + requestDraw(); + });""", + 1, + ) bridge_script = """ + """ diff --git a/frontend/public/app.js b/frontend/public/app.js index bac8944..2785030 100644 --- a/frontend/public/app.js +++ b/frontend/public/app.js @@ -103,6 +103,10 @@ function isAdmin() { return getSession()?.user?.role === "admin"; } +function isSlotBasedSeatMap() { + return seatMapState.seatMap?.source_type === "dxf" || seatMapState.seatMap?.source_type === "fixed_html"; +} + function escapeHtml(value) { return String(value ?? "") .replaceAll("&", "&") @@ -618,6 +622,24 @@ function getDraftPlacedSlotKeys() { .map((value) => String(value)); } +function getSeatAssignmentPayload() { + const slotMap = getSeatSlotMap(); + const memberMap = getMemberMap(); + return getPlacementSource() + .map((placement) => { + const slot = slotMap.get(Number(placement.seat_slot_id)); + const member = memberMap.get(Number(placement.member_id)); + if (!slot || !member) return null; + return { + key: String(slot.slot_key), + member_id: Number(member.id), + name: member.name || "-", + rank: member.rank || "-", + }; + }) + .filter(Boolean); +} + function syncSeatMapViewerFrame() { const frame = seatMapBoard?.querySelector("#seatmap-dxf-frame"); if (!frame?.contentWindow) return; @@ -625,6 +647,22 @@ function syncSeatMapViewerFrame() { { type: "seatmap-set-placed", keys: getDraftPlacedSlotKeys() }, window.location.origin, ); + frame.contentWindow.postMessage( + { type: "seatmap-set-assignments", items: getSeatAssignmentPayload() }, + window.location.origin, + ); +} + +function updateSeatMapDraftUi() { + if (seatMapSaveBtn) { + seatMapSaveBtn.hidden = !isAdmin() || !seatMapState.seatMap; + seatMapSaveBtn.disabled = !seatMapState.dirty; + } + if (seatMapCancelBtn) { + seatMapCancelBtn.hidden = !seatMapState.seatMap; + } + renderUnassignedMembers(); + syncSeatMapViewerFrame(); } function setupSeatMapViewerFrame() { @@ -657,7 +695,7 @@ function setupSeatMapViewerFrame() { const matchedSlot = (seatMapState.slots || []).find((item) => String(item.slot_key) === String(picked.key)); if (!matchedSlot) return; upsertDraftPlacementForSlot(memberId, Number(matchedSlot.id)); - renderSeatMap(); + updateSeatMapDraftUi(); }); }, { once: true }); } @@ -767,6 +805,13 @@ function handleEmbeddedNavigationMessage(event) { hideUserPopover(); setActiveView("organization"); } + if (data.type === "seatmap-clear-slot" && isAdmin()) { + const cleared = clearDraftPlacementBySlotKey(String(data.key || "")); + if (cleared) { + setSeatMapStatus("구성원을 공석으로 이동했습니다. 저장 버튼으로 반영하세요.", "info"); + updateSeatMapDraftUi(); + } + } } async function fetchJson(url, options) { @@ -848,6 +893,15 @@ async function getImageDimensions(file) { }); } +function clearDraftPlacementBySlotKey(slotKey) { + const matchedSlot = (seatMapState.slots || []).find((item) => String(item.slot_key) === String(slotKey)); + if (!matchedSlot) return false; + const placement = (seatMapState.draftPlacements || []).find((item) => Number(item.seat_slot_id) === Number(matchedSlot.id)); + if (!placement) return false; + removeDraftPlacement(Number(placement.member_id)); + return true; +} + async function uploadSeatMapImage(file, name) { const formData = new FormData(); formData.append("file", file); @@ -927,7 +981,7 @@ function handleSeatMapCellDrop(event) { event.preventDefault(); const memberId = getDraggedMemberId(event); if (!memberId) return; - if (seatMapState.seatMap?.source_type === "dxf") { + if (isSlotBasedSeatMap()) { const slot = event.target.closest(".seatmap-slot"); if (slot) { upsertDraftPlacementForSlot(memberId, Number(slot.dataset.slotId)); @@ -957,7 +1011,7 @@ function handleSeatMapListDrop(event) { const memberId = getDraggedMemberId(event); if (!memberId) return; removeDraftPlacement(memberId); - renderSeatMap(); + updateSeatMapDraftUi(); } function setActiveView(view) { @@ -1108,7 +1162,7 @@ if (seatMapFormImage) { if (seatMapBoard) { seatMapBoard.addEventListener("wheel", (event) => { - if (seatMapState.seatMap?.source_type !== "dxf") return; + if (!isSlotBasedSeatMap()) return; event.preventDefault(); zoomDxfSeatMapAtPoint(event.clientX, event.clientY, event.deltaY < 0 ? 1.08 : 0.92); }, { passive: false }); @@ -1119,7 +1173,7 @@ if (seatMapBoard) { }); seatMapBoard.addEventListener("dragover", (event) => { if (!seatMapState.editMode) return; - const target = seatMapState.seatMap?.source_type === "dxf" + const target = isSlotBasedSeatMap() ? (event.target.closest(".seatmap-slot") || event.target.closest("#seatmap-dxf-canvas")) : event.target.closest(".seatmap-cell"); if (!target) return; @@ -1131,7 +1185,7 @@ if (seatMapBoard) { if (seatMapBoardWrap) { seatMapBoardWrap.addEventListener("mousedown", (event) => { - if (seatMapState.seatMap?.source_type !== "dxf") return; + if (!isSlotBasedSeatMap()) return; if (seatMapBoard?.querySelector("#seatmap-dxf-canvas")) return; if (event.button !== 0) return; if (event.target.closest(".seatmap-member-card, button, input, label")) return; @@ -1150,7 +1204,7 @@ if (seatMapBoardWrap) { updateSeatMapViewerHoverChip(); }); seatMapBoardWrap.addEventListener("mousemove", (event) => { - if (seatMapState.seatMap?.source_type !== "dxf") return; + if (!isSlotBasedSeatMap()) return; if (seatMapBoard?.querySelector("#seatmap-dxf-canvas")) return; const slot = event.target.closest(".seatmap-slot"); const nextSlotId = slot ? Number(slot.dataset.slotId) : null; @@ -1207,7 +1261,7 @@ setActiveView(currentView); renderAuth(); window.addEventListener("resize", () => { - if (seatMapState.seatMap?.source_type !== "dxf" || currentView !== "seatmap") return; + if (!isSlotBasedSeatMap() || currentView !== "seatmap") return; requestAnimationFrame(() => { if (seatMapState.zoom === 1) { centerSeatMapBoard(); diff --git a/legacy/static/organization.css b/legacy/static/organization.css index 0d2b9bb..5e0661d 100644 --- a/legacy/static/organization.css +++ b/legacy/static/organization.css @@ -541,6 +541,14 @@ body { overflow: hidden; } +.seat-preview-frame { + display: block; + width: 100%; + height: 300px; + border: 0; + background: #fff; +} + .seat-preview-placeholder { display: flex; flex-direction: column; @@ -551,6 +559,7 @@ body { font-weight: 900; } + .seat-preview-placeholder-icon { width: 52px; height: 52px; diff --git a/legacy/static/organization.js b/legacy/static/organization.js index bea9228..1d8ee80 100644 --- a/legacy/static/organization.js +++ b/legacy/static/organization.js @@ -6,6 +6,7 @@ let collapsedUnits = new Set(); let isListMode = false; let emptyStateMessage = '서버에 조직 데이터가 없습니다. 상단의 업로드 버튼으로 초기 데이터를 넣어주세요.'; let photoPreviewObjectUrl = null; +let seatMapLayoutCache = null; const levelOrder = ['부서', '그룹', '디비전', '팀', '셀']; const dropdownFields = ['소속회사', '직급', '직책', ...levelOrder]; @@ -146,6 +147,44 @@ async function loadMembers(message) { render(); } +async function loadActiveSeatMapLayout(force = false) { + if (seatMapLayoutCache && !force) { + return seatMapLayoutCache; + } + try { + const activePayload = await apiFetch('/api/seat-maps/active'); + const seatMap = activePayload?.item; + if (!seatMap?.id) { + seatMapLayoutCache = null; + return null; + } + const layoutPayload = await apiFetch(`/api/seat-maps/${seatMap.id}/layout`); + seatMapLayoutCache = layoutPayload; + return layoutPayload; + } catch { + seatMapLayoutCache = null; + return null; + } +} + +function getMemberSeatInfo(layout, memberId) { + if (!layout || !memberId) { + return null; + } + const placement = (layout.placements || []).find((item) => Number(item.member_id) === Number(memberId)); + if (!placement) { + return null; + } + const slot = (layout.slots || []).find((item) => Number(item.id) === Number(placement.seat_slot_id)); + return { + seatMapId: layout.seat_map?.id || null, + seatMapName: layout.seat_map?.name || '자리배치도', + seatLabel: placement.seat_label || slot?.label || '', + slotKey: slot?.slot_key || '', + assigned: true, + }; +} + async function syncMembers(nextMembers) { const payload = await apiFetch('/api/members/bulk-sync', { method: 'PUT', @@ -846,31 +885,86 @@ function handlePhotoFileChange(event) { updatePhotoPreview(photoPreviewObjectUrl, name); } -function renderSeatPreviewCard(seatLabel) { - const safeLabel = escapeHtml(seatLabel || ''); - const badge = safeLabel - ? `${safeLabel}` +function renderSeatPreviewCard(seatInfo) { + const assigned = Boolean(seatInfo?.assigned); + const safeLabel = escapeHtml(seatInfo?.seatLabel || ''); + const safeSeatMapName = escapeHtml(seatInfo?.seatMapName || '자리배치도'); + const safeSlotKey = escapeHtml(seatInfo?.slotKey || ''); + const badge = assigned + ? `${safeLabel || '배치완료'}` : '미배치'; + const body = assigned + ? ` + + ` + : ` +
향후 해당 인원의 좌석 영역을 크롭해 표시하고, 스크롤 확대/축소를 지원할 예정입니다.
+${assigned ? '현재 자리배치도 기준으로 배치된 좌석 정보를 표시합니다.' : '현재 자리배치도에서 배치된 좌석이 없습니다.'}