feat: update seat map dxf workflow and organization ui
This commit is contained in:
726
frontend/public/app.js
Executable file → Normal file
726
frontend/public/app.js
Executable file → Normal 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("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Pretendard:wght@400;600;700;900&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/legacy/static/common.css">
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
<link rel="stylesheet" href="/styles.css?v=20260325-11">
|
||||
</head>
|
||||
<body>
|
||||
<section id="login-panel" class="login-screen">
|
||||
@@ -53,12 +53,10 @@
|
||||
<button id="user-badge" class="ghost-button ghost-button-soft user-chip" type="button"></button>
|
||||
<div id="user-popover" class="user-popover hidden"></div>
|
||||
<button id="logout-btn" class="ghost-button icon-button" type="button" title="로그아웃" aria-label="로그아웃">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M15 3h-4a2 2 0 0 0-2 2v3" />
|
||||
<path d="M10 17v2a2 2 0 0 0 2 2h3" />
|
||||
<path d="M21 12H9" />
|
||||
<path d="m16 7 5 5-5 5" />
|
||||
<path d="M3 5h8v14H3z" />
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
|
||||
<polyline points="16 17 21 12 16 7"></polyline>
|
||||
<line x1="21" y1="12" x2="9" y2="12"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
@@ -67,7 +65,68 @@
|
||||
<main class="dashboard-main">
|
||||
<section id="organization-stage" class="main-stage">
|
||||
<div class="stage-frame">
|
||||
<iframe id="organization-frame" src="/legacy/organization?v=20260325-2" data-src="/legacy/organization?v=20260325-2" title="조직도 메인 화면"></iframe>
|
||||
<iframe id="organization-frame" src="/legacy/organization?v=20260325-11" data-src="/legacy/organization?v=20260325-11" title="조직도 메인 화면"></iframe>
|
||||
</div>
|
||||
</section>
|
||||
<section id="seatmap-stage" class="main-stage" hidden>
|
||||
<div class="seatmap-layout">
|
||||
<div class="seatmap-topbar">
|
||||
<div>
|
||||
<p class="eyebrow">Seat Layout</p>
|
||||
<h3 id="seatmap-name">자리배치도</h3>
|
||||
</div>
|
||||
<div class="seatmap-actions">
|
||||
<button id="seatmap-save-btn" class="ghost-button" type="button" hidden disabled>저장</button>
|
||||
<button id="seatmap-cancel-btn" class="ghost-button ghost-button-soft" type="button" hidden>취소</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p id="seatmap-status" class="seatmap-status" role="status"></p>
|
||||
|
||||
<div class="seatmap-content">
|
||||
<div class="seatmap-board-panel">
|
||||
<div id="seatmap-empty" class="seatmap-empty hidden"></div>
|
||||
<div id="seatmap-board-wrap" class="seatmap-board-wrap hidden">
|
||||
<div id="seatmap-board" class="seatmap-board"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="seatmap-sidebar">
|
||||
<section id="seatmap-settings-panel" class="seatmap-panel hidden">
|
||||
<div class="seatmap-panel-head">
|
||||
<h4>배치도 설정</h4>
|
||||
<p>DXF 파일의 chair 레이어를 좌석 위치로 사용합니다.</p>
|
||||
</div>
|
||||
<form id="seatmap-settings-form" class="seatmap-form">
|
||||
<label>
|
||||
<span>배치도 이름</span>
|
||||
<input id="seatmap-form-name" name="name" type="text" placeholder="예: 본사 3층" required>
|
||||
</label>
|
||||
<div>
|
||||
<span>DXF 파일</span>
|
||||
<label class="seatmap-file-input" for="seatmap-form-image">
|
||||
<input id="seatmap-form-image" name="image" type="file" accept=".dxf" required>
|
||||
<span class="seatmap-file-button">DXF 선택</span>
|
||||
<strong id="seatmap-file-name" class="seatmap-file-name">선택된 파일 없음</strong>
|
||||
</label>
|
||||
</div>
|
||||
<button id="seatmap-settings-submit" type="submit">DXF 업로드</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="seatmap-panel">
|
||||
<div class="seatmap-panel-head">
|
||||
<h4>미배치 인원</h4>
|
||||
<p>이름을 검색하고 자리배치도에 바로 드래그하세요.</p>
|
||||
</div>
|
||||
<label class="seatmap-search">
|
||||
<span class="hidden">구성원 검색</span>
|
||||
<input id="seatmap-search" type="search" placeholder="이름 또는 부서 검색">
|
||||
</label>
|
||||
<div id="seatmap-unassigned" class="seatmap-member-list"></div>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section id="empty-stage" class="main-stage" hidden>
|
||||
@@ -76,6 +135,6 @@
|
||||
</main>
|
||||
</section>
|
||||
|
||||
<script src="/app.js"></script>
|
||||
<script src="/app.js?v=20260325-11"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -182,12 +182,11 @@
|
||||
}
|
||||
|
||||
.header-center {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
margin-left: auto;
|
||||
margin-right: 48px;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
gap: 24px;
|
||||
flex-wrap: nowrap;
|
||||
white-space: nowrap;
|
||||
z-index: 1;
|
||||
@@ -197,33 +196,32 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 34px;
|
||||
padding: 0 14px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #dbe2ea;
|
||||
background: #f8fafc;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
min-height: 48px;
|
||||
padding: 0 4px;
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
border-bottom: 3px solid transparent;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: transform 0.16s ease, box-shadow 0.16s ease, border-color 0.16s ease;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.nav-pill.active {
|
||||
background: var(--color-accent);
|
||||
border-color: transparent;
|
||||
color: #fff;
|
||||
box-shadow: 0 10px 20px rgba(79, 70, 229, 0.2);
|
||||
background: transparent;
|
||||
border-bottom-color: var(--color-accent);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.nav-pill.muted {
|
||||
color: #64748b;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.nav-pill:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: #c7d2fe;
|
||||
box-shadow: 0 8px 18px rgba(148, 163, 184, 0.16);
|
||||
transform: none;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
@@ -242,6 +240,34 @@
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.icon-button:hover {
|
||||
background: #f1f5f9;
|
||||
border-color: #cbd5e1;
|
||||
color: var(--color-accent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.icon-button svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2.5;
|
||||
fill: none;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.ghost-button-soft {
|
||||
@@ -291,26 +317,16 @@
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
width: 34px;
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.icon-button svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.9;
|
||||
fill: none;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
.user-chip-caret {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.user-popover {
|
||||
position: absolute;
|
||||
top: calc(100% + 10px);
|
||||
right: 42px;
|
||||
right: 0;
|
||||
min-width: 220px;
|
||||
padding: 14px;
|
||||
border: 1px solid #dbe2ea;
|
||||
@@ -340,6 +356,19 @@
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.user-popover-action {
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
min-height: 38px;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
background: #0f172a;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dashboard-main {
|
||||
flex: 1;
|
||||
min-height: calc(100vh - 68px);
|
||||
@@ -371,6 +400,514 @@
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.seatmap-layout {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 18px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(248, 250, 252, 0.94), rgba(241, 245, 249, 0.92)),
|
||||
radial-gradient(circle at top left, rgba(14, 165, 233, 0.1), transparent 32%);
|
||||
}
|
||||
|
||||
.seatmap-topbar {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.seatmap-topbar h3,
|
||||
.seatmap-panel-head h4 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.seatmap-topbar .eyebrow {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.seatmap-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.seatmap-status {
|
||||
min-height: 20px;
|
||||
margin: 0;
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.seatmap-status[data-tone="error"] {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.seatmap-status[data-tone="success"] {
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
.seatmap-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 320px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.seatmap-board-panel,
|
||||
.seatmap-panel {
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
border-radius: 24px;
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.seatmap-board-panel {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.seatmap-board-wrap {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
border-radius: 24px;
|
||||
background: #fff;
|
||||
padding: 0;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.seatmap-board-wrap.is-panning {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.seatmap-board {
|
||||
min-width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.seatmap-dxf-canvas {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
margin: 0 auto;
|
||||
border-radius: 24px;
|
||||
overflow: hidden;
|
||||
box-shadow: none;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.seatmap-dxf-stage {
|
||||
position: relative;
|
||||
transform-origin: center center;
|
||||
transition: transform 0.12s ease-out;
|
||||
}
|
||||
|
||||
.seatmap-dxf-preview {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
line-height: 0;
|
||||
filter: contrast(1.9) saturate(1.1) brightness(0.88);
|
||||
}
|
||||
|
||||
.seatmap-preview-svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.seatmap-preview-svg .seatmap-dxf-entity {
|
||||
stroke: #000 !important;
|
||||
stroke-opacity: 1 !important;
|
||||
stroke-width: 12 !important;
|
||||
}
|
||||
|
||||
.seatmap-preview-svg .seatmap-dxf-chair-entity {
|
||||
stroke: #2563eb !important;
|
||||
stroke-opacity: 1 !important;
|
||||
stroke-width: 6 !important;
|
||||
}
|
||||
|
||||
.seatmap-preview-svg rect {
|
||||
fill: #fff !important;
|
||||
}
|
||||
|
||||
.seatmap-dxf-slots {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.seatmap-slot {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 30px;
|
||||
min-height: 30px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
pointer-events: auto;
|
||||
transition: box-shadow 0.18s ease, background 0.18s ease, transform 0.18s ease;
|
||||
}
|
||||
|
||||
.seatmap-slot.editable:hover {
|
||||
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.4);
|
||||
background: rgba(37, 99, 235, 0.12);
|
||||
transform: translate(-50%, -50%) scale(1.02);
|
||||
}
|
||||
|
||||
.seatmap-slot.occupied {
|
||||
width: 34px;
|
||||
min-height: 34px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.seatmap-slot.empty {
|
||||
opacity: 0.14;
|
||||
}
|
||||
|
||||
.seatmap-canvas {
|
||||
position: relative;
|
||||
width: min(100%, 1240px);
|
||||
margin: 0 auto;
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 20px 40px rgba(15, 23, 42, 0.16);
|
||||
}
|
||||
|
||||
.seatmap-image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
.seatmap-grid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--seatmap-cols), 1fr);
|
||||
grid-template-rows: repeat(var(--seatmap-rows), 1fr);
|
||||
gap: var(--seatmap-gap);
|
||||
padding: var(--seatmap-gap);
|
||||
}
|
||||
|
||||
.seatmap-cell {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
border: 1px dashed rgba(15, 23, 42, 0.14);
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
transition: border-color 0.18s ease, background 0.18s ease;
|
||||
}
|
||||
|
||||
.seatmap-cell.editable:hover {
|
||||
border-color: rgba(14, 165, 233, 0.62);
|
||||
background: rgba(14, 165, 233, 0.12);
|
||||
}
|
||||
|
||||
.seatmap-cell.occupied {
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
.seatmap-cell-label {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 4px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 999px;
|
||||
background: rgba(15, 23, 42, 0.72);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.seatmap-member-card {
|
||||
position: absolute;
|
||||
inset: 22px 6px 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||
border-radius: 14px;
|
||||
background: rgba(15, 23, 42, 0.8);
|
||||
box-shadow: 0 12px 24px rgba(15, 23, 42, 0.18);
|
||||
color: #fff;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.seatmap-member-card.draggable {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.seatmap-member-avatar {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
flex: 0 0 auto;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.seatmap-member-avatar img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.seatmap-member-avatar-fallback {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.seatmap-member-text {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.seatmap-member-text strong,
|
||||
.seatmap-member-text em {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.seatmap-member-text strong {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.seatmap-member-text em {
|
||||
color: rgba(226, 232, 240, 0.84);
|
||||
font-size: 10px;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.seatmap-sidebar {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.seatmap-panel {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.seatmap-sidebar .seatmap-panel:last-child {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.seatmap-panel-head p {
|
||||
margin: 6px 0 0;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.seatmap-form,
|
||||
.seatmap-form-grid {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.seatmap-form label,
|
||||
.seatmap-form > div,
|
||||
.seatmap-search {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.seatmap-form span {
|
||||
color: #475569;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.seatmap-form input,
|
||||
.seatmap-search input,
|
||||
.seatmap-form button {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
min-height: 38px;
|
||||
border-radius: 12px;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.seatmap-form input,
|
||||
.seatmap-search input {
|
||||
border: 1px solid #d7dee8;
|
||||
padding: 0 12px;
|
||||
background: #fff;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.seatmap-file-input {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-height: 48px;
|
||||
padding: 8px 10px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #d7dee8;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(180deg, #fff, #f8fafc);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.seatmap-file-input input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.seatmap-file-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 32px;
|
||||
padding: 0 12px;
|
||||
border-radius: 10px;
|
||||
background: #0f172a;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.seatmap-file-name {
|
||||
min-width: 0;
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.seatmap-form button {
|
||||
border: 0;
|
||||
background: #0f172a;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.seatmap-member-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.seatmap-member-list .seatmap-member-card {
|
||||
position: relative;
|
||||
inset: auto;
|
||||
}
|
||||
|
||||
.seatmap-member-card-compact {
|
||||
position: relative;
|
||||
inset: auto;
|
||||
min-height: 42px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
background: #3f4658;
|
||||
border: 1px solid rgba(148, 163, 184, 0.14);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.seatmap-member-text-inline {
|
||||
flex-direction: row;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.seatmap-member-text-inline strong {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.seatmap-member-text-inline em {
|
||||
display: inline;
|
||||
color: rgba(226, 232, 240, 0.8);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.seatmap-slot .seatmap-member-card {
|
||||
inset: auto;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
min-width: 48px;
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 8px;
|
||||
padding: 3px 4px;
|
||||
gap: 4px;
|
||||
box-shadow: 0 4px 10px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
.seatmap-slot .seatmap-member-avatar {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.seatmap-slot .seatmap-member-text strong {
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
.seatmap-slot .seatmap-member-text em {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.seatmap-dxf-stage {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.seatmap-list-empty,
|
||||
.seatmap-empty-card {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 120px;
|
||||
border: 1px dashed rgba(148, 163, 184, 0.4);
|
||||
border-radius: 18px;
|
||||
background: rgba(248, 250, 252, 0.8);
|
||||
color: #64748b;
|
||||
text-align: center;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.seatmap-empty-card strong {
|
||||
display: block;
|
||||
color: #0f172a;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.dashboard-header {
|
||||
flex-wrap: wrap;
|
||||
@@ -390,6 +927,14 @@
|
||||
.header-actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.seatmap-content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.seatmap-sidebar {
|
||||
order: -1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
@@ -414,6 +959,15 @@
|
||||
.login-form-wrap {
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.seatmap-layout {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.seatmap-topbar {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
@@ -424,4 +978,28 @@
|
||||
.main-stage {
|
||||
height: calc(100vh - 68px);
|
||||
}
|
||||
|
||||
.seatmap-board {
|
||||
min-width: 640px;
|
||||
}
|
||||
|
||||
.seatmap-member-card {
|
||||
inset: 20px 4px 4px;
|
||||
padding: 6px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.seatmap-member-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.seatmap-member-text strong {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.seatmap-member-text em {
|
||||
font-size: 9px;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user