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 navButtons = Array.from(document.querySelectorAll(".header-center [data-view]"));
const organizationFrame = document.getElementById("organization-frame");
const organizationStage = document.getElementById("organization-stage");
const seatMapStage = document.getElementById("seatmap-stage");
const emptyStage = document.getElementById("empty-stage");
const seatMapName = document.getElementById("seatmap-name");
const seatMapStatus = document.getElementById("seatmap-status");
const seatMapSaveBtn = document.getElementById("seatmap-save-btn");
const seatMapCancelBtn = document.getElementById("seatmap-cancel-btn");
const seatMapBoardWrap = document.getElementById("seatmap-board-wrap");
const seatMapBoard = document.getElementById("seatmap-board");
const seatMapEmpty = document.getElementById("seatmap-empty");
const seatMapSettingsPanel = document.getElementById("seatmap-settings-panel");
const seatMapSettingsForm = document.getElementById("seatmap-settings-form");
const seatMapFormName = document.getElementById("seatmap-form-name");
const seatMapFileName = document.getElementById("seatmap-file-name");
const seatMapFormRows = document.getElementById("seatmap-form-rows");
const seatMapFormCols = document.getElementById("seatmap-form-cols");
const seatMapFormGap = document.getElementById("seatmap-form-gap");
const seatMapFormImage = document.getElementById("seatmap-form-image");
const seatMapSearch = document.getElementById("seatmap-search");
const seatMapUnassigned = document.getElementById("seatmap-unassigned");
const APP_BASE_URL = String(window.__MH_BASE_URL || "").replace(/\/$/, "");
const viewLabels = {
ledger: "사업관리대장",
project: "프로젝트별 분석",
team: "팀/개인별 분석",
organization: "조직 현황",
seatmap: "조직 현황",
};
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,
};
let currentView = "organization";
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 hideUserPopover() {
userPopover?.classList.add("hidden");
}
function toggleUserPopover() {
userPopover?.classList.toggle("hidden");
}
function isAdmin() {
return getSession()?.user?.role === "admin";
}
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 getUnassignedMembers() {
const placedIds = new Set(getPlacementSource().map((item) => Number(item.member_id)));
const keyword = seatMapState.search.trim().toLowerCase();
return seatMapState.members.filter((member) => {
if (placedIds.has(Number(member.id))) return false;
if (!keyword) return true;
const haystack = `${member.name || ""} ${member.department || ""} ${member.team || ""}`.toLowerCase();
return haystack.includes(keyword);
});
}
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(getInitials(member.name))}`;
return `
${isAdmin() ? "오른쪽 설정 패널에서 이미지와 그리드를 등록하세요." : "관리자에게 자리배치도 등록을 요청하세요."}