1217 lines
42 KiB
JavaScript
1217 lines
42 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 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
|
|
? `<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 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`);
|
|
seatMapBoard.innerHTML = `
|
|
<div class="seatmap-dxf-frame-shell">
|
|
<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 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 syncSeatMapViewerFrame() {
|
|
const frame = seatMapBoard?.querySelector("#seatmap-dxf-frame");
|
|
if (!frame?.contentWindow) return;
|
|
frame.contentWindow.postMessage(
|
|
{ type: "seatmap-set-placed", keys: getDraftPlacedSlotKeys() },
|
|
window.location.origin,
|
|
);
|
|
}
|
|
|
|
function setupSeatMapViewerFrame() {
|
|
const frame = seatMapBoard?.querySelector("#seatmap-dxf-frame");
|
|
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;
|
|
|
|
canvas.addEventListener("dragover", (event) => {
|
|
event.preventDefault();
|
|
event.dataTransfer.dropEffect = "move";
|
|
});
|
|
|
|
canvas.addEventListener("drop", (event) => {
|
|
event.preventDefault();
|
|
const memberId = getDraggedMemberId(event);
|
|
if (!memberId) return;
|
|
const rect = canvas.getBoundingClientRect();
|
|
const picked = frameWindow.__mhSeatmap.pickChairAt(
|
|
event.clientX - rect.left,
|
|
event.clientY - rect.top,
|
|
);
|
|
if (!picked?.key) return;
|
|
const matchedSlot = (seatMapState.slots || []).find((item) => String(item.slot_key) === String(picked.key));
|
|
if (!matchedSlot) return;
|
|
upsertDraftPlacementForSlot(memberId, Number(matchedSlot.id));
|
|
renderSeatMap();
|
|
});
|
|
}, { once: true });
|
|
}
|
|
|
|
function renderUnassignedMembers() {
|
|
if (!seatMapUnassigned) return;
|
|
const editable = seatMapState.editMode && isAdmin();
|
|
const members = getUnassignedMembers();
|
|
|
|
if (!members.length) {
|
|
seatMapUnassigned.innerHTML = `
|
|
<div class="seatmap-list-empty">
|
|
${seatMapState.search ? "검색 결과가 없습니다." : "미배치 인원이 없습니다."}
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
seatMapUnassigned.innerHTML = members.map((member) => renderUnassignedMemberCard(member, editable)).join("");
|
|
}
|
|
|
|
function renderSeatMapEmpty() {
|
|
if (!seatMapEmpty) return;
|
|
if (seatMapState.seatMap) {
|
|
seatMapEmpty.classList.add("hidden");
|
|
seatMapEmpty.innerHTML = "";
|
|
return;
|
|
}
|
|
|
|
seatMapEmpty.classList.remove("hidden");
|
|
seatMapEmpty.innerHTML = `
|
|
<div class="seatmap-empty-card">
|
|
<strong>등록된 자리배치도가 없습니다.</strong>
|
|
<p>${isAdmin() ? "오른쪽 설정 패널에서 이미지와 그리드를 등록하세요." : "관리자에게 자리배치도 등록을 요청하세요."}</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 = isAdmin();
|
|
const fixedViewerMap = seatMapState.seatMap?.source_type === "fixed_html";
|
|
|
|
if (seatMapName) {
|
|
seatMapName.textContent = hasSeatMap ? seatMapState.seatMap.name : "자리배치도";
|
|
}
|
|
if (seatMapStatus) {
|
|
seatMapStatus.textContent = seatMapState.status;
|
|
seatMapStatus.dataset.tone = seatMapState.statusTone;
|
|
}
|
|
if (seatMapSettingsPanel) {
|
|
seatMapSettingsPanel.classList.toggle("hidden", !admin || fixedViewerMap);
|
|
}
|
|
if (seatMapSaveBtn) {
|
|
seatMapSaveBtn.hidden = !admin || !hasSeatMap;
|
|
seatMapSaveBtn.disabled = !seatMapState.dirty;
|
|
}
|
|
if (seatMapCancelBtn) {
|
|
seatMapCancelBtn.hidden = !hasSeatMap;
|
|
}
|
|
if (seatMapSettingsForm) {
|
|
seatMapSettingsForm.querySelector("button[type='submit']").textContent = hasSeatMap ? "배치도 저장" : "배치도 생성";
|
|
}
|
|
|
|
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" && isAdmin()) {
|
|
hideUserPopover();
|
|
setActiveView("seatmap");
|
|
}
|
|
if (data.type === "open-organization") {
|
|
hideUserPopover();
|
|
setActiveView("organization");
|
|
}
|
|
}
|
|
|
|
async function fetchJson(url, options) {
|
|
const response = await fetch(resolveAppUrl(url), options);
|
|
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 activePayload = await fetchJson("/api/seat-maps/active");
|
|
const activeSeatMap = activePayload.item;
|
|
const layoutPayload = await fetchJson(`/api/seat-maps/${activeSeatMap.id}/layout`);
|
|
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 = isAdmin();
|
|
resetSeatMapDraft();
|
|
seatMapState.loaded = true;
|
|
setSeatMapStatus(isAdmin() ? "구성원을 바로 드래그해서 배치한 뒤 저장하세요." : "자리배치도를 불러왔습니다.", "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 = isAdmin();
|
|
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;
|
|
});
|
|
}
|
|
|
|
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 (!isAdmin()) 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);
|
|
setSeatMapStatus("자리배치를 저장했습니다.", "success");
|
|
} catch (error) {
|
|
setSeatMapStatus(error.message || "자리배치도 저장에 실패했습니다.", "error");
|
|
renderSeatMap();
|
|
}
|
|
}
|
|
|
|
function cancelSeatMapEdit() {
|
|
resetSeatMapDraft();
|
|
setSeatMapStatus("", "info");
|
|
setActiveView("organization");
|
|
}
|
|
|
|
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 (seatMapState.seatMap?.source_type === "dxf") {
|
|
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);
|
|
renderSeatMap();
|
|
}
|
|
|
|
function setActiveView(view) {
|
|
const previousView = currentView;
|
|
currentView = view in viewLabels ? view : "organization";
|
|
if (currentViewTitle) {
|
|
currentViewTitle.textContent = viewLabels[currentView];
|
|
}
|
|
|
|
navButtons.forEach((button) => {
|
|
const active = button.dataset.view === currentView;
|
|
button.classList.toggle("active", active);
|
|
button.classList.toggle("muted", !active);
|
|
});
|
|
|
|
const isOrganization = currentView === "organization";
|
|
const isSeatMap = currentView === "seatmap";
|
|
if (organizationStage) {
|
|
organizationStage.hidden = !isOrganization;
|
|
organizationStage.style.display = isOrganization ? "flex" : "none";
|
|
}
|
|
if (seatMapStage) {
|
|
seatMapStage.hidden = !isSeatMap;
|
|
seatMapStage.style.display = isSeatMap ? "flex" : "none";
|
|
}
|
|
if (emptyStage) {
|
|
const showEmpty = !isOrganization && !isSeatMap;
|
|
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 (isSeatMap) {
|
|
loadSeatMapData();
|
|
}
|
|
}
|
|
|
|
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 = "-";
|
|
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/mock-login", {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
setSession(payload);
|
|
loginForm.reset();
|
|
loginMessage.textContent = "";
|
|
renderAuth();
|
|
if (currentView === "seatmap") {
|
|
await loadSeatMapData(true);
|
|
}
|
|
} catch (error) {
|
|
loginMessage.textContent = error.message || "로그인에 실패했습니다.";
|
|
}
|
|
});
|
|
}
|
|
|
|
if (userBadge) {
|
|
userBadge.addEventListener("click", (event) => {
|
|
event.stopPropagation();
|
|
toggleUserPopover();
|
|
});
|
|
}
|
|
|
|
if (logoutBtn) {
|
|
logoutBtn.addEventListener("click", (event) => {
|
|
event.stopPropagation();
|
|
clearSession();
|
|
hideUserPopover();
|
|
renderAuth();
|
|
});
|
|
}
|
|
|
|
navButtons.forEach((button) => {
|
|
button.addEventListener("click", () => {
|
|
hideUserPopover();
|
|
setActiveView(button.dataset.view || "organization");
|
|
});
|
|
});
|
|
|
|
if (seatMapSettingsForm) {
|
|
seatMapSettingsForm.addEventListener("submit", submitSeatMapSettings);
|
|
}
|
|
|
|
if (seatMapSaveBtn) {
|
|
seatMapSaveBtn.addEventListener("click", saveSeatLayout);
|
|
}
|
|
|
|
if (seatMapCancelBtn) {
|
|
seatMapCancelBtn.addEventListener("click", cancelSeatMapEdit);
|
|
}
|
|
|
|
if (seatMapSearch) {
|
|
seatMapSearch.addEventListener("input", () => {
|
|
seatMapState.search = seatMapSearch.value || "";
|
|
renderSeatMap();
|
|
});
|
|
}
|
|
|
|
if (seatMapFormImage) {
|
|
seatMapFormImage.addEventListener("change", () => {
|
|
if (seatMapFileName) {
|
|
seatMapFileName.textContent = seatMapFormImage.files?.[0]?.name || "선택된 파일 없음";
|
|
}
|
|
});
|
|
}
|
|
|
|
if (seatMapBoard) {
|
|
seatMapBoard.addEventListener("wheel", (event) => {
|
|
if (seatMapState.seatMap?.source_type !== "dxf") return;
|
|
event.preventDefault();
|
|
zoomDxfSeatMapAtPoint(event.clientX, event.clientY, event.deltaY < 0 ? 1.08 : 0.92);
|
|
}, { passive: false });
|
|
seatMapBoard.addEventListener("click", (event) => {
|
|
const fitButton = event.target.closest("[data-seatmap-action='fit']");
|
|
if (!fitButton) return;
|
|
fitDxfSeatMapBoard();
|
|
});
|
|
seatMapBoard.addEventListener("dragover", (event) => {
|
|
if (!seatMapState.editMode) return;
|
|
const target = seatMapState.seatMap?.source_type === "dxf"
|
|
? (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";
|
|
});
|
|
seatMapBoard.addEventListener("drop", handleSeatMapCellDrop);
|
|
}
|
|
|
|
if (seatMapBoardWrap) {
|
|
seatMapBoardWrap.addEventListener("mousedown", (event) => {
|
|
if (seatMapState.seatMap?.source_type !== "dxf") 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");
|
|
});
|
|
seatMapBoardWrap.addEventListener("mouseleave", () => {
|
|
if (seatMapBoard?.querySelector("#seatmap-dxf-canvas")) return;
|
|
if (seatMapState.hoveredSlotId == null) return;
|
|
seatMapState.hoveredSlotId = null;
|
|
updateSeatMapViewerHoverChip();
|
|
});
|
|
seatMapBoardWrap.addEventListener("mousemove", (event) => {
|
|
if (seatMapState.seatMap?.source_type !== "dxf") 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");
|
|
});
|
|
|
|
if (seatMapUnassigned) {
|
|
seatMapUnassigned.addEventListener("dragover", (event) => {
|
|
if (!seatMapState.editMode) return;
|
|
event.preventDefault();
|
|
event.dataTransfer.dropEffect = "move";
|
|
});
|
|
seatMapUnassigned.addEventListener("drop", handleSeatMapListDrop);
|
|
}
|
|
|
|
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;
|
|
event.dataTransfer.effectAllowed = "move";
|
|
event.dataTransfer.setData("text/plain", String(memberId));
|
|
});
|
|
|
|
document.addEventListener("dragend", () => {
|
|
seatMapState.draggingMemberId = null;
|
|
});
|
|
|
|
document.addEventListener("click", () => {
|
|
hideUserPopover();
|
|
});
|
|
|
|
window.addEventListener("message", handleEmbeddedNavigationMessage);
|
|
|
|
setActiveView(currentView);
|
|
renderAuth();
|
|
|
|
window.addEventListener("resize", () => {
|
|
if (seatMapState.seatMap?.source_type !== "dxf" || currentView !== "seatmap") return;
|
|
requestAnimationFrame(() => {
|
|
if (seatMapState.zoom === 1) {
|
|
centerSeatMapBoard();
|
|
}
|
|
});
|
|
});
|