feat: split seatmap admin and readonly flows

This commit is contained in:
hyunho
2026-03-26 11:32:33 +09:00
parent 8efb5da65f
commit 69a14fab51
7 changed files with 852 additions and 182 deletions

View File

@@ -11,34 +11,100 @@ 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 seatMapAdminStage = document.getElementById("seatmap-admin-stage");
const seatMapReadonlyStage = document.getElementById("seatmap-readonly-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 seatMapDom = {
admin: {
stage: seatMapAdminStage,
name: document.getElementById("seatmap-admin-name"),
status: document.getElementById("seatmap-admin-status"),
saveBtn: document.getElementById("seatmap-admin-save-btn"),
cancelBtn: null,
exitBtn: document.getElementById("seatmap-admin-exit-btn"),
actions: document.getElementById("seatmap-admin-actions"),
boardWrap: document.getElementById("seatmap-admin-board-wrap"),
board: document.getElementById("seatmap-admin-board"),
empty: document.getElementById("seatmap-admin-empty"),
settingsPanel: document.getElementById("seatmap-admin-settings-panel"),
settingsForm: document.getElementById("seatmap-admin-settings-form"),
formName: document.getElementById("seatmap-admin-form-name"),
fileName: document.getElementById("seatmap-admin-file-name"),
formRows: null,
formCols: null,
formGap: null,
formImage: document.getElementById("seatmap-admin-form-image"),
search: document.getElementById("seatmap-admin-search"),
unassigned: document.getElementById("seatmap-admin-unassigned"),
officeTabs: document.getElementById("seatmap-admin-office-tabs"),
sidebarTitle: document.getElementById("seatmap-admin-sidebar-title"),
sidebarDesc: document.getElementById("seatmap-admin-sidebar-desc"),
},
readonly: {
stage: seatMapReadonlyStage,
name: document.getElementById("seatmap-readonly-name"),
status: document.getElementById("seatmap-readonly-status"),
saveBtn: null,
cancelBtn: null,
exitBtn: document.getElementById("seatmap-readonly-exit-btn"),
actions: document.getElementById("seatmap-readonly-actions"),
boardWrap: document.getElementById("seatmap-readonly-board-wrap"),
board: document.getElementById("seatmap-readonly-board"),
empty: document.getElementById("seatmap-readonly-empty"),
settingsPanel: null,
settingsForm: null,
formName: null,
fileName: null,
formRows: null,
formCols: null,
formGap: null,
formImage: null,
search: document.getElementById("seatmap-readonly-search"),
unassigned: document.getElementById("seatmap-readonly-unassigned"),
officeTabs: document.getElementById("seatmap-readonly-office-tabs"),
sidebarTitle: document.getElementById("seatmap-readonly-sidebar-title"),
sidebarDesc: document.getElementById("seatmap-readonly-sidebar-desc"),
},
};
let seatMapName = seatMapDom.admin.name;
let seatMapStatus = seatMapDom.admin.status;
let seatMapSaveBtn = seatMapDom.admin.saveBtn;
let seatMapCancelBtn = seatMapDom.admin.cancelBtn;
let seatMapExitBtn = seatMapDom.admin.exitBtn;
let seatMapActions = seatMapDom.admin.actions;
let seatMapBoardWrap = seatMapDom.admin.boardWrap;
let seatMapBoard = seatMapDom.admin.board;
let seatMapEmpty = seatMapDom.admin.empty;
let seatMapSettingsPanel = seatMapDom.admin.settingsPanel;
let seatMapSettingsForm = seatMapDom.admin.settingsForm;
let seatMapFormName = seatMapDom.admin.formName;
let seatMapFileName = seatMapDom.admin.fileName;
let seatMapFormRows = seatMapDom.admin.formRows;
let seatMapFormCols = seatMapDom.admin.formCols;
let seatMapFormGap = seatMapDom.admin.formGap;
let seatMapFormImage = seatMapDom.admin.formImage;
let seatMapSearch = seatMapDom.admin.search;
let seatMapUnassigned = seatMapDom.admin.unassigned;
let seatMapOfficeTabs = seatMapDom.admin.officeTabs;
let seatMapSidebarTitle = seatMapDom.admin.sidebarTitle;
let seatMapSidebarDesc = seatMapDom.admin.sidebarDesc;
const APP_BASE_URL = String(window.__MH_BASE_URL || "").replace(/\/$/, "");
const seatMapOffices = [
{ key: "technical-development-center", label: "기술개발센터", ready: true },
{ key: "hanmac-building-7f", label: "한맥빌딩 7층", ready: false },
{ key: "hanmac-building-6f", label: "한맥빌딩 6층", ready: false },
];
const viewLabels = {
ledger: "사업관리대장",
project: "프로젝트별 분석",
team: "팀/개인별 분석",
organization: "조직 현황",
seatmap: "조직 현황",
"seatmap-admin": "자리배치도",
"seatmap-readonly": "자리배치도",
};
const seatMapState = {
@@ -71,6 +137,8 @@ const seatMapState = {
viewerDragStartY: 0,
viewerDragOffsetX: 0,
viewerDragOffsetY: 0,
officeKey: "technical-development-center",
forceReadOnly: false,
};
let currentView = "organization";
@@ -107,6 +175,48 @@ function isSlotBasedSeatMap() {
return seatMapState.seatMap?.source_type === "dxf" || seatMapState.seatMap?.source_type === "fixed_html";
}
function canEditSeatMap() {
return isAdmin() && !seatMapState.forceReadOnly;
}
function getSeatMapScreenMode() {
return canEditSeatMap() ? "admin" : "readonly";
}
function isSeatMapAdminMode() {
return getSeatMapScreenMode() === "admin";
}
function syncSeatMapDomRefs() {
const dom = currentView === "seatmap-readonly" ? seatMapDom.readonly : seatMapDom.admin;
seatMapName = dom.name;
seatMapStatus = dom.status;
seatMapSaveBtn = dom.saveBtn;
seatMapCancelBtn = dom.cancelBtn;
seatMapExitBtn = dom.exitBtn;
seatMapActions = dom.actions;
seatMapBoardWrap = dom.boardWrap;
seatMapBoard = dom.board;
seatMapEmpty = dom.empty;
seatMapSettingsPanel = dom.settingsPanel;
seatMapSettingsForm = dom.settingsForm;
seatMapFormName = dom.formName;
seatMapFileName = dom.fileName;
seatMapFormRows = dom.formRows;
seatMapFormCols = dom.formCols;
seatMapFormGap = dom.formGap;
seatMapFormImage = dom.formImage;
seatMapSearch = dom.search;
seatMapUnassigned = dom.unassigned;
seatMapOfficeTabs = dom.officeTabs;
seatMapSidebarTitle = dom.sidebarTitle;
seatMapSidebarDesc = dom.sidebarDesc;
}
function getCurrentSeatMapOffice() {
return seatMapOffices.find((item) => item.key === seatMapState.officeKey) || seatMapOffices[0];
}
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&")
@@ -443,14 +553,72 @@ function getMemberMap() {
return new Map(seatMapState.members.map((member) => [Number(member.id), member]));
}
function getPlacementForMember(memberId) {
return getPlacementSource().find((item) => Number(item.member_id) === Number(memberId)) || null;
}
function memberMatchesSeatMapSearch(member) {
const keyword = seatMapState.search.trim().toLowerCase();
if (!keyword) return true;
const haystack = `${member.name || ""} ${member.department || ""} ${member.team || ""} ${member.rank || ""}`.toLowerCase();
return haystack.includes(keyword);
}
function getSidebarMembers() {
const members = isSeatMapAdminMode()
? getUnassignedMembers()
: seatMapState.members.filter((member) => Boolean(getPlacementForMember(Number(member.id))));
return members.filter(memberMatchesSeatMapSearch);
}
function focusSeatMapMember(memberId) {
const placement = getPlacementForMember(memberId);
const member = getMemberMap().get(Number(memberId));
if (!placement) {
setSeatMapStatus("해당 인원은 아직 배치되지 않았습니다.", "info");
return;
}
const slot = getSeatSlotMap().get(Number(placement.seat_slot_id));
if (!slot) {
setSeatMapStatus("좌석 정보를 찾지 못했습니다.", "error");
return;
}
const frame = seatMapBoard?.querySelector("#seatmap-dxf-frame");
if (!frame?.contentWindow) {
setSeatMapStatus("현재 선택한 사무실 도면에서는 좌석을 표시할 수 없습니다.", "info");
return;
}
frame.contentWindow.postMessage(
{ type: "seatmap-focus-chair", key: String(slot.slot_key), padding: 2600 },
window.location.origin,
);
setSeatMapStatus(`${member?.name || "구성원"} 좌석으로 이동했습니다.`, "info");
}
function renderSeatMapOfficeTabs() {
if (!seatMapOfficeTabs) return;
seatMapOfficeTabs.innerHTML = seatMapOffices.map((office) => `
<button
class="seatmap-office-tab${seatMapState.officeKey === office.key ? " active" : ""}"
type="button"
data-seatmap-office="${office.key}"
>${escapeHtml(office.label)}</button>
`).join("");
}
function getUnassignedMembers() {
const placedIds = new Set(getPlacementSource().map((item) => Number(item.member_id)));
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);
return memberMatchesSeatMapSearch(member);
});
}
function getPlacedMembers() {
const placedIds = new Set(getPlacementSource().map((item) => Number(item.member_id)));
return seatMapState.members.filter((member) => {
if (!placedIds.has(Number(member.id))) return false;
return memberMatchesSeatMapSearch(member);
});
}
@@ -553,6 +721,21 @@ function renderUnassignedMemberCard(member, draggable) {
`;
}
function renderSeatMapSearchCard(member) {
const placement = getPlacementForMember(Number(member.id));
if (!placement) return "";
const badge = `<span class="seatmap-member-badge occupied">${escapeHtml(placement.seat_label || "배치완료")}</span>`;
return `
<button class="seatmap-member-search-card" type="button" data-member-id="${Number(member.id)}">
<span class="seatmap-member-text seatmap-member-text-inline">
<strong>${escapeHtml(member.name || "-")}</strong>
<em>${escapeHtml(member.rank || member.department || "-")}</em>
</span>
${badge}
</button>
`;
}
function renderSeatMapBoard() {
if (!seatMapBoard || !seatMapState.seatMap) return;
@@ -643,6 +826,10 @@ function getSeatAssignmentPayload() {
function syncSeatMapViewerFrame() {
const frame = seatMapBoard?.querySelector("#seatmap-dxf-frame");
if (!frame?.contentWindow) return;
frame.contentWindow.postMessage(
{ type: "seatmap-set-mode", mode: isSeatMapAdminMode() ? "default" : "readonly" },
window.location.origin,
);
frame.contentWindow.postMessage(
{ type: "seatmap-set-placed", keys: getDraftPlacedSlotKeys() },
window.location.origin,
@@ -653,14 +840,23 @@ function syncSeatMapViewerFrame() {
);
}
function updateSeatMapDraftUi() {
function renderSeatMapActions() {
const hasSeatMap = Boolean(seatMapState.seatMap);
const adminMode = isSeatMapAdminMode();
if (seatMapSaveBtn) {
seatMapSaveBtn.hidden = !isAdmin() || !seatMapState.seatMap;
seatMapSaveBtn.hidden = !adminMode || !hasSeatMap;
seatMapSaveBtn.disabled = !seatMapState.dirty;
}
if (seatMapCancelBtn) {
seatMapCancelBtn.hidden = !seatMapState.seatMap;
if (seatMapExitBtn) {
seatMapExitBtn.hidden = !hasSeatMap;
}
if (seatMapActions) {
seatMapActions.hidden = !hasSeatMap;
}
}
function updateSeatMapDraftUi() {
renderSeatMapActions();
renderUnassignedMembers();
syncSeatMapViewerFrame();
}
@@ -700,25 +896,80 @@ function setupSeatMapViewerFrame() {
}, { once: true });
}
function renderUnassignedMembers() {
if (!seatMapUnassigned) return;
const editable = seatMapState.editMode && isAdmin();
const members = getUnassignedMembers();
if (!members.length) {
function renderAdminSeatMapSidebar() {
const unassignedMembers = getUnassignedMembers();
const placedMembers = getPlacedMembers();
if (seatMapSidebarTitle) {
seatMapSidebarTitle.textContent = "전체 인원";
}
if (seatMapSidebarDesc) {
seatMapSidebarDesc.textContent = "미배치 인원은 상단, 배치 완료 인원은 하단에 표시됩니다.";
}
if (!unassignedMembers.length && !placedMembers.length) {
seatMapUnassigned.innerHTML = `
<div class="seatmap-list-empty">
${seatMapState.search ? "검색 결과가 없습니다." : "미배치 인원이 없습니다."}
${seatMapState.search ? "검색 결과가 없습니다." : "표시할 인원이 없습니다."}
</div>
`;
return;
}
seatMapUnassigned.innerHTML = `
<section class="seatmap-member-section">
<div class="seatmap-member-section-head">
<strong>미배치 인원</strong>
<span>${unassignedMembers.length}명</span>
</div>
<div class="seatmap-member-section-list">
${unassignedMembers.length
? unassignedMembers.map((member) => renderUnassignedMemberCard(member, true)).join("")
: '<div class="seatmap-list-empty seatmap-list-empty-inline">미배치 인원이 없습니다.</div>'}
</div>
</section>
<section class="seatmap-member-section">
<div class="seatmap-member-section-head">
<strong>배치 완료</strong>
<span>${placedMembers.length}명</span>
</div>
<div class="seatmap-member-section-list">
${placedMembers.length
? placedMembers.map((member) => renderSeatMapSearchCard(member)).join("")
: '<div class="seatmap-list-empty seatmap-list-empty-inline">배치된 인원이 없습니다.</div>'}
</div>
</section>
`;
}
seatMapUnassigned.innerHTML = members.map((member) => renderUnassignedMemberCard(member, editable)).join("");
function renderReadonlySeatMapSidebar() {
const members = getSidebarMembers();
if (seatMapSidebarTitle) {
seatMapSidebarTitle.textContent = "배치 인원 검색";
}
if (seatMapSidebarDesc) {
seatMapSidebarDesc.textContent = "이름이나 부서를 검색하고 클릭하면 해당 좌석으로 바로 확대 이동합니다.";
}
if (!members.length) {
seatMapUnassigned.innerHTML = `
<div class="seatmap-list-empty">
${seatMapState.search ? "검색 결과가 없습니다." : "배치된 인원이 없습니다."}
</div>
`;
return;
}
seatMapUnassigned.innerHTML = members.map((member) => renderSeatMapSearchCard(member)).join("");
}
function renderUnassignedMembers() {
if (!seatMapUnassigned) return;
if (isSeatMapAdminMode()) {
renderAdminSeatMapSidebar();
return;
}
renderReadonlySeatMapSidebar();
}
function renderSeatMapEmpty() {
if (!seatMapEmpty) return;
const office = getCurrentSeatMapOffice();
if (seatMapState.seatMap) {
seatMapEmpty.classList.add("hidden");
seatMapEmpty.innerHTML = "";
@@ -728,8 +979,8 @@ function renderSeatMapEmpty() {
seatMapEmpty.classList.remove("hidden");
seatMapEmpty.innerHTML = `
<div class="seatmap-empty-card">
<strong>등록된 자리배치도가 없습니다.</strong>
<p>${isAdmin() ? "오른쪽 설정 패널에서 이미지와 그리드를 등록하세요." : "관리자에게 자리배치도 등록을 요청하세요."}</p>
<strong>${escapeHtml(office.label)} 도면이 아직 준비되지 않았습니다.</strong>
<p>${office.ready ? (canEditSeatMap() ? "오른쪽 설정 패널에서 이미지와 그리드를 등록하세요." : "관리자에게 자리배치도 등록을 요청하세요.") : "도면 파일을 추후 연결하면 여기서 바로 전환해 볼 수 있습니다."}</p>
</div>
`;
}
@@ -758,11 +1009,12 @@ function syncSeatMapSettingsForm() {
function renderSeatMap() {
const hasSeatMap = Boolean(seatMapState.seatMap);
const admin = isAdmin();
const admin = isSeatMapAdminMode();
const fixedViewerMap = seatMapState.seatMap?.source_type === "fixed_html";
const office = getCurrentSeatMapOffice();
if (seatMapName) {
seatMapName.textContent = hasSeatMap ? seatMapState.seatMap.name : "자리배치도";
seatMapName.textContent = hasSeatMap ? seatMapState.seatMap.name : office.label;
}
if (seatMapStatus) {
seatMapStatus.textContent = seatMapState.status;
@@ -771,16 +1023,11 @@ function renderSeatMap() {
if (seatMapSettingsPanel) {
seatMapSettingsPanel.classList.toggle("hidden", !admin || fixedViewerMap);
}
if (seatMapSaveBtn) {
seatMapSaveBtn.hidden = !admin || !hasSeatMap;
seatMapSaveBtn.disabled = !seatMapState.dirty;
}
if (seatMapCancelBtn) {
seatMapCancelBtn.hidden = !hasSeatMap;
}
renderSeatMapActions();
if (seatMapSettingsForm) {
seatMapSettingsForm.querySelector("button[type='submit']").textContent = hasSeatMap ? "배치도 저장" : "배치도 생성";
}
renderSeatMapOfficeTabs();
renderSeatMapEmpty();
if (seatMapBoardWrap) {
@@ -797,15 +1044,16 @@ function renderSeatMap() {
function handleEmbeddedNavigationMessage(event) {
const data = event.data;
if (!data || typeof data !== "object") return;
if (data.type === "open-seatmap" && isAdmin()) {
if (data.type === "open-seatmap") {
hideUserPopover();
setActiveView("seatmap");
seatMapState.forceReadOnly = Boolean(data.readOnly);
setActiveView(data.readOnly ? "seatmap-readonly" : "seatmap-admin");
}
if (data.type === "open-organization") {
hideUserPopover();
setActiveView("organization");
}
if (data.type === "seatmap-clear-slot" && isAdmin()) {
if (data.type === "seatmap-clear-slot" && canEditSeatMap()) {
const cleared = clearDraftPlacementBySlotKey(String(data.key || ""));
if (cleared) {
setSeatMapStatus("구성원을 공석으로 이동했습니다. 저장 버튼으로 반영하세요.", "info");
@@ -838,6 +1086,22 @@ async function loadSeatMapData(force = false) {
renderSeatMap();
try {
const office = getCurrentSeatMapOffice();
if (!office.ready) {
const membersPayload = await fetchJson("/api/members");
seatMapState.seatMap = null;
seatMapState.members = Array.isArray(membersPayload.items) ? membersPayload.items : [];
seatMapState.slots = [];
seatMapState.placements = [];
seatMapState.zoom = 1;
seatMapState.hoveredSlotId = null;
seatMapState.editMode = canEditSeatMap();
resetSeatMapDraft();
seatMapState.loaded = true;
setSeatMapStatus(`${office.label} 도면은 아직 등록 전입니다.`, "info");
renderSeatMap();
return;
}
const activePayload = await fetchJson("/api/seat-maps/active");
const activeSeatMap = activePayload.item;
const layoutPayload = await fetchJson(`/api/seat-maps/${activeSeatMap.id}/layout`);
@@ -850,10 +1114,10 @@ async function loadSeatMapData(force = false) {
seatMapState.placements = clonePlacements(layoutPayload.placements || []);
seatMapState.zoom = 1;
seatMapState.hoveredSlotId = null;
seatMapState.editMode = isAdmin();
seatMapState.editMode = canEditSeatMap();
resetSeatMapDraft();
seatMapState.loaded = true;
setSeatMapStatus(isAdmin() ? "구성원을 바로 드래그해서 배치한 뒤 저장하세요." : "자리배치도를 불러왔습니다.", "success");
setSeatMapStatus(canEditSeatMap() ? "구성원을 바로 드래그해서 배치한 뒤 저장하세요." : "자리배치도를 불러왔습니다.", "success");
syncSeatMapSettingsForm();
} catch (error) {
if (error.status === 404) {
@@ -863,7 +1127,7 @@ async function loadSeatMapData(force = false) {
seatMapState.placements = [];
seatMapState.zoom = 1;
seatMapState.hoveredSlotId = null;
seatMapState.editMode = isAdmin();
seatMapState.editMode = canEditSeatMap();
resetSeatMapDraft();
seatMapState.loaded = true;
setSeatMapStatus("활성화된 자리배치도가 없습니다.", "info");
@@ -914,7 +1178,7 @@ async function uploadSeatMapImage(file, name) {
async function submitSeatMapSettings(event) {
event.preventDefault();
if (!isAdmin()) return;
if (!canEditSeatMap()) return;
const name = seatMapFormName?.value?.trim() || "";
const imageFile = seatMapFormImage?.files?.[0] || null;
@@ -965,8 +1229,8 @@ async function saveSeatLayout() {
function cancelSeatMapEdit() {
resetSeatMapDraft();
setSeatMapStatus("", "info");
setActiveView("organization");
setSeatMapStatus("현재까지 수정한 배치를 취소했습니다.", "info");
renderSeatMap();
}
function getDraggedMemberId(event) {
@@ -1017,6 +1281,7 @@ function handleSeatMapListDrop(event) {
function setActiveView(view) {
const previousView = currentView;
currentView = view in viewLabels ? view : "organization";
syncSeatMapDomRefs();
if (currentViewTitle) {
currentViewTitle.textContent = viewLabels[currentView];
}
@@ -1028,17 +1293,22 @@ function setActiveView(view) {
});
const isOrganization = currentView === "organization";
const isSeatMap = currentView === "seatmap";
const isSeatMapAdmin = currentView === "seatmap-admin";
const isSeatMapReadonly = currentView === "seatmap-readonly";
if (organizationStage) {
organizationStage.hidden = !isOrganization;
organizationStage.style.display = isOrganization ? "flex" : "none";
}
if (seatMapStage) {
seatMapStage.hidden = !isSeatMap;
seatMapStage.style.display = isSeatMap ? "flex" : "none";
if (seatMapAdminStage) {
seatMapAdminStage.hidden = !isSeatMapAdmin;
seatMapAdminStage.style.display = isSeatMapAdmin ? "flex" : "none";
}
if (seatMapReadonlyStage) {
seatMapReadonlyStage.hidden = !isSeatMapReadonly;
seatMapReadonlyStage.style.display = isSeatMapReadonly ? "flex" : "none";
}
if (emptyStage) {
const showEmpty = !isOrganization && !isSeatMap;
const showEmpty = !isOrganization && !isSeatMapAdmin && !isSeatMapReadonly;
emptyStage.hidden = !showEmpty;
emptyStage.style.display = showEmpty ? "flex" : "none";
}
@@ -1047,7 +1317,7 @@ function setActiveView(view) {
const frameSrc = organizationFrame.dataset.src || organizationFrame.src;
organizationFrame.src = resolveAppUrl(frameSrc);
}
if (isSeatMap) {
if (isSeatMapAdmin || isSeatMapReadonly) {
loadSeatMapData();
}
}
@@ -1101,7 +1371,7 @@ if (loginForm) {
loginForm.reset();
loginMessage.textContent = "";
renderAuth();
if (currentView === "seatmap") {
if (currentView === "seatmap-admin" || currentView === "seatmap-readonly") {
await loadSeatMapData(true);
}
} catch (error) {
@@ -1133,45 +1403,77 @@ 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();
Object.values(seatMapDom).forEach((dom) => {
dom.officeTabs?.addEventListener("click", (event) => {
const button = event.target.closest("[data-seatmap-office]");
if (!button) return;
const officeKey = button.dataset.seatmapOffice || "";
if (!officeKey || officeKey === seatMapState.officeKey) return;
seatMapState.officeKey = officeKey;
seatMapState.loaded = false;
seatMapState.search = "";
Object.values(seatMapDom).forEach((item) => {
if (item.search) item.search.value = "";
});
loadSeatMapData(true);
});
}
if (seatMapFormImage) {
seatMapFormImage.addEventListener("change", () => {
if (seatMapFileName) {
seatMapFileName.textContent = seatMapFormImage.files?.[0]?.name || "선택된 파일 없음";
dom.settingsForm?.addEventListener("submit", submitSeatMapSettings);
dom.saveBtn?.addEventListener("click", saveSeatLayout);
dom.exitBtn?.addEventListener("click", () => {
if (dom === seatMapDom.admin && seatMapState.dirty) {
const confirmed = window.confirm("저장하지 않고 나가면 저장하지 않은 내용은 사라집니다. 나가시겠습니까?");
if (!confirmed) return;
resetSeatMapDraft();
}
hideUserPopover();
setActiveView("organization");
});
dom.search?.addEventListener("input", () => {
seatMapState.search = dom.search.value || "";
const otherMode = dom === seatMapDom.admin ? seatMapDom.readonly : seatMapDom.admin;
if (otherMode.search) otherMode.search.value = seatMapState.search;
renderUnassignedMembers();
});
dom.unassigned?.addEventListener("click", (event) => {
const button = event.target.closest("[data-member-id]");
if (!button) return;
if (button.classList.contains("seatmap-member-search-card")) {
focusSeatMapMember(Number(button.dataset.memberId));
return;
}
if (canEditSeatMap()) return;
focusSeatMapMember(Number(button.dataset.memberId));
});
dom.unassigned?.addEventListener("dragover", (event) => {
if (!seatMapState.editMode) return;
event.preventDefault();
event.dataTransfer.dropEffect = "move";
});
dom.unassigned?.addEventListener("drop", handleSeatMapListDrop);
});
Object.values(seatMapDom).forEach((dom) => {
dom.formImage?.addEventListener("change", () => {
if (dom.fileName) {
dom.fileName.textContent = dom.formImage.files?.[0]?.name || "선택된 파일 없음";
}
});
}
});
if (seatMapBoard) {
seatMapBoard.addEventListener("wheel", (event) => {
Object.values(seatMapDom).forEach((dom) => {
dom.board?.addEventListener("wheel", (event) => {
if (!isSlotBasedSeatMap()) return;
event.preventDefault();
zoomDxfSeatMapAtPoint(event.clientX, event.clientY, event.deltaY < 0 ? 1.08 : 0.92);
}, { passive: false });
seatMapBoard.addEventListener("click", (event) => {
dom.board?.addEventListener("click", (event) => {
const fitButton = event.target.closest("[data-seatmap-action='fit']");
if (!fitButton) return;
fitDxfSeatMapBoard();
});
seatMapBoard.addEventListener("dragover", (event) => {
dom.board?.addEventListener("dragover", (event) => {
if (!seatMapState.editMode) return;
const target = isSlotBasedSeatMap()
? (event.target.closest(".seatmap-slot") || event.target.closest("#seatmap-dxf-canvas"))
@@ -1180,11 +1482,9 @@ if (seatMapBoard) {
event.preventDefault();
event.dataTransfer.dropEffect = "move";
});
seatMapBoard.addEventListener("drop", handleSeatMapCellDrop);
}
dom.board?.addEventListener("drop", handleSeatMapCellDrop);
if (seatMapBoardWrap) {
seatMapBoardWrap.addEventListener("mousedown", (event) => {
dom.boardWrap?.addEventListener("mousedown", (event) => {
if (!isSlotBasedSeatMap()) return;
if (seatMapBoard?.querySelector("#seatmap-dxf-canvas")) return;
if (event.button !== 0) return;
@@ -1197,13 +1497,13 @@ if (seatMapBoardWrap) {
seatMapState.panScrollTop = seatMapBoardWrap.scrollTop;
seatMapBoardWrap.classList.add("is-panning");
});
seatMapBoardWrap.addEventListener("mouseleave", () => {
dom.boardWrap?.addEventListener("mouseleave", () => {
if (seatMapBoard?.querySelector("#seatmap-dxf-canvas")) return;
if (seatMapState.hoveredSlotId == null) return;
seatMapState.hoveredSlotId = null;
updateSeatMapViewerHoverChip();
});
seatMapBoardWrap.addEventListener("mousemove", (event) => {
dom.boardWrap?.addEventListener("mousemove", (event) => {
if (!isSlotBasedSeatMap()) return;
if (seatMapBoard?.querySelector("#seatmap-dxf-canvas")) return;
const slot = event.target.closest(".seatmap-slot");
@@ -1212,7 +1512,7 @@ if (seatMapBoardWrap) {
seatMapState.hoveredSlotId = nextSlotId;
updateSeatMapViewerHoverChip();
});
}
});
document.addEventListener("mousemove", (event) => {
if (!seatMapState.panning || !seatMapBoardWrap) return;
@@ -1228,14 +1528,6 @@ document.addEventListener("mouseup", () => {
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");
@@ -1261,7 +1553,7 @@ setActiveView(currentView);
renderAuth();
window.addEventListener("resize", () => {
if (!isSlotBasedSeatMap() || currentView !== "seatmap") return;
if (!isSlotBasedSeatMap() || (currentView !== "seatmap-admin" && currentView !== "seatmap-readonly")) return;
requestAnimationFrame(() => {
if (seatMapState.zoom === 1) {
centerSeatMapBoard();

View File

@@ -68,62 +68,102 @@
<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>
<section id="seatmap-admin-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>
<h3 id="seatmap-admin-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 id="seatmap-admin-office-tabs" class="seatmap-office-tabs"></div>
<div class="seatmap-actions" id="seatmap-admin-actions">
<button id="seatmap-admin-exit-btn" class="ghost-button ghost-button-soft" type="button" hidden>나가기</button>
<button id="seatmap-admin-save-btn" class="ghost-button" type="button" hidden disabled>저장</button>
</div>
</div>
<p id="seatmap-status" class="seatmap-status" role="status"></p>
<p id="seatmap-admin-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 id="seatmap-admin-empty" class="seatmap-empty hidden"></div>
<div id="seatmap-admin-board-wrap" class="seatmap-board-wrap hidden">
<div id="seatmap-admin-board" class="seatmap-board"></div>
</div>
</div>
<aside class="seatmap-sidebar">
<section id="seatmap-settings-panel" class="seatmap-panel hidden">
<section id="seatmap-admin-settings-panel" class="seatmap-panel hidden">
<div class="seatmap-panel-head">
<h4>도면 설정</h4>
<p>현재는 기술개발센터 고정 도면을 사용합니다.</p>
</div>
<form id="seatmap-settings-form" class="seatmap-form">
<form id="seatmap-admin-settings-form" class="seatmap-form">
<label>
<span>도면 이름</span>
<input id="seatmap-form-name" name="name" type="text" placeholder="예: 기술개발센터" required>
<input id="seatmap-admin-form-name" name="name" type="text" placeholder="예: 기술개발센터" 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>
<label class="seatmap-file-input" for="seatmap-admin-form-image">
<input id="seatmap-admin-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>
<strong id="seatmap-admin-file-name" class="seatmap-file-name">선택된 파일 없음</strong>
</label>
</div>
<button id="seatmap-settings-submit" type="submit">DXF 업로드</button>
<button id="seatmap-admin-settings-submit" type="submit">DXF 업로드</button>
</form>
</section>
<section class="seatmap-panel">
<div class="seatmap-panel-head">
<h4>미배치 인원</h4>
<p>이름을 검색하고 자리배치도에 바로 드래그하세요.</p>
<h4 id="seatmap-admin-sidebar-title">전체 인원</h4>
<p id="seatmap-admin-sidebar-desc">미배치 인원은 상단, 배치 완료 인원은 하단에 표시됩니다.</p>
</div>
<label class="seatmap-search">
<span class="hidden">구성원 검색</span>
<input id="seatmap-search" type="search" placeholder="이름 또는 부서 검색">
<input id="seatmap-admin-search" type="search" placeholder="이름 또는 부서 검색">
</label>
<div id="seatmap-unassigned" class="seatmap-member-list"></div>
<div id="seatmap-admin-unassigned" class="seatmap-member-list"></div>
</section>
</aside>
</div>
</div>
</section>
<section id="seatmap-readonly-stage" class="main-stage" hidden>
<div class="seatmap-layout">
<div class="seatmap-topbar">
<div>
<p class="eyebrow">Seat Layout</p>
<h3 id="seatmap-readonly-name">자리배치도</h3>
</div>
<div id="seatmap-readonly-office-tabs" class="seatmap-office-tabs"></div>
<div class="seatmap-actions" id="seatmap-readonly-actions">
<button id="seatmap-readonly-exit-btn" class="ghost-button ghost-button-soft" type="button" hidden>나가기</button>
</div>
</div>
<p id="seatmap-readonly-status" class="seatmap-status" role="status"></p>
<div class="seatmap-content">
<div class="seatmap-board-panel">
<div id="seatmap-readonly-empty" class="seatmap-empty hidden"></div>
<div id="seatmap-readonly-board-wrap" class="seatmap-board-wrap hidden">
<div id="seatmap-readonly-board" class="seatmap-board"></div>
</div>
</div>
<aside class="seatmap-sidebar">
<section class="seatmap-panel">
<div class="seatmap-panel-head">
<h4 id="seatmap-readonly-sidebar-title">배치 인원 검색</h4>
<p id="seatmap-readonly-sidebar-desc">이름이나 부서를 검색하고 클릭하면 해당 좌석으로 바로 확대 이동합니다.</p>
</div>
<label class="seatmap-search">
<span class="hidden">구성원 검색</span>
<input id="seatmap-readonly-search" type="search" placeholder="이름 또는 부서 검색">
</label>
<div id="seatmap-readonly-unassigned" class="seatmap-member-list"></div>
</section>
</aside>
</div>

View File

@@ -1,3 +1,19 @@
.dashboard-shell,
.dashboard-main,
.main-stage,
.seatmap-layout,
.seatmap-content,
.seatmap-board-panel,
.seatmap-sidebar {
min-height: 0;
}
html,
body {
height: 100%;
overflow: hidden;
}
.hidden {
display: none !important;
}
@@ -143,9 +159,10 @@
}
.dashboard-shell {
min-height: 100vh;
height: 100dvh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.dashboard-header {
@@ -371,14 +388,16 @@
.dashboard-main {
flex: 1;
min-height: calc(100vh - 68px);
height: calc(100dvh - 68px);
padding: 0;
overflow: hidden;
}
.main-stage {
height: calc(100vh - 68px);
height: calc(100dvh - 68px);
display: flex;
flex-direction: column;
overflow: hidden;
}
.stage-frame {
@@ -407,18 +426,45 @@
flex-direction: column;
gap: 12px;
padding: 18px;
overflow: hidden;
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;
display: grid;
grid-template-columns: minmax(180px, 1fr) auto minmax(180px, 1fr);
align-items: end;
gap: 16px;
}
.seatmap-office-tabs {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
grid-column: 2;
}
.seatmap-office-tab {
min-height: 38px;
padding: 0 14px;
border-radius: 999px;
border: 1px solid rgba(148, 163, 184, 0.26);
background: rgba(255, 255, 255, 0.92);
color: #475569;
font-size: 12px;
font-weight: 800;
cursor: pointer;
}
.seatmap-office-tab.active {
border-color: rgba(15, 118, 110, 0.22);
background: rgba(15, 118, 110, 0.1);
color: #0f766e;
}
.seatmap-topbar h3,
.seatmap-panel-head h4 {
margin: 0;
@@ -432,6 +478,12 @@
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
grid-column: 3;
}
.seatmap-actions[hidden] {
display: none !important;
}
.seatmap-status {
@@ -456,6 +508,7 @@
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
gap: 16px;
overflow: hidden;
}
.seatmap-board-panel,
@@ -468,6 +521,7 @@
.seatmap-board-panel {
min-height: 0;
height: 100%;
overflow: hidden;
padding: 0;
}
@@ -475,7 +529,7 @@
.seatmap-board-wrap {
width: 100%;
height: 100%;
overflow: auto;
overflow: hidden;
border-radius: 24px;
background: #ffffff;
padding: 0;
@@ -490,6 +544,7 @@
.seatmap-board {
min-width: 100%;
height: 100%;
overflow: hidden;
}
.seatmap-dxf-canvas {
@@ -506,15 +561,16 @@
.seatmap-dxf-frame-shell {
width: 100%;
height: 100%;
min-height: 720px;
min-height: 0;
background: #ffffff;
overflow: hidden;
}
.seatmap-dxf-frame {
display: block;
width: 100%;
height: 100%;
min-height: 720px;
min-height: 0;
border: 0;
background: #ffffff;
}
@@ -924,11 +980,78 @@
padding-right: 4px;
}
.seatmap-member-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.seatmap-member-section + .seatmap-member-section {
margin-top: 6px;
}
.seatmap-member-section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.seatmap-member-section-head strong {
font-size: 12px;
font-weight: 900;
color: #0f172a;
}
.seatmap-member-section-head span {
color: #64748b;
font-size: 11px;
font-weight: 800;
}
.seatmap-member-section-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.seatmap-member-list .seatmap-member-card {
position: relative;
inset: auto;
}
.seatmap-member-search-card {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border: 1px solid rgba(226, 232, 240, 0.9);
border-radius: 16px;
background: #fff;
cursor: pointer;
text-align: left;
}
.seatmap-member-badge {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 0 10px;
border-radius: 999px;
background: #e2e8f0;
color: #475569;
font-size: 11px;
font-weight: 900;
}
.seatmap-member-badge.occupied {
background: rgba(220, 38, 38, 0.12);
color: #b91c1c;
}
.seatmap-member-card-compact {
position: relative;
inset: auto;
@@ -1005,6 +1128,11 @@
margin-bottom: 8px;
}
.seatmap-list-empty-inline {
min-height: 64px;
padding: 12px;
}
@media (max-width: 1180px) {
.dashboard-header {
flex-wrap: wrap;
@@ -1062,8 +1190,13 @@
}
.seatmap-topbar {
grid-template-columns: 1fr;
align-items: flex-start;
flex-direction: column;
}
.seatmap-office-tabs {
grid-column: 1;
justify-content: center;
}
.seatmap-dxf-canvas {
@@ -1076,7 +1209,7 @@
.seatmap-dxf-frame-shell,
.seatmap-dxf-frame {
min-height: 620px;
min-height: 0;
}
}