Files
MH-DashBoard-organization/frontend/public/app.js

2003 lines
70 KiB
JavaScript

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 organizationHistoryControls = document.getElementById("organization-history-controls");
const organizationMonthSelect = document.getElementById("organization-month-select");
const organizationCompareBtn = document.getElementById("organization-compare-btn");
const navButtons = Array.from(document.querySelectorAll(".header-center [data-view]"));
const ledgerFrame = document.getElementById("ledger-frame");
const ledgerStage = document.getElementById("ledger-stage");
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 = "ledger";
const globalDateState = {
loaded: false,
startDate: "",
endDate: "",
};
const organizationHistoryState = {
selectedMonth: "",
currentMonth: "",
};
function padDatePart(value) {
return String(value).padStart(2, "0");
}
function getCurrentMonthValue() {
const now = new Date();
return `${now.getFullYear()}-${padDatePart(now.getMonth() + 1)}`;
}
function getMonthLabel(monthValue) {
const [, month] = String(monthValue || "").split("-");
if (!month) return "";
const monthNumber = Number(month);
if (!Number.isInteger(monthNumber) || monthNumber <= 0) return "";
return `${monthNumber}`;
}
function getMonthEndDate(monthValue) {
const [yearText, monthText] = String(monthValue || "").split("-");
const year = Number(yearText);
const month = Number(monthText);
if (!Number.isInteger(year) || !Number.isInteger(month) || month <= 0) return "";
const lastDay = new Date(year, month, 0);
return `${lastDay.getFullYear()}-${padDatePart(lastDay.getMonth() + 1)}-${padDatePart(lastDay.getDate())}`;
}
function getTodayDate() {
const now = new Date();
return `${now.getFullYear()}-${padDatePart(now.getMonth() + 1)}-${padDatePart(now.getDate())}`;
}
function parseDateOnly(value) {
const raw = String(value || "").trim();
if (!raw) return null;
const match = raw.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!match) return null;
const year = Number(match[1]);
const monthIndex = Number(match[2]) - 1;
const day = Number(match[3]);
const parsed = new Date(year, monthIndex, day);
if (
Number.isNaN(parsed.getTime())
|| parsed.getFullYear() !== year
|| parsed.getMonth() !== monthIndex
|| parsed.getDate() !== day
) {
return null;
}
return parsed;
}
function formatDateOnly(date) {
if (!(date instanceof Date) || Number.isNaN(date.getTime())) return "";
return `${date.getFullYear()}-${padDatePart(date.getMonth() + 1)}-${padDatePart(date.getDate())}`;
}
function getMonthRangeForDate(date) {
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
return { startDate: "", endDate: "" };
}
const start = new Date(date.getFullYear(), date.getMonth(), 1);
const end = new Date(date.getFullYear(), date.getMonth() + 1, 0);
return {
startDate: formatDateOnly(start),
endDate: formatDateOnly(end),
};
}
function getPreviousMonthRange(baseDate = new Date()) {
return getMonthRangeForDate(new Date(baseDate.getFullYear(), baseDate.getMonth() - 1, 1));
}
function resolveLatestCompletedMonthRange(maxDateText) {
const fallbackRange = getPreviousMonthRange();
const latestAvailableDate = parseDateOnly(String(maxDateText || "").slice(0, 10));
if (!latestAvailableDate) return fallbackRange;
const fallbackStart = parseDateOnly(fallbackRange.startDate);
if (fallbackStart && latestAvailableDate >= fallbackStart) {
return fallbackRange;
}
return getMonthRangeForDate(latestAvailableDate);
}
function syncOrganizationHistoryControls() {
if (!organizationHistoryControls) return;
const visible = currentView === "organization";
organizationHistoryControls.classList.toggle("hidden", !visible);
if (organizationCompareBtn) {
const isCurrentMonth = !organizationHistoryState.selectedMonth || organizationHistoryState.selectedMonth === organizationHistoryState.currentMonth;
organizationCompareBtn.classList.toggle("hidden", !visible || isCurrentMonth);
}
}
function initializeOrganizationMonthOptions() {
if (!organizationMonthSelect) return;
const now = new Date();
const year = now.getFullYear();
const monthCount = now.getMonth() + 1;
organizationMonthSelect.innerHTML = "";
for (let month = monthCount; month >= 1; month -= 1) {
const value = `${year}-${padDatePart(month)}`;
const option = document.createElement("option");
option.value = value;
option.textContent = month === monthCount ? `${month}월 (최신)` : `${month}`;
organizationMonthSelect.append(option);
}
organizationHistoryState.currentMonth = `${year}-${padDatePart(monthCount)}`;
organizationHistoryState.selectedMonth = organizationHistoryState.currentMonth;
organizationMonthSelect.value = organizationHistoryState.selectedMonth;
syncOrganizationHistoryControls();
}
function postOrganizationHistoryState() {
if (!organizationFrame?.contentWindow) return;
const selectedMonth = organizationHistoryState.selectedMonth || organizationHistoryState.currentMonth;
const currentMonth = organizationHistoryState.currentMonth || getCurrentMonthValue();
const isHistorical = Boolean(selectedMonth) && selectedMonth !== currentMonth;
organizationFrame.contentWindow.postMessage(
{
source: "total-control",
type: "organization-history-view",
month: selectedMonth,
asOfDate: isHistorical ? getMonthEndDate(selectedMonth) : "",
historical: isHistorical,
},
window.location.origin,
);
syncOrganizationHistoryControls();
}
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";
}
function syncGlobalDateControlVisibility() {
if (!globalDateControls) return;
globalDateControls.classList.toggle("hidden", !shouldShowGlobalDateControls());
syncOrganizationHistoryControls();
}
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 buildSeatMapAsOfQuery() {
return "";
}
function notifyEmbeddedTabActivated() {
if (currentView === "ledger" && ledgerFrame?.contentWindow) {
ledgerFrame.contentWindow.postMessage({ source: "total-control", type: "embedded-host" }, window.location.origin);
ledgerFrame.contentWindow.postMessage({ source: "total-control", type: "tab-activated", tab: "business" }, window.location.origin);
}
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);
}
}
let ledgerDefaultSourcePromise = null;
async function fetchDefaultLedgerSource() {
if (!ledgerDefaultSourcePromise) {
ledgerDefaultSourcePromise = fetch("/api/integration/business-ledger-default")
.then(async (response) => {
if (!response.ok) {
throw new Error("기본 사업관리대장 원본을 불러오지 못했습니다.");
}
const fileName = response.headers.get("x-source-filename") || "사업관리대장-1.xlsx";
const buffer = await response.arrayBuffer();
if (!buffer || !buffer.byteLength) {
throw new Error("기본 사업관리대장 원본 데이터가 비어 있습니다.");
}
return { fileName, buffer };
})
.catch((error) => {
ledgerDefaultSourcePromise = null;
throw error;
});
}
return ledgerDefaultSourcePromise;
}
async function pushDefaultLedgerSourceToFrame(force = false) {
if (!ledgerFrame?.contentWindow) return;
if (ledgerFrame.dataset.defaultLedgerLoaded === "true" && !force) return;
try {
const { fileName, buffer } = await fetchDefaultLedgerSource();
ledgerFrame.contentWindow.postMessage(
{ source: "total-control", type: "embedded-host" },
window.location.origin,
);
ledgerFrame.contentWindow.postMessage(
{ source: "total-upload", type: "business", fileName, buffer },
window.location.origin,
);
ledgerFrame.dataset.defaultLedgerLoaded = "true";
} catch (error) {
console.error("사업관리대장 기본 원본 전달에 실패했습니다.", error);
}
}
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 ends = [work.max_work_date, voucher.max_voucher_date].filter(Boolean).sort();
const defaultRange = resolveLatestCompletedMonthRange(ends.length ? ends[ends.length - 1] : "");
globalDateState.startDate = defaultRange.startDate;
globalDateState.endDate = defaultRange.endDate;
globalDateState.loaded = true;
syncGlobalDateControlInputs();
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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
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 isMemberAssignedAnywhere(member) {
const seatLabel = String(
member?.member_seat_label
|| member?.seat_label
|| ""
).trim();
return Boolean(seatLabel);
}
function shouldHideMemberFromSeatMap(member) {
if (Boolean(member?.is_retired)) return true;
const workStatus = String(member?.work_status || "").trim();
if (/(퇴사|퇴직)/u.test(workStatus)) return true;
return false;
}
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) => `
<button
class="seatmap-office-tab${seatMapState.officeKey === office.key ? " active" : ""}"
type="button"
data-seatmap-office="${office.key}"
>${escapeHtml(office.label)}</button>
`).join("");
}
function getUnassignedMembers() {
const placedIds = new Set(getPlacementSource().map((item) => Number(item.member_id)));
return seatMapState.members.filter((member) => {
if (shouldHideMemberFromSeatMap(member)) return false;
if (placedIds.has(Number(member.id))) return false;
if (isMemberAssignedAnywhere(member)) return false;
return memberMatchesSeatMapSearch(member);
});
}
function getPlacedMembers() {
const placedIds = new Set(getPlacementSource().map((item) => Number(item.member_id)));
return seatMapState.members.filter((member) => {
if (shouldHideMemberFromSeatMap(member)) return false;
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
? `<span class="seatmap-member-avatar"><img src="${photoUrl}" alt="${escapeHtml(member.name)}"></span>`
: `<span class="seatmap-member-avatar seatmap-member-avatar-fallback">${escapeHtml(getInitials(member.name))}</span>`;
return `
<div class="seatmap-member-card${draggable ? " draggable" : ""}" draggable="${draggable}" data-member-id="${Number(member.id)}">
${avatar}
<span class="seatmap-member-text">
<strong>${escapeHtml(member.name || "-")}</strong>
<em>${escapeHtml(member.department || member.team || member.rank || "-")}</em>
</span>
</div>
`;
}
function renderUnassignedMemberCard(member, draggable) {
return `
<div class="seatmap-member-card seatmap-member-card-compact${draggable ? " draggable" : ""}" draggable="${draggable}" data-member-id="${Number(member.id)}">
<span class="seatmap-member-text seatmap-member-text-inline">
<strong>${escapeHtml(member.name || "-")}</strong>
<em>${escapeHtml(member.rank || "-")}</em>
</span>
</div>
`;
}
function renderSeatMapSearchCard(member) {
const placement = getPlacementForMember(Number(member.id));
if (!placement) return "";
const badge = `<span class="seatmap-member-badge occupied">${escapeHtml(placement.seat_label || "배치완료")}</span>`;
return `
<button class="seatmap-member-search-card" type="button" data-member-id="${Number(member.id)}">
<span class="seatmap-member-text seatmap-member-text-inline">
<strong>${escapeHtml(member.name || "-")}</strong>
<em>${escapeHtml(member.rank || member.department || "-")}</em>
</span>
${badge}
</button>
`;
}
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(`
<div class="seatmap-cell${placement ? " occupied" : ""}${editable ? " editable" : ""}" data-row="${rowIndex}" data-col="${colIndex}">
<span class="seatmap-cell-label">${escapeHtml(computeSeatLabel(rowIndex, colIndex))}</span>
${member ? renderMemberCard(member, editable) : ""}
</div>
`);
}
}
seatMapBoard.innerHTML = `
<div class="seatmap-canvas" style="--seatmap-rows:${rows}; --seatmap-cols:${cols}; --seatmap-gap:${gap}px;">
<img class="seatmap-image" src="${escapeHtml(seatMapState.seatMap.image_url)}" alt="${escapeHtml(seatMapState.seatMap.name)}">
<div class="seatmap-grid">${cells.join("")}</div>
</div>
`;
}
function renderDxfSeatMapBoard() {
if (!seatMapBoard || !seatMapState.seatMap) return;
const viewerData = seatMapState.seatMap.viewer_data;
if (!viewerData) {
seatMapBoard.innerHTML = `<div class="seatmap-empty-card"><strong>DXF 뷰어 데이터를 준비하지 못했습니다.</strong></div>`;
return;
}
const viewerUrl = resolveAppUrl(`/api/seat-maps/${seatMapState.seatMap.id}/viewer${buildSeatMapAsOfQuery()}`);
seatMapBoard.innerHTML = `
<div class="seatmap-dxf-frame-shell">
<div class="seatmap-dxf-drop-overlay" data-seatmap-drop-overlay></div>
<iframe
id="seatmap-dxf-frame"
class="seatmap-dxf-frame"
src="${escapeHtml(viewerUrl)}"
title="${escapeHtml(seatMapState.seatMap.name || "DXF Viewer")}"
loading="eager"
referrerpolicy="same-origin"
></iframe>
</div>
`;
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 = `
<div class="seatmap-list-empty">
${seatMapState.search ? "검색 결과가 없습니다." : "표시할 인원이 없습니다."}
</div>
`;
return;
}
seatMapUnassigned.innerHTML = `
<section class="seatmap-member-section">
<div class="seatmap-member-section-head">
<strong>미배치 인원</strong>
<span>${unassignedMembers.length}명</span>
</div>
<div class="seatmap-member-section-list">
${unassignedMembers.length
? unassignedMembers.map((member) => renderUnassignedMemberCard(member, true)).join("")
: '<div class="seatmap-list-empty seatmap-list-empty-inline">미배치 인원이 없습니다.</div>'}
</div>
</section>
<section class="seatmap-member-section">
<div class="seatmap-member-section-head">
<strong>배치 완료</strong>
<span>${placedMembers.length}명</span>
</div>
<div class="seatmap-member-section-list">
${placedMembers.length
? placedMembers.map((member) => renderSeatMapSearchCard(member)).join("")
: '<div class="seatmap-list-empty seatmap-list-empty-inline">배치된 인원이 없습니다.</div>'}
</div>
</section>
`;
}
function renderReadonlySeatMapSidebar() {
const members = getSidebarMembers();
if (seatMapSidebarTitle) {
seatMapSidebarTitle.textContent = "배치 인원 검색";
}
if (seatMapSidebarDesc) {
seatMapSidebarDesc.textContent = "이름이나 부서를 검색하고 클릭하면 해당 좌석으로 바로 확대 이동합니다.";
}
if (!members.length) {
seatMapUnassigned.innerHTML = `
<div class="seatmap-list-empty">
${seatMapState.search ? "검색 결과가 없습니다." : "배치된 인원이 없습니다."}
</div>
`;
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 = `
<div class="seatmap-empty-card">
<strong>${escapeHtml(office.label)} 도면이 아직 준비되지 않았습니다.</strong>
<p>${office.ready ? (canEditSeatMap() ? "오른쪽 설정 패널에서 이미지와 그리드를 등록하세요." : "관리자에게 자리배치도 등록을 요청하세요.") : "도면 파일을 추후 연결하면 여기서 바로 전환해 볼 수 있습니다."}</p>
</div>
`;
}
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${buildSeatMapAsOfQuery()}`);
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 isLedger = currentView === "ledger";
const isProject = currentView === "project";
const isTeam = currentView === "team";
const isSeatMapAdmin = currentView === "seatmap-admin";
const isSeatMapReadonly = currentView === "seatmap-readonly";
if (ledgerStage) {
ledgerStage.hidden = !isLedger;
ledgerStage.style.display = isLedger ? "flex" : "none";
}
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 = !isLedger && !isOrganization && !isProject && !isTeam && !isSeatMapAdmin && !isSeatMapReadonly;
emptyStage.hidden = !showEmpty;
emptyStage.style.display = showEmpty ? "flex" : "none";
}
if (isLedger && previousView !== "ledger" && ledgerFrame) {
const frameSrc = ledgerFrame.dataset.src || ledgerFrame.src;
ledgerFrame.src = resolveAppUrl(frameSrc);
}
if (isOrganization && previousView !== "organization" && organizationFrame) {
const frameSrc = organizationFrame.dataset.src || organizationFrame.src;
organizationFrame.src = resolveAppUrl(frameSrc);
} else if (isOrganization) {
postOrganizationHistoryState();
}
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 = `<span class="user-chip-icon">◎</span><span class="user-chip-text"><strong>${escapeHtml(displayName)}</strong><em>${escapeHtml(rank)}</em></span><span class="user-chip-caret" aria-hidden="true">▾</span>`;
userBadge.title = `${displayName} / -`;
if (userPopover) {
userPopover.innerHTML = `
<div class="user-popover-row">
<span class="user-popover-label">이름</span>
<strong>${escapeHtml(displayName)}</strong>
</div>
<div class="user-popover-row">
<span class="user-popover-label">직급</span>
<span>${escapeHtml(rank)}</span>
</div>
<div class="user-popover-row">
<span class="user-popover-label">권한</span>
<span>${escapeHtml(session.user.role || "-")}</span>
</div>
<div class="user-popover-row">
<span class="user-popover-label">사번</span>
<span>${escapeHtml(employeeId)}</span>
</div>
`;
}
}
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("ledger");
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(projectFrame);
postGlobalDateRangeToFrame(teamFrame);
});
}
if (globalEndDateInput) {
globalEndDateInput.addEventListener("change", () => {
globalDateState.endDate = globalEndDateInput.value || "";
postGlobalDateRangeToFrame(projectFrame);
postGlobalDateRangeToFrame(teamFrame);
if (currentView === "seatmap-admin" || currentView === "seatmap-readonly") {
seatMapState.loaded = false;
loadSeatMapData(true);
}
});
}
organizationFrame?.addEventListener("load", () => {
postOrganizationHistoryState();
});
ledgerFrame?.addEventListener("load", () => {
if (currentView === "ledger") {
notifyEmbeddedTabActivated();
}
void pushDefaultLedgerSourceToFrame(true);
});
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");
});
});
if (organizationMonthSelect) {
initializeOrganizationMonthOptions();
organizationMonthSelect.addEventListener("change", () => {
organizationHistoryState.selectedMonth = organizationMonthSelect.value || organizationHistoryState.currentMonth;
postOrganizationHistoryState();
});
}
if (organizationCompareBtn) {
organizationCompareBtn.addEventListener("click", () => {
if (!organizationFrame?.contentWindow) return;
const fromDate = getMonthEndDate(organizationHistoryState.selectedMonth);
const toDate = getTodayDate();
if (!fromDate) return;
organizationFrame.contentWindow.postMessage(
{
source: "total-control",
type: "open-history-compare",
fromDate,
toDate,
},
window.location.origin,
);
});
}
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();
}
});
});