feat: update seat map dxf workflow and organization ui

This commit is contained in:
hyunho
2026-03-25 18:00:09 +09:00
parent 8f073e1458
commit e62a6a5458
27 changed files with 2517660 additions and 125 deletions

726
frontend/public/app.js Executable file → Normal file
View File

@@ -11,13 +11,55 @@ 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 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,
};
let currentView = "organization";
@@ -46,31 +88,588 @@ function toggleUserPopover() {
userPopover?.classList.toggle("hidden");
}
function isAdmin() {
return getSession()?.user?.role === "admin";
}
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
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(3, Math.max(0.5, Number(nextZoom.toFixed(2))));
}
function setSeatMapZoom(nextZoom) {
seatMapState.zoom = clampSeatMapZoom(nextZoom);
renderSeatMap();
}
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") {
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 memberMap = getMemberMap();
const placementMap = getSlotPlacementMap();
const slots = Array.isArray(seatMapState.slots) ? seatMapState.slots : [];
const editable = seatMapState.editMode && isAdmin();
const minX = Number(seatMapState.seatMap.view_box_min_x || 0);
const minY = Number(seatMapState.seatMap.view_box_min_y || 0);
const width = Number(seatMapState.seatMap.view_box_width || 1);
const height = Number(seatMapState.seatMap.view_box_height || 1);
const previewSvg = seatMapState.seatMap.preview_svg || "";
const slotHtml = slots
.map((slot) => {
const slotId = Number(slot.id);
const placement = placementMap.get(slotId);
const member = placement ? memberMap.get(Number(placement.member_id)) : null;
if (!member && !editable) {
return "";
}
const left = ((Number(slot.x) - minX) / width) * 100;
const top = (1 - (Number(slot.y) - minY) / height) * 100;
return `
<div class="seatmap-slot${placement ? " occupied" : ""}${editable ? " editable" : ""}${!member ? " empty" : ""}" data-slot-id="${slotId}" style="left:${left}%; top:${top}%;">
${member ? renderMemberCard(member, editable) : ""}
</div>
`;
})
.join("");
seatMapBoard.innerHTML = `
<div class="seatmap-dxf-canvas">
<div class="seatmap-dxf-stage" style="transform: scale(${seatMapState.zoom}); --seatmap-zoom:${seatMapState.zoom};">
<div class="seatmap-dxf-preview">${previewSvg}</div>
<div class="seatmap-dxf-slots">${slotHtml}</div>
</div>
</div>
`;
}
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();
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);
}
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(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;
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.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.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) return;
upsertDraftPlacementForSlot(memberId, Number(slot.dataset.slotId));
} 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) {
emptyStage.hidden = isOrganization;
emptyStage.style.display = isOrganization ? "none" : "flex";
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 = frameSrc;
}
if (isSeatMap) {
loadSeatMapData();
}
}
function renderAuth() {
@@ -81,29 +680,31 @@ function renderAuth() {
if (authenticated) {
const displayName = session.user.display_name || "접속자";
const rank = "-";
userBadge.innerHTML = `<span class="user-chip-icon">◎</span><span class="user-chip-text"><strong>${displayName}</strong><em>${rank}</em></span><span class="user-chip-caret" aria-hidden="true">▾</span>`;
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>${displayName}</strong>
<strong>${escapeHtml(displayName)}</strong>
</div>
<div class="user-popover-row">
<span class="user-popover-label">직급</span>
<span>${rank}</span>
<span>${escapeHtml(rank)}</span>
</div>
<div class="user-popover-row">
<span class="user-popover-label">권한</span>
<span>${session.user.role || "-"}</span>
<span>${escapeHtml(session.user.role || "-")}</span>
</div>
<div class="user-popover-row">
<span class="user-popover-label">아이디</span>
<span>${session.user.username || "-"}</span>
<span class="user-popover-label">사번</span>
<span>${escapeHtml(employeeId)}</span>
</div>
`;
}
}
renderSeatMap();
}
if (loginForm) {
@@ -112,17 +713,19 @@ if (loginForm) {
loginMessage.textContent = "로그인 처리 중입니다.";
const formData = new FormData(loginForm);
try {
const response = await fetch("/api/mock-login", {
const payload = await fetchJson("/api/mock-login", {
method: "POST",
body: formData,
});
const payload = await response.json();
if (!response.ok) throw new Error(payload.detail || "login failed");
setSession(payload);
loginForm.reset();
loginMessage.textContent = "";
renderAuth();
if (currentView === "seatmap") {
await loadSeatMapData(true);
}
} catch (error) {
loginMessage.textContent = "로그인에 실패했습니다. backend 연결 상태를 확인해주세요.";
loginMessage.textContent = error.message || "로그인에 실패했습니다.";
}
});
}
@@ -150,9 +753,108 @@ navButtons.forEach((button) => {
});
});
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();
const delta = event.deltaY < 0 ? 0.1 : -0.1;
setSeatMapZoom(seatMapState.zoom + delta);
}, { passive: false });
seatMapBoard.addEventListener("dragover", (event) => {
if (!seatMapState.editMode) return;
const target = seatMapState.seatMap?.source_type === "dxf"
? event.target.closest(".seatmap-slot")
: 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 (event.button !== 1) 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");
});
}
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();