const sessionKey = "mh-dashboard-session"; const loginPanel = document.getElementById("login-panel"); const dashboardPanel = document.getElementById("dashboard-panel"); const loginForm = document.getElementById("login-form"); const loginMessage = document.getElementById("login-message"); const logoutBtn = document.getElementById("logout-btn"); const userBadge = document.getElementById("user-badge"); const userPopover = document.getElementById("user-popover"); const currentViewTitle = document.getElementById("current-view-title"); const globalDateControls = document.getElementById("global-date-controls"); const globalStartDateInput = document.getElementById("global-start-date"); const globalEndDateInput = document.getElementById("global-end-date"); const navButtons = Array.from(document.querySelectorAll(".header-center [data-view]")); const organizationFrame = document.getElementById("organization-frame"); const organizationStage = document.getElementById("organization-stage"); const projectFrame = document.getElementById("project-frame"); const projectStage = document.getElementById("project-stage"); const teamFrame = document.getElementById("team-frame"); const teamStage = document.getElementById("team-stage"); const seatMapAdminStage = document.getElementById("seatmap-admin-stage"); const seatMapReadonlyStage = document.getElementById("seatmap-readonly-stage"); const emptyStage = document.getElementById("empty-stage"); const seatMapDom = { admin: { stage: seatMapAdminStage, name: document.getElementById("seatmap-admin-name"), status: document.getElementById("seatmap-admin-status"), saveBtn: document.getElementById("seatmap-admin-save-btn"), cancelBtn: null, exitBtn: document.getElementById("seatmap-admin-exit-btn"), actions: document.getElementById("seatmap-admin-actions"), boardWrap: document.getElementById("seatmap-admin-board-wrap"), board: document.getElementById("seatmap-admin-board"), empty: document.getElementById("seatmap-admin-empty"), settingsPanel: document.getElementById("seatmap-admin-settings-panel"), settingsForm: document.getElementById("seatmap-admin-settings-form"), formName: document.getElementById("seatmap-admin-form-name"), fileName: document.getElementById("seatmap-admin-file-name"), formRows: null, formCols: null, formGap: null, formImage: document.getElementById("seatmap-admin-form-image"), search: document.getElementById("seatmap-admin-search"), unassigned: document.getElementById("seatmap-admin-unassigned"), officeTabs: document.getElementById("seatmap-admin-office-tabs"), sidebarTitle: document.getElementById("seatmap-admin-sidebar-title"), sidebarDesc: document.getElementById("seatmap-admin-sidebar-desc"), }, readonly: { stage: seatMapReadonlyStage, name: document.getElementById("seatmap-readonly-name"), status: document.getElementById("seatmap-readonly-status"), saveBtn: null, cancelBtn: null, exitBtn: document.getElementById("seatmap-readonly-exit-btn"), actions: document.getElementById("seatmap-readonly-actions"), boardWrap: document.getElementById("seatmap-readonly-board-wrap"), board: document.getElementById("seatmap-readonly-board"), empty: document.getElementById("seatmap-readonly-empty"), settingsPanel: null, settingsForm: null, formName: null, fileName: null, formRows: null, formCols: null, formGap: null, formImage: null, search: document.getElementById("seatmap-readonly-search"), unassigned: document.getElementById("seatmap-readonly-unassigned"), officeTabs: document.getElementById("seatmap-readonly-office-tabs"), sidebarTitle: document.getElementById("seatmap-readonly-sidebar-title"), sidebarDesc: document.getElementById("seatmap-readonly-sidebar-desc"), }, }; let seatMapName = seatMapDom.admin.name; let seatMapStatus = seatMapDom.admin.status; let seatMapSaveBtn = seatMapDom.admin.saveBtn; let seatMapCancelBtn = seatMapDom.admin.cancelBtn; let seatMapExitBtn = seatMapDom.admin.exitBtn; let seatMapActions = seatMapDom.admin.actions; let seatMapBoardWrap = seatMapDom.admin.boardWrap; let seatMapBoard = seatMapDom.admin.board; let seatMapEmpty = seatMapDom.admin.empty; let seatMapSettingsPanel = seatMapDom.admin.settingsPanel; let seatMapSettingsForm = seatMapDom.admin.settingsForm; let seatMapFormName = seatMapDom.admin.formName; let seatMapFileName = seatMapDom.admin.fileName; let seatMapFormRows = seatMapDom.admin.formRows; let seatMapFormCols = seatMapDom.admin.formCols; let seatMapFormGap = seatMapDom.admin.formGap; let seatMapFormImage = seatMapDom.admin.formImage; let seatMapSearch = seatMapDom.admin.search; let seatMapUnassigned = seatMapDom.admin.unassigned; let seatMapOfficeTabs = seatMapDom.admin.officeTabs; let seatMapSidebarTitle = seatMapDom.admin.sidebarTitle; let seatMapSidebarDesc = seatMapDom.admin.sidebarDesc; const APP_BASE_URL = String(window.__MH_BASE_URL || "").replace(/\/$/, ""); const seatMapOffices = [ { key: "technical-development-center", label: "기술개발센터", ready: true }, { key: "hanmac-building-6f", label: "한맥빌딩 6층", ready: true }, { key: "hanmac-building-7f", label: "한맥빌딩 7층", ready: true }, ]; const viewLabels = { ledger: "사업관리대장", project: "프로젝트별 분석", team: "팀/개인별 분석", organization: "조직 현황", "seatmap-admin": "자리배치도", "seatmap-readonly": "자리배치도", }; const seatMapState = { loaded: false, loading: false, seatMap: null, members: [], slots: [], placements: [], draftPlacements: [], editMode: false, dirty: false, search: "", status: "", statusTone: "info", draggingMemberId: null, zoom: 1, panning: false, panStartX: 0, panStartY: 0, panScrollLeft: 0, panScrollTop: 0, hoveredSlotId: null, viewerOffsetX: 0, viewerOffsetY: 0, viewerPointerX: 0, viewerPointerY: 0, viewerDragging: false, viewerDragStartX: 0, viewerDragStartY: 0, viewerDragOffsetX: 0, viewerDragOffsetY: 0, officeKey: "technical-development-center", forceReadOnly: false, }; let currentView = "project"; const globalDateState = { loaded: true, startDate: "2026-01-01", endDate: "2026-01-31", }; function getGlobalAsOfDate() { return globalDateState.endDate || ""; } function getSession() { try { return JSON.parse(sessionStorage.getItem(sessionKey) || "null"); } catch { return null; } } function setSession(session) { sessionStorage.setItem(sessionKey, JSON.stringify(session)); } function clearSession() { sessionStorage.removeItem(sessionKey); } function buildAuthHeaders(headers) { const nextHeaders = new Headers(headers || {}); const token = getSession()?.token; if (token && !nextHeaders.has("Authorization")) { nextHeaders.set("Authorization", `Bearer ${token}`); } return nextHeaders; } function shouldShowGlobalDateControls() { return currentView === "ledger" || currentView === "project" || currentView === "team" || currentView === "organization" || currentView === "seatmap-admin" || currentView === "seatmap-readonly"; } function syncGlobalDateControlVisibility() { if (!globalDateControls) return; globalDateControls.classList.toggle("hidden", !shouldShowGlobalDateControls()); } function syncGlobalDateControlInputs() { if (globalStartDateInput) globalStartDateInput.value = globalDateState.startDate || ""; if (globalEndDateInput) globalEndDateInput.value = globalDateState.endDate || ""; } function getGlobalDateRangePayload() { return { source: "total-control", type: "date-range", startDate: globalDateState.startDate || "", endDate: globalDateState.endDate || "", }; } function postGlobalDateRangeToFrame(frame) { if (!frame?.contentWindow || !globalDateState.loaded) return; frame.contentWindow.postMessage(getGlobalDateRangePayload(), window.location.origin); } function buildAsOfQuery() { const asOf = getGlobalAsOfDate(); if (!asOf) return ""; return `?as_of=${encodeURIComponent(asOf)}`; } function notifyEmbeddedTabActivated() { if (currentView === "project" && projectFrame?.contentWindow) { projectFrame.contentWindow.postMessage({ source: "total-control", type: "tab-activated", tab: "project" }, window.location.origin); } if (currentView === "team" && teamFrame?.contentWindow) { teamFrame.contentWindow.postMessage({ source: "total-control", type: "tab-activated", tab: "mh" }, window.location.origin); } } async function ensureGlobalDateRangeLoaded() { if (globalDateState.loaded) return; try { const payload = await fetchJson("/api/integration/summary"); const work = payload?.date_ranges?.work || {}; const voucher = payload?.date_ranges?.voucher || {}; const starts = [work.min_work_date, voucher.min_voucher_date].filter(Boolean).sort(); const ends = [work.max_work_date, voucher.max_voucher_date].filter(Boolean).sort(); globalDateState.startDate = starts[0] ? String(starts[0]).slice(0, 10) : ""; globalDateState.endDate = ends.length ? String(ends[ends.length - 1]).slice(0, 10) : ""; globalDateState.loaded = true; syncGlobalDateControlInputs(); postGlobalDateRangeToFrame(organizationFrame); postGlobalDateRangeToFrame(projectFrame); postGlobalDateRangeToFrame(teamFrame); } catch (error) { console.error("공통 기간을 불러오지 못했습니다.", error); } } function hideUserPopover() { userPopover?.classList.add("hidden"); } function toggleUserPopover() { userPopover?.classList.toggle("hidden"); } function isAdmin() { return getSession()?.user?.role === "admin"; } function isSlotBasedSeatMap() { return seatMapState.seatMap?.source_type === "dxf" || seatMapState.seatMap?.source_type === "fixed_html"; } function canEditSeatMap() { return isAdmin() && !seatMapState.forceReadOnly; } function getSeatMapScreenMode() { return canEditSeatMap() ? "admin" : "readonly"; } function isSeatMapAdminMode() { return getSeatMapScreenMode() === "admin"; } function syncSeatMapDomRefs() { const dom = currentView === "seatmap-readonly" ? seatMapDom.readonly : seatMapDom.admin; seatMapName = dom.name; seatMapStatus = dom.status; seatMapSaveBtn = dom.saveBtn; seatMapCancelBtn = dom.cancelBtn; seatMapExitBtn = dom.exitBtn; seatMapActions = dom.actions; seatMapBoardWrap = dom.boardWrap; seatMapBoard = dom.board; seatMapEmpty = dom.empty; seatMapSettingsPanel = dom.settingsPanel; seatMapSettingsForm = dom.settingsForm; seatMapFormName = dom.formName; seatMapFileName = dom.fileName; seatMapFormRows = dom.formRows; seatMapFormCols = dom.formCols; seatMapFormGap = dom.formGap; seatMapFormImage = dom.formImage; seatMapSearch = dom.search; seatMapUnassigned = dom.unassigned; seatMapOfficeTabs = dom.officeTabs; seatMapSidebarTitle = dom.sidebarTitle; seatMapSidebarDesc = dom.sidebarDesc; } function getCurrentSeatMapOffice() { return seatMapOffices.find((item) => item.key === seatMapState.officeKey) || seatMapOffices[0]; } function escapeHtml(value) { return String(value ?? "") .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } function resolveAppUrl(path) { if (!path) return path; if (/^https?:\/\//i.test(path)) return path; if (!APP_BASE_URL) return path; if (path.startsWith("/")) return `${APP_BASE_URL}${path}`; return path; } function clonePlacements(items) { return items.map((item) => ({ member_id: Number(item.member_id), seat_slot_id: item.seat_slot_id == null ? null : Number(item.seat_slot_id), row_index: Number(item.row_index), col_index: Number(item.col_index), seat_label: item.seat_label || "", })); } function computeSeatLabel(rowIndex, colIndex) { let quotient = rowIndex; let rowLabel = ""; while (true) { const remainder = quotient % 26; rowLabel = String.fromCharCode(65 + remainder) + rowLabel; quotient = Math.floor(quotient / 26); if (quotient === 0) break; quotient -= 1; } return `${rowLabel}-${String(colIndex + 1).padStart(2, "0")}`; } function getInitials(name) { const trimmed = String(name || "").trim(); if (!trimmed) return "?"; return trimmed.slice(0, 2).toUpperCase(); } function getPlacementSource() { return seatMapState.editMode ? seatMapState.draftPlacements : seatMapState.placements; } function setSeatMapStatus(message, tone = "info") { seatMapState.status = message || ""; seatMapState.statusTone = tone; if (seatMapStatus) { seatMapStatus.textContent = seatMapState.status; seatMapStatus.dataset.tone = seatMapState.statusTone; } } function resetSeatMapDraft() { seatMapState.draftPlacements = clonePlacements(seatMapState.placements); seatMapState.dirty = false; } function clampSeatMapZoom(nextZoom) { return Math.min(4, Math.max(0.35, Number(nextZoom.toFixed(2)))); } function setSeatMapZoom(nextZoom) { seatMapState.zoom = clampSeatMapZoom(nextZoom); renderSeatMap(); } function getDxfCanvasSize() { return { width: Math.max(960, seatMapBoardWrap?.clientWidth || seatMapBoard?.clientWidth || 960), height: Math.max(680, seatMapBoardWrap?.clientHeight || seatMapBoard?.clientHeight || 680), }; } function centerSeatMapBoard() { fitDxfSeatMapBoard(); } function fitDxfSeatMapBoard() { const viewerData = seatMapState.seatMap?.viewer_data; if (!viewerData) return; const world = viewerData.meta?.world; const canvas = seatMapBoard?.querySelector("#seatmap-dxf-canvas"); if (!world || !canvas) return; const rect = canvas.getBoundingClientRect(); const pad = 36; const scaleX = (rect.width - pad * 2) / Math.max(Number(world.width || 1), 1); const scaleY = (rect.height - pad * 2) / Math.max(Number(world.height || 1), 1); seatMapState.zoom = clampSeatMapZoom(Math.min(scaleX, scaleY)); seatMapState.viewerOffsetX = pad - Number(world.min_x) * seatMapState.zoom + (rect.width - pad * 2 - Number(world.width) * seatMapState.zoom) / 2; seatMapState.viewerOffsetY = pad - Number(world.min_y) * seatMapState.zoom + (rect.height - pad * 2 - Number(world.height) * seatMapState.zoom) / 2; drawDxfCanvasViewer(); } function zoomDxfSeatMapAtPoint(clientX, clientY, factor) { const viewerData = seatMapState.seatMap?.viewer_data; const world = viewerData?.meta?.world; const canvas = seatMapBoard?.querySelector("#seatmap-dxf-canvas"); if (!viewerData || !world || !canvas) return; const rect = canvas.getBoundingClientRect(); const mx = clientX - rect.left; const my = clientY - rect.top; const before = screenToWorld(mx, my, world); const nextZoom = clampSeatMapZoom(seatMapState.zoom * factor); if (nextZoom === seatMapState.zoom) return; seatMapState.zoom = nextZoom; const after = worldToScreen(before.x, before.y, world); seatMapState.viewerOffsetX += mx - after.x; seatMapState.viewerOffsetY += my - after.y; drawDxfCanvasViewer(); } function getHoveredSeatMapSlotMeta() { if (seatMapState.hoveredSlotId == null) return null; const slot = getSeatSlotMap().get(Number(seatMapState.hoveredSlotId)); if (!slot) return null; const placement = getSlotPlacementMap().get(Number(slot.id)); const member = placement ? getMemberMap().get(Number(placement.member_id)) : null; return { label: slot.label || `SLOT-${slot.id}`, memberName: member?.name || "", }; } function updateSeatMapViewerHoverChip() { const chip = seatMapBoard?.querySelector("[data-seatmap-chip='hover']"); if (!chip) return; const hoveredMeta = getHoveredSeatMapSlotMeta(); chip.textContent = hoveredMeta ? `hover ${hoveredMeta.label}${hoveredMeta.memberName ? ` · ${hoveredMeta.memberName}` : ""}` : "hover none"; } function worldToScreen(x, y, world) { return { x: x * seatMapState.zoom + seatMapState.viewerOffsetX, y: (Number(world.max_y) - y + Number(world.min_y)) * seatMapState.zoom + seatMapState.viewerOffsetY, }; } function screenToWorld(x, y, world) { return { x: (x - seatMapState.viewerOffsetX) / seatMapState.zoom, y: Number(world.max_y) + Number(world.min_y) - (y - seatMapState.viewerOffsetY) / seatMapState.zoom, }; } function pickViewerChair(screenX, screenY, viewerData) { const world = viewerData.meta.world; const threshold = 12; let best = null; for (const chair of viewerData.chairs || []) { const min = worldToScreen(Number(chair.min_x), Number(chair.max_y), world); const max = worldToScreen(Number(chair.max_x), Number(chair.min_y), world); const left = Math.min(min.x, max.x) - threshold; const right = Math.max(min.x, max.x) + threshold; const top = Math.min(min.y, max.y) - threshold; const bottom = Math.max(min.y, max.y) + threshold; if (screenX < left || screenX > right || screenY < top || screenY > bottom) continue; let dist = Infinity; for (let index = Number(chair.start); index < Number(chair.start) + Number(chair.count); index += 1) { const segment = viewerData.chair_segments[index]; if (!segment) continue; const a = worldToScreen(Number(segment[0]), Number(segment[1]), world); const b = worldToScreen(Number(segment[2]), Number(segment[3]), world); const dx = b.x - a.x; const dy = b.y - a.y; const len2 = dx * dx + dy * dy; let segDist; if (len2 === 0) { segDist = Math.hypot(screenX - a.x, screenY - a.y); } else { let t = ((screenX - a.x) * dx + (screenY - a.y) * dy) / len2; t = Math.max(0, Math.min(1, t)); const px = a.x + t * dx; const py = a.y + t * dy; segDist = Math.hypot(screenX - px, screenY - py); } if (segDist < dist) dist = segDist; if (dist <= threshold) break; } if (dist > threshold) continue; if (!best || dist < best.dist) { best = { chair, dist }; } } return best?.chair || null; } function drawViewerSegments(ctx, viewerData) { const world = viewerData.meta.world; const bgSegments = viewerData.background_segments || []; const chairSegments = viewerData.chair_segments || []; const placementMap = getSlotPlacementMap(); const slotByKey = new Map((seatMapState.slots || []).map((slot) => [String(slot.slot_key), slot])); ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); ctx.fillStyle = "#ffffff"; ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height); ctx.save(); ctx.strokeStyle = "rgba(21,35,48,0.10)"; ctx.lineWidth = 1; for (let index = 0; index < bgSegments.length; index += 1) { const segment = bgSegments[index]; const a = worldToScreen(Number(segment[0]), Number(segment[1]), world); const b = worldToScreen(Number(segment[2]), Number(segment[3]), world); ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke(); } ctx.restore(); for (const chair of viewerData.chairs || []) { const slot = slotByKey.get(String(chair.key)); const occupied = slot ? placementMap.has(Number(slot.id)) : false; const hovered = slot && Number(slot.id) === seatMapState.hoveredSlotId; ctx.save(); ctx.strokeStyle = occupied ? "rgba(220, 38, 38, 0.98)" : hovered ? "rgba(15, 118, 110, 0.98)" : "rgba(15, 118, 110, 0.82)"; ctx.lineWidth = occupied ? 2.8 : hovered ? 2.2 : 1.5; ctx.lineCap = "round"; ctx.lineJoin = "round"; ctx.beginPath(); for (let index = Number(chair.start); index < Number(chair.start) + Number(chair.count); index += 1) { const segment = chairSegments[index]; if (!segment) continue; const a = worldToScreen(Number(segment[0]), Number(segment[1]), world); const b = worldToScreen(Number(segment[2]), Number(segment[3]), world); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); } ctx.stroke(); ctx.restore(); } } function drawDxfCanvasViewer() { const canvas = seatMapBoard?.querySelector("#seatmap-dxf-canvas"); const viewerData = seatMapState.seatMap?.viewer_data; if (!canvas || !viewerData) return; const ctx = canvas.getContext("2d"); const pixelRatio = window.devicePixelRatio || 1; const rect = canvas.getBoundingClientRect(); canvas.width = Math.round(rect.width * pixelRatio); canvas.height = Math.round(rect.height * pixelRatio); ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0); drawViewerSegments(ctx, viewerData); updateSeatMapViewerHoverChip(); } function setupDxfCanvasViewer() { const canvas = seatMapBoard?.querySelector("#seatmap-dxf-canvas"); const viewerData = seatMapState.seatMap?.viewer_data; if (!canvas || !viewerData) return; canvas.addEventListener("pointerdown", (event) => { seatMapState.viewerDragging = true; seatMapState.viewerDragStartX = event.clientX; seatMapState.viewerDragStartY = event.clientY; seatMapState.viewerDragOffsetX = seatMapState.viewerOffsetX; seatMapState.viewerDragOffsetY = seatMapState.viewerOffsetY; canvas.classList.add("dragging"); }); canvas.addEventListener("pointermove", (event) => { const rect = canvas.getBoundingClientRect(); seatMapState.viewerPointerX = event.clientX - rect.left; seatMapState.viewerPointerY = event.clientY - rect.top; if (seatMapState.viewerDragging) { seatMapState.viewerOffsetX = seatMapState.viewerDragOffsetX + (event.clientX - seatMapState.viewerDragStartX); seatMapState.viewerOffsetY = seatMapState.viewerDragOffsetY + (event.clientY - seatMapState.viewerDragStartY); drawDxfCanvasViewer(); return; } const chair = pickViewerChair(seatMapState.viewerPointerX, seatMapState.viewerPointerY, viewerData); const slot = chair ? (seatMapState.slots || []).find((item) => String(item.slot_key) === String(chair.key)) : null; const nextSlotId = slot ? Number(slot.id) : null; if (nextSlotId !== seatMapState.hoveredSlotId) { seatMapState.hoveredSlotId = nextSlotId; drawDxfCanvasViewer(); } }); canvas.addEventListener("pointerleave", () => { seatMapState.hoveredSlotId = null; drawDxfCanvasViewer(); }); canvas.addEventListener("pointerup", () => { if (!seatMapState.viewerDragging) return; seatMapState.viewerDragging = false; canvas.classList.remove("dragging"); }); canvas.addEventListener("click", (event) => { const rect = canvas.getBoundingClientRect(); const chair = pickViewerChair(event.clientX - rect.left, event.clientY - rect.top, viewerData); const slot = chair ? (seatMapState.slots || []).find((item) => String(item.slot_key) === String(chair.key)) : null; seatMapState.hoveredSlotId = slot ? Number(slot.id) : null; drawDxfCanvasViewer(); }); canvas.addEventListener("wheel", (event) => { event.preventDefault(); zoomDxfSeatMapAtPoint(event.clientX, event.clientY, event.deltaY < 0 ? 1.08 : 0.92); }, { passive: false }); fitDxfSeatMapBoard(); } function getSeatSlotMap() { return new Map((seatMapState.slots || []).map((slot) => [Number(slot.id), slot])); } function getMemberMap() { return new Map(seatMapState.members.map((member) => [Number(member.id), member])); } function getPlacementForMember(memberId) { return getPlacementSource().find((item) => Number(item.member_id) === Number(memberId)) || null; } function memberMatchesSeatMapSearch(member) { const keyword = seatMapState.search.trim().toLowerCase(); if (!keyword) return true; const haystack = `${member.name || ""} ${member.department || ""} ${member.team || ""} ${member.rank || ""}`.toLowerCase(); return haystack.includes(keyword); } function getSidebarMembers() { const members = isSeatMapAdminMode() ? getUnassignedMembers() : seatMapState.members.filter((member) => Boolean(getPlacementForMember(Number(member.id)))); return members.filter(memberMatchesSeatMapSearch); } function focusSeatMapMember(memberId) { const placement = getPlacementForMember(memberId); const member = getMemberMap().get(Number(memberId)); if (!placement) { setSeatMapStatus("해당 인원은 아직 배치되지 않았습니다.", "info"); return; } const slot = getSeatSlotMap().get(Number(placement.seat_slot_id)); if (!slot) { setSeatMapStatus("좌석 정보를 찾지 못했습니다.", "error"); return; } const frame = seatMapBoard?.querySelector("#seatmap-dxf-frame"); if (!frame?.contentWindow) { setSeatMapStatus("현재 선택한 사무실 도면에서는 좌석을 표시할 수 없습니다.", "info"); return; } frame.contentWindow.postMessage( { type: "seatmap-focus-chair", key: String(slot.slot_key), padding: 2600 }, window.location.origin, ); setSeatMapStatus(`${member?.name || "구성원"} 좌석으로 이동했습니다.`, "info"); } function renderSeatMapOfficeTabs() { if (!seatMapOfficeTabs) return; seatMapOfficeTabs.innerHTML = seatMapOffices.map((office) => ` `).join(""); } function getUnassignedMembers() { const placedIds = new Set(getPlacementSource().map((item) => Number(item.member_id))); return seatMapState.members.filter((member) => { if (placedIds.has(Number(member.id))) return false; return memberMatchesSeatMapSearch(member); }); } function getPlacedMembers() { const placedIds = new Set(getPlacementSource().map((item) => Number(item.member_id))); return seatMapState.members.filter((member) => { if (!placedIds.has(Number(member.id))) return false; return memberMatchesSeatMapSearch(member); }); } function getCellPlacementMap() { const cellMap = new Map(); getPlacementSource().forEach((item) => { cellMap.set(`${item.row_index}:${item.col_index}`, item); }); return cellMap; } function getSlotPlacementMap() { const slotMap = new Map(); getPlacementSource().forEach((item) => { if (item.seat_slot_id != null) { slotMap.set(Number(item.seat_slot_id), item); } }); return slotMap; } function upsertDraftPlacement(memberId, rowIndex, colIndex) { const cellMap = getCellPlacementMap(); const existing = cellMap.get(`${rowIndex}:${colIndex}`); if (existing && Number(existing.member_id) !== Number(memberId)) { setSeatMapStatus("이미 사용 중인 칸입니다. 빈 칸으로 이동해주세요.", "error"); return; } const nextPlacements = seatMapState.draftPlacements.filter((item) => Number(item.member_id) !== Number(memberId)); nextPlacements.push({ member_id: Number(memberId), row_index: Number(rowIndex), col_index: Number(colIndex), seat_label: computeSeatLabel(rowIndex, colIndex), }); seatMapState.draftPlacements = nextPlacements.sort((left, right) => { if (left.row_index !== right.row_index) return left.row_index - right.row_index; return left.col_index - right.col_index; }); seatMapState.dirty = true; setSeatMapStatus("배치를 수정했습니다. 저장 버튼으로 반영하세요.", "info"); } function upsertDraftPlacementForSlot(memberId, seatSlotId) { const placementMap = getSlotPlacementMap(); const existing = placementMap.get(Number(seatSlotId)); if (existing && Number(existing.member_id) !== Number(memberId)) { setSeatMapStatus("이미 사용 중인 좌석입니다. 빈 좌석으로 이동해주세요.", "error"); return; } const seatSlot = getSeatSlotMap().get(Number(seatSlotId)); const nextPlacements = seatMapState.draftPlacements.filter((item) => Number(item.member_id) !== Number(memberId)); nextPlacements.push({ member_id: Number(memberId), seat_slot_id: Number(seatSlotId), row_index: 0, col_index: 0, seat_label: seatSlot?.label || `SLOT-${seatSlotId}`, }); seatMapState.draftPlacements = nextPlacements; seatMapState.dirty = true; setSeatMapStatus("배치를 수정했습니다. 저장 버튼으로 반영하세요.", "info"); } function removeDraftPlacement(memberId) { const before = seatMapState.draftPlacements.length; seatMapState.draftPlacements = seatMapState.draftPlacements.filter((item) => Number(item.member_id) !== Number(memberId)); if (seatMapState.draftPlacements.length !== before) { seatMapState.dirty = true; setSeatMapStatus("구성원을 미배치 목록으로 이동했습니다. 저장 버튼으로 반영하세요.", "info"); } } function renderMemberCard(member, draggable) { const photoUrl = member.photo_url ? escapeHtml(member.photo_url) : ""; const avatar = photoUrl ? `${escapeHtml(member.name)}` : `${escapeHtml(getInitials(member.name))}`; return `
${avatar} ${escapeHtml(member.name || "-")} ${escapeHtml(member.department || member.team || member.rank || "-")}
`; } function renderUnassignedMemberCard(member, draggable) { return `
${escapeHtml(member.name || "-")} ${escapeHtml(member.rank || "-")}
`; } function renderSeatMapSearchCard(member) { const placement = getPlacementForMember(Number(member.id)); if (!placement) return ""; const badge = `${escapeHtml(placement.seat_label || "배치완료")}`; return ` `; } function renderSeatMapBoard() { if (!seatMapBoard || !seatMapState.seatMap) return; if (seatMapState.seatMap.source_type === "dxf" || seatMapState.seatMap.source_type === "fixed_html") { renderDxfSeatMapBoard(); return; } const memberMap = getMemberMap(); const placementMap = getCellPlacementMap(); const rows = Number(seatMapState.seatMap.grid_rows || 0); const cols = Number(seatMapState.seatMap.grid_cols || 0); const gap = Number(seatMapState.seatMap.cell_gap || 0); const editable = seatMapState.editMode && isAdmin(); const cells = []; for (let rowIndex = 0; rowIndex < rows; rowIndex += 1) { for (let colIndex = 0; colIndex < cols; colIndex += 1) { const key = `${rowIndex}:${colIndex}`; const placement = placementMap.get(key); const member = placement ? memberMap.get(Number(placement.member_id)) : null; cells.push(`
${escapeHtml(computeSeatLabel(rowIndex, colIndex))} ${member ? renderMemberCard(member, editable) : ""}
`); } } seatMapBoard.innerHTML = `
${escapeHtml(seatMapState.seatMap.name)}
${cells.join("")}
`; } function renderDxfSeatMapBoard() { if (!seatMapBoard || !seatMapState.seatMap) return; const viewerData = seatMapState.seatMap.viewer_data; if (!viewerData) { seatMapBoard.innerHTML = `
DXF 뷰어 데이터를 준비하지 못했습니다.
`; return; } const viewerUrl = resolveAppUrl(`/api/seat-maps/${seatMapState.seatMap.id}/viewer${buildAsOfQuery()}`); seatMapBoard.innerHTML = `
`; setupSeatMapViewerFrame(); } function setSeatMapDropOverlayActive(active) { const overlay = seatMapBoard?.querySelector("[data-seatmap-drop-overlay]"); if (!overlay) return; overlay.classList.toggle("is-active", Boolean(active && seatMapState.editMode)); } function getDraftPlacedSlotKeys() { const slotMap = getSeatSlotMap(); return (seatMapState.draftPlacements || []) .map((placement) => slotMap.get(Number(placement.seat_slot_id))?.slot_key) .filter(Boolean) .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; frame.contentWindow.postMessage( { type: "seatmap-set-mode", mode: isSeatMapAdminMode() ? "default" : "readonly" }, window.location.origin, ); frame.contentWindow.postMessage( { type: "seatmap-set-placed", keys: getDraftPlacedSlotKeys() }, window.location.origin, ); frame.contentWindow.postMessage( { type: "seatmap-set-assignments", items: getSeatAssignmentPayload() }, window.location.origin, ); } function scheduleSeatMapViewerSync() { syncSeatMapViewerFrame(); window.setTimeout(() => { syncSeatMapViewerFrame(); }, 80); } function renderSeatMapActions() { const hasSeatMap = Boolean(seatMapState.seatMap); const adminMode = isSeatMapAdminMode(); if (seatMapSaveBtn) { seatMapSaveBtn.hidden = !adminMode || !hasSeatMap; seatMapSaveBtn.disabled = !seatMapState.dirty; } if (seatMapExitBtn) { seatMapExitBtn.hidden = !hasSeatMap; } if (seatMapActions) { seatMapActions.hidden = !hasSeatMap; } } function updateSeatMapDraftUi() { renderSeatMapActions(); renderUnassignedMembers(); syncSeatMapViewerFrame(); } function setupSeatMapViewerFrame() { const frame = seatMapBoard?.querySelector("#seatmap-dxf-frame"); const overlay = seatMapBoard?.querySelector("[data-seatmap-drop-overlay]"); if (!frame) return; frame.addEventListener("load", () => { syncSeatMapViewerFrame(); if (!seatMapState.editMode) return; const frameWindow = frame.contentWindow; const frameDocument = frame.contentDocument; const canvas = frameDocument?.getElementById("canvas"); if (!frameWindow || !frameDocument || !canvas || !frameWindow.__mhSeatmap) return; const handleDrop = (event) => { event.preventDefault(); const memberId = getDraggedMemberId(event); if (!memberId) { setSeatMapStatus("드롭 감지됨: memberId를 읽지 못했습니다.", "error"); return; } const frameRect = frame.getBoundingClientRect(); const canvasRect = canvas.getBoundingClientRect(); const picked = frameWindow.__mhSeatmap.pickChairAt( event.clientX - frameRect.left - canvasRect.left, event.clientY - frameRect.top - canvasRect.top, ); if (!picked?.key) { setSeatMapStatus(`드롭 감지됨: 좌석 인식 실패 (memberId=${memberId})`, "error"); return; } const matchedSlot = (seatMapState.slots || []).find((item) => String(item.slot_key) === String(picked.key)); if (!matchedSlot) { setSeatMapStatus(`드롭 감지됨: slot 매칭 실패 (${picked.key})`, "error"); return; } upsertDraftPlacementForSlot(memberId, Number(matchedSlot.id)); setSeatMapStatus(`드롭 성공: memberId=${memberId}, slot=${picked.key}, slotId=${matchedSlot.id}`, "info"); updateSeatMapDraftUi(); }; const handleDragOver = (event) => { event.preventDefault(); event.dataTransfer.dropEffect = "move"; }; canvas.addEventListener("dragover", handleDragOver); canvas.addEventListener("drop", handleDrop); overlay?.addEventListener("dragover", handleDragOver); overlay?.addEventListener("drop", handleDrop); }, { once: true }); } function renderAdminSeatMapSidebar() { const unassignedMembers = getUnassignedMembers(); const placedMembers = getPlacedMembers(); if (seatMapSidebarTitle) { seatMapSidebarTitle.textContent = "전체 인원"; } if (seatMapSidebarDesc) { seatMapSidebarDesc.textContent = "미배치 인원은 상단, 배치 완료 인원은 하단에 표시됩니다."; } if (!unassignedMembers.length && !placedMembers.length) { seatMapUnassigned.innerHTML = `
${seatMapState.search ? "검색 결과가 없습니다." : "표시할 인원이 없습니다."}
`; return; } seatMapUnassigned.innerHTML = `
미배치 인원 ${unassignedMembers.length}명
${unassignedMembers.length ? unassignedMembers.map((member) => renderUnassignedMemberCard(member, true)).join("") : '
미배치 인원이 없습니다.
'}
배치 완료 ${placedMembers.length}명
${placedMembers.length ? placedMembers.map((member) => renderSeatMapSearchCard(member)).join("") : '
배치된 인원이 없습니다.
'}
`; } function renderReadonlySeatMapSidebar() { const members = getSidebarMembers(); if (seatMapSidebarTitle) { seatMapSidebarTitle.textContent = "배치 인원 검색"; } if (seatMapSidebarDesc) { seatMapSidebarDesc.textContent = "이름이나 부서를 검색하고 클릭하면 해당 좌석으로 바로 확대 이동합니다."; } if (!members.length) { seatMapUnassigned.innerHTML = `
${seatMapState.search ? "검색 결과가 없습니다." : "배치된 인원이 없습니다."}
`; return; } seatMapUnassigned.innerHTML = members.map((member) => renderSeatMapSearchCard(member)).join(""); } function renderUnassignedMembers() { if (!seatMapUnassigned) return; if (isSeatMapAdminMode()) { renderAdminSeatMapSidebar(); return; } renderReadonlySeatMapSidebar(); } function renderSeatMapEmpty() { if (!seatMapEmpty) return; const office = getCurrentSeatMapOffice(); if (seatMapState.seatMap) { seatMapEmpty.classList.add("hidden"); seatMapEmpty.innerHTML = ""; return; } seatMapEmpty.classList.remove("hidden"); seatMapEmpty.innerHTML = `
${escapeHtml(office.label)} 도면이 아직 준비되지 않았습니다.

${office.ready ? (canEditSeatMap() ? "오른쪽 설정 패널에서 이미지와 그리드를 등록하세요." : "관리자에게 자리배치도 등록을 요청하세요.") : "도면 파일을 추후 연결하면 여기서 바로 전환해 볼 수 있습니다."}

`; } function syncSeatMapSettingsForm() { if (!seatMapSettingsForm) return; if (seatMapFormName) { seatMapFormName.value = seatMapState.seatMap?.name || ""; } if (seatMapFormRows) { seatMapFormRows.value = seatMapState.seatMap?.grid_rows || 12; } if (seatMapFormCols) { seatMapFormCols.value = seatMapState.seatMap?.grid_cols || 24; } if (seatMapFormGap) { seatMapFormGap.value = seatMapState.seatMap?.cell_gap ?? 2; } if (seatMapFormImage) { seatMapFormImage.value = ""; } if (seatMapFileName) { seatMapFileName.textContent = "선택된 파일 없음"; } } function renderSeatMap() { const hasSeatMap = Boolean(seatMapState.seatMap); const admin = isSeatMapAdminMode(); const fixedViewerMap = seatMapState.seatMap?.source_type === "fixed_html"; const office = getCurrentSeatMapOffice(); if (seatMapName) { seatMapName.textContent = hasSeatMap ? seatMapState.seatMap.name : office.label; } if (seatMapStatus) { seatMapStatus.textContent = seatMapState.status; seatMapStatus.dataset.tone = seatMapState.statusTone; } if (seatMapSettingsPanel) { seatMapSettingsPanel.classList.toggle("hidden", !admin || fixedViewerMap); } renderSeatMapActions(); if (seatMapSettingsForm) { seatMapSettingsForm.querySelector("button[type='submit']").textContent = hasSeatMap ? "배치도 저장" : "배치도 생성"; } renderSeatMapOfficeTabs(); renderSeatMapEmpty(); if (seatMapBoardWrap) { seatMapBoardWrap.classList.toggle("hidden", !hasSeatMap); } if (hasSeatMap) { renderSeatMapBoard(); } else if (seatMapBoard) { seatMapBoard.innerHTML = ""; } renderUnassignedMembers(); } function handleEmbeddedNavigationMessage(event) { const data = event.data; if (!data || typeof data !== "object") return; if (data.type === "open-seatmap") { hideUserPopover(); seatMapState.forceReadOnly = Boolean(data.readOnly); setActiveView(data.readOnly ? "seatmap-readonly" : "seatmap-admin"); } if (data.type === "open-organization") { hideUserPopover(); setActiveView("organization"); } if (data.type === "seatmap-clear-slot" && canEditSeatMap()) { const cleared = clearDraftPlacementBySlotKey(String(data.key || "")); if (cleared) { setSeatMapStatus("구성원을 공석으로 이동했습니다. 저장 버튼으로 반영하세요.", "info"); updateSeatMapDraftUi(); } } } async function fetchJson(url, options) { const requestOptions = { ...options, headers: buildAuthHeaders(options?.headers), }; const response = await fetch(resolveAppUrl(url), requestOptions); let payload = null; try { payload = await response.json(); } catch { payload = null; } if (!response.ok) { const message = payload?.detail || "요청 처리에 실패했습니다."; const error = new Error(message); error.status = response.status; throw error; } return payload; } async function loadSeatMapData(force = false) { if (seatMapState.loading || (seatMapState.loaded && !force)) return; seatMapState.loading = true; setSeatMapStatus("자리배치도를 불러오는 중입니다.", "info"); renderSeatMap(); try { const office = getCurrentSeatMapOffice(); const activePayload = await fetchJson(`/api/seat-maps/active?office_key=${encodeURIComponent(office.key)}`); const activeSeatMap = activePayload.item; const layoutPayload = await fetchJson(`/api/seat-maps/${activeSeatMap.id}/layout${buildAsOfQuery()}`); seatMapState.seatMap = { ...(layoutPayload.seat_map || {}), viewer_data: layoutPayload.viewer_data || null, }; seatMapState.members = Array.isArray(layoutPayload.members) ? layoutPayload.members : []; seatMapState.slots = Array.isArray(layoutPayload.slots) ? layoutPayload.slots : []; seatMapState.placements = clonePlacements(layoutPayload.placements || []); seatMapState.zoom = 1; seatMapState.hoveredSlotId = null; seatMapState.editMode = canEditSeatMap(); resetSeatMapDraft(); seatMapState.loaded = true; setSeatMapStatus(canEditSeatMap() ? "구성원을 바로 드래그해서 배치한 뒤 저장하세요." : "자리배치도를 불러왔습니다.", "success"); syncSeatMapSettingsForm(); } catch (error) { if (error.status === 404) { seatMapState.seatMap = null; seatMapState.members = []; seatMapState.slots = []; seatMapState.placements = []; seatMapState.zoom = 1; seatMapState.hoveredSlotId = null; seatMapState.editMode = canEditSeatMap(); resetSeatMapDraft(); seatMapState.loaded = true; setSeatMapStatus("활성화된 자리배치도가 없습니다.", "info"); syncSeatMapSettingsForm(); } else { setSeatMapStatus(error.message || "자리배치도 조회에 실패했습니다.", "error"); } } finally { seatMapState.loading = false; renderSeatMap(); } } async function getImageDimensions(file) { return new Promise((resolve) => { const image = new Image(); const objectUrl = URL.createObjectURL(file); image.onload = () => { resolve({ width: image.naturalWidth || null, height: image.naturalHeight || null }); URL.revokeObjectURL(objectUrl); }; image.onerror = () => { resolve({ width: null, height: null }); URL.revokeObjectURL(objectUrl); }; image.src = objectUrl; }); } 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); formData.append("name", name); return fetchJson("/api/seat-maps/dxf", { method: "POST", body: formData, }); } async function submitSeatMapSettings(event) { event.preventDefault(); if (!canEditSeatMap()) return; const name = seatMapFormName?.value?.trim() || ""; const imageFile = seatMapFormImage?.files?.[0] || null; if (!name) { setSeatMapStatus("배치도 이름을 입력하세요.", "error"); return; } if (!imageFile) { setSeatMapStatus("DXF 파일을 선택하세요.", "error"); return; } if (!imageFile.name.toLowerCase().endsWith(".dxf")) { setSeatMapStatus("DXF 파일만 업로드할 수 있습니다.", "error"); return; } try { setSeatMapStatus("DXF 자리배치도를 업로드하고 분석하는 중입니다.", "info"); await uploadSeatMapImage(imageFile, name); if (seatMapFormImage) seatMapFormImage.value = ""; if (seatMapFileName) seatMapFileName.textContent = "선택된 파일 없음"; await loadSeatMapData(true); setSeatMapStatus("DXF 자리배치도를 저장했습니다.", "success"); } catch (error) { setSeatMapStatus(error.message || "DXF 자리배치도 저장에 실패했습니다.", "error"); } finally { renderSeatMap(); } } async function saveSeatLayout() { if (!seatMapState.seatMap || !seatMapState.editMode || !seatMapState.dirty) return; try { setSeatMapStatus("자리배치를 저장하는 중입니다.", "info"); await fetchJson(`/api/seat-maps/${seatMapState.seatMap.id}/layout`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ placements: seatMapState.draftPlacements }), }); await loadSeatMapData(true); if (organizationFrame?.contentWindow) { organizationFrame.contentWindow.postMessage({ type: "seatmap-layout-updated" }, window.location.origin); } setSeatMapStatus("자리배치를 저장했습니다.", "success"); } catch (error) { setSeatMapStatus(error.message || "자리배치도 저장에 실패했습니다.", "error"); renderSeatMap(); } } function cancelSeatMapEdit() { resetSeatMapDraft(); setSeatMapStatus("현재까지 수정한 배치를 취소했습니다.", "info"); renderSeatMap(); } function getDraggedMemberId(event) { const raw = event.dataTransfer?.getData("text/plain") || seatMapState.draggingMemberId; const memberId = Number(raw); if (!Number.isInteger(memberId) || memberId <= 0) return null; return memberId; } function handleSeatMapCellDrop(event) { if (!seatMapState.editMode) return; event.preventDefault(); const memberId = getDraggedMemberId(event); if (!memberId) return; if (isSlotBasedSeatMap()) { const slot = event.target.closest(".seatmap-slot"); if (slot) { upsertDraftPlacementForSlot(memberId, Number(slot.dataset.slotId)); renderSeatMap(); return; } const viewerData = seatMapState.seatMap.viewer_data; const canvas = seatMapBoard?.querySelector("#seatmap-dxf-canvas"); if (!viewerData || !canvas) return; const rect = canvas.getBoundingClientRect(); const chair = pickViewerChair(event.clientX - rect.left, event.clientY - rect.top, viewerData); if (!chair) return; const matchedSlot = (seatMapState.slots || []).find((item) => String(item.slot_key) === String(chair.key)); if (!matchedSlot) return; upsertDraftPlacementForSlot(memberId, Number(matchedSlot.id)); } else { const cell = event.target.closest(".seatmap-cell"); if (!cell) return; upsertDraftPlacement(memberId, Number(cell.dataset.row), Number(cell.dataset.col)); } renderSeatMap(); } function handleSeatMapListDrop(event) { if (!seatMapState.editMode) return; event.preventDefault(); const memberId = getDraggedMemberId(event); if (!memberId) return; removeDraftPlacement(memberId); updateSeatMapDraftUi(); } function setActiveView(view) { const previousView = currentView; currentView = view in viewLabels ? view : "organization"; syncSeatMapDomRefs(); if (currentViewTitle) { currentViewTitle.textContent = viewLabels[currentView]; } syncGlobalDateControlVisibility(); if (shouldShowGlobalDateControls()) { ensureGlobalDateRangeLoaded(); } navButtons.forEach((button) => { const active = button.dataset.view === currentView; button.classList.toggle("active", active); button.classList.toggle("muted", !active); }); const isOrganization = currentView === "organization"; const isProject = currentView === "project"; const isTeam = currentView === "team"; const isSeatMapAdmin = currentView === "seatmap-admin"; const isSeatMapReadonly = currentView === "seatmap-readonly"; if (organizationStage) { organizationStage.hidden = !isOrganization; organizationStage.style.display = isOrganization ? "flex" : "none"; } if (projectStage) { projectStage.hidden = !isProject; projectStage.style.display = isProject ? "flex" : "none"; } if (teamStage) { teamStage.hidden = !isTeam; teamStage.style.display = isTeam ? "flex" : "none"; } if (seatMapAdminStage) { seatMapAdminStage.hidden = !isSeatMapAdmin; seatMapAdminStage.style.display = isSeatMapAdmin ? "flex" : "none"; } if (seatMapReadonlyStage) { seatMapReadonlyStage.hidden = !isSeatMapReadonly; seatMapReadonlyStage.style.display = isSeatMapReadonly ? "flex" : "none"; } if (emptyStage) { const showEmpty = !isOrganization && !isProject && !isTeam && !isSeatMapAdmin && !isSeatMapReadonly; emptyStage.hidden = !showEmpty; emptyStage.style.display = showEmpty ? "flex" : "none"; } if (isOrganization && previousView !== "organization" && organizationFrame) { const frameSrc = organizationFrame.dataset.src || organizationFrame.src; organizationFrame.src = resolveAppUrl(frameSrc); } if (isProject && previousView !== "project" && projectFrame) { const frameSrc = projectFrame.dataset.src || projectFrame.src; projectFrame.src = resolveAppUrl(frameSrc); } else if (isProject) { postGlobalDateRangeToFrame(projectFrame); } if (isTeam && previousView !== "team" && teamFrame) { const frameSrc = teamFrame.dataset.src || teamFrame.src; teamFrame.src = resolveAppUrl(frameSrc); } else if (isTeam) { postGlobalDateRangeToFrame(teamFrame); } if (isSeatMapAdmin || isSeatMapReadonly) { loadSeatMapData(); } notifyEmbeddedTabActivated(); } function renderAuth() { const session = getSession(); const authenticated = Boolean(session?.user?.display_name); loginPanel.classList.toggle("hidden", authenticated); dashboardPanel.classList.toggle("hidden", !authenticated); if (authenticated) { const displayName = session.user.display_name || "접속자"; const rank = session.user.rank || "-"; const employeeId = session.user.username || "-"; userBadge.innerHTML = `${escapeHtml(displayName)}${escapeHtml(rank)}`; userBadge.title = `${displayName} / -`; if (userPopover) { userPopover.innerHTML = `
이름 ${escapeHtml(displayName)}
직급 ${escapeHtml(rank)}
권한 ${escapeHtml(session.user.role || "-")}
사번 ${escapeHtml(employeeId)}
`; } } renderSeatMap(); } if (loginForm) { loginForm.addEventListener("submit", async (event) => { event.preventDefault(); loginMessage.textContent = "로그인 처리 중입니다."; const formData = new FormData(loginForm); try { const payload = await fetchJson("/api/auth/login", { method: "POST", body: formData, }); setSession(payload); setActiveView("project"); loginForm.reset(); loginMessage.textContent = ""; renderAuth(); } catch (error) { loginMessage.textContent = error.message || "로그인에 실패했습니다."; } }); } if (userBadge) { userBadge.addEventListener("click", (event) => { event.stopPropagation(); toggleUserPopover(); }); } if (logoutBtn) { logoutBtn.addEventListener("click", async (event) => { event.stopPropagation(); try { await fetchJson("/api/auth/logout", { method: "POST", }); } catch { // Ignore logout API errors and clear the local session regardless. } clearSession(); hideUserPopover(); renderAuth(); }); } if (globalStartDateInput) { globalStartDateInput.addEventListener("change", () => { globalDateState.startDate = globalStartDateInput.value || ""; postGlobalDateRangeToFrame(organizationFrame); postGlobalDateRangeToFrame(projectFrame); postGlobalDateRangeToFrame(teamFrame); }); } if (globalEndDateInput) { globalEndDateInput.addEventListener("change", () => { globalDateState.endDate = globalEndDateInput.value || ""; postGlobalDateRangeToFrame(organizationFrame); postGlobalDateRangeToFrame(projectFrame); postGlobalDateRangeToFrame(teamFrame); if (currentView === "seatmap-admin" || currentView === "seatmap-readonly") { seatMapState.loaded = false; loadSeatMapData(true); } }); } organizationFrame?.addEventListener("load", () => { postGlobalDateRangeToFrame(organizationFrame); }); projectFrame?.addEventListener("load", () => { postGlobalDateRangeToFrame(projectFrame); if (currentView === "project") { notifyEmbeddedTabActivated(); } }); teamFrame?.addEventListener("load", () => { postGlobalDateRangeToFrame(teamFrame); if (currentView === "team") { notifyEmbeddedTabActivated(); } }); navButtons.forEach((button) => { button.addEventListener("click", () => { hideUserPopover(); setActiveView(button.dataset.view || "organization"); }); }); Object.values(seatMapDom).forEach((dom) => { dom.officeTabs?.addEventListener("click", (event) => { const button = event.target.closest("[data-seatmap-office]"); if (!button) return; const officeKey = button.dataset.seatmapOffice || ""; if (!officeKey || officeKey === seatMapState.officeKey) return; seatMapState.officeKey = officeKey; seatMapState.loaded = false; seatMapState.search = ""; Object.values(seatMapDom).forEach((item) => { if (item.search) item.search.value = ""; }); loadSeatMapData(true); }); dom.settingsForm?.addEventListener("submit", submitSeatMapSettings); dom.saveBtn?.addEventListener("click", saveSeatLayout); dom.exitBtn?.addEventListener("click", () => { if (dom === seatMapDom.admin && seatMapState.dirty) { const confirmed = window.confirm("저장하지 않고 나가면 저장하지 않은 내용은 사라집니다. 나가시겠습니까?"); if (!confirmed) return; resetSeatMapDraft(); } hideUserPopover(); setActiveView("organization"); }); dom.search?.addEventListener("input", () => { seatMapState.search = dom.search.value || ""; const otherMode = dom === seatMapDom.admin ? seatMapDom.readonly : seatMapDom.admin; if (otherMode.search) otherMode.search.value = seatMapState.search; renderUnassignedMembers(); }); dom.unassigned?.addEventListener("click", (event) => { const button = event.target.closest("[data-member-id]"); if (!button) return; if (button.classList.contains("seatmap-member-search-card")) { focusSeatMapMember(Number(button.dataset.memberId)); return; } if (canEditSeatMap()) return; focusSeatMapMember(Number(button.dataset.memberId)); }); dom.unassigned?.addEventListener("dragover", (event) => { if (!seatMapState.editMode) return; event.preventDefault(); event.dataTransfer.dropEffect = "move"; }); dom.unassigned?.addEventListener("drop", handleSeatMapListDrop); }); Object.values(seatMapDom).forEach((dom) => { dom.formImage?.addEventListener("change", () => { if (dom.fileName) { dom.fileName.textContent = dom.formImage.files?.[0]?.name || "선택된 파일 없음"; } }); }); Object.values(seatMapDom).forEach((dom) => { dom.board?.addEventListener("wheel", (event) => { if (!isSlotBasedSeatMap()) return; event.preventDefault(); zoomDxfSeatMapAtPoint(event.clientX, event.clientY, event.deltaY < 0 ? 1.08 : 0.92); }, { passive: false }); dom.board?.addEventListener("click", (event) => { const fitButton = event.target.closest("[data-seatmap-action='fit']"); if (!fitButton) return; fitDxfSeatMapBoard(); }); dom.board?.addEventListener("dragover", (event) => { if (!seatMapState.editMode) return; const target = isSlotBasedSeatMap() ? (event.target.closest(".seatmap-slot") || event.target.closest("#seatmap-dxf-canvas")) : event.target.closest(".seatmap-cell"); if (!target) return; event.preventDefault(); event.dataTransfer.dropEffect = "move"; }); dom.board?.addEventListener("drop", handleSeatMapCellDrop); dom.boardWrap?.addEventListener("mousedown", (event) => { 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; event.preventDefault(); seatMapState.panning = true; seatMapState.panStartX = event.clientX; seatMapState.panStartY = event.clientY; seatMapState.panScrollLeft = seatMapBoardWrap.scrollLeft; seatMapState.panScrollTop = seatMapBoardWrap.scrollTop; seatMapBoardWrap.classList.add("is-panning"); }); dom.boardWrap?.addEventListener("mouseleave", () => { if (seatMapBoard?.querySelector("#seatmap-dxf-canvas")) return; if (seatMapState.hoveredSlotId == null) return; seatMapState.hoveredSlotId = null; updateSeatMapViewerHoverChip(); }); dom.boardWrap?.addEventListener("mousemove", (event) => { 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; if (nextSlotId === seatMapState.hoveredSlotId) return; seatMapState.hoveredSlotId = nextSlotId; updateSeatMapViewerHoverChip(); }); }); document.addEventListener("mousemove", (event) => { if (!seatMapState.panning || !seatMapBoardWrap) return; const deltaX = event.clientX - seatMapState.panStartX; const deltaY = event.clientY - seatMapState.panStartY; seatMapBoardWrap.scrollLeft = seatMapState.panScrollLeft - deltaX; seatMapBoardWrap.scrollTop = seatMapState.panScrollTop - deltaY; }); document.addEventListener("mouseup", () => { if (!seatMapState.panning || !seatMapBoardWrap) return; seatMapState.panning = false; seatMapBoardWrap.classList.remove("is-panning"); }); document.addEventListener("dragstart", (event) => { const card = event.target.closest(".seatmap-member-card"); if (!seatMapState.editMode || !card) return; const memberId = Number(card.dataset.memberId); if (!memberId) return; seatMapState.draggingMemberId = memberId; setSeatMapDropOverlayActive(true); event.dataTransfer.effectAllowed = "move"; event.dataTransfer.setData("text/plain", String(memberId)); }); document.addEventListener("dragend", () => { seatMapState.draggingMemberId = null; setSeatMapDropOverlayActive(false); }); document.addEventListener("click", () => { hideUserPopover(); }); window.addEventListener("message", handleEmbeddedNavigationMessage); syncGlobalDateControlInputs(); setActiveView(currentView); renderAuth(); window.addEventListener("resize", () => { if (!isSlotBasedSeatMap() || (currentView !== "seatmap-admin" && currentView !== "seatmap-readonly")) return; requestAnimationFrame(() => { if (seatMapState.zoom === 1) { centerSeatMapBoard(); } }); });