feat: add seatmap context panel and smoke checks

This commit is contained in:
hyunho
2026-04-01 18:01:59 +09:00
parent 19c8c6ade1
commit a4480c3435
10 changed files with 528 additions and 26 deletions

View File

@@ -50,6 +50,7 @@ const seatMapDom = {
formGap: null,
formImage: document.getElementById("seatmap-admin-form-image"),
search: document.getElementById("seatmap-admin-search"),
context: document.getElementById("seatmap-admin-context"),
unassigned: document.getElementById("seatmap-admin-unassigned"),
officeTabs: document.getElementById("seatmap-admin-office-tabs"),
sidebarTitle: document.getElementById("seatmap-admin-sidebar-title"),
@@ -75,6 +76,7 @@ const seatMapDom = {
formGap: null,
formImage: null,
search: document.getElementById("seatmap-readonly-search"),
context: document.getElementById("seatmap-readonly-context"),
unassigned: document.getElementById("seatmap-readonly-unassigned"),
officeTabs: document.getElementById("seatmap-readonly-office-tabs"),
sidebarTitle: document.getElementById("seatmap-readonly-sidebar-title"),
@@ -100,6 +102,7 @@ let seatMapFormCols = seatMapDom.admin.formCols;
let seatMapFormGap = seatMapDom.admin.formGap;
let seatMapFormImage = seatMapDom.admin.formImage;
let seatMapSearch = seatMapDom.admin.search;
let seatMapContext = seatMapDom.admin.context;
let seatMapUnassigned = seatMapDom.admin.unassigned;
let seatMapOfficeTabs = seatMapDom.admin.officeTabs;
let seatMapSidebarTitle = seatMapDom.admin.sidebarTitle;
@@ -135,6 +138,7 @@ const seatMapState = {
search: "",
status: "",
statusTone: "info",
selectedMemberId: null,
draggingMemberId: null,
zoom: 1,
panning: false,
@@ -491,6 +495,7 @@ function syncSeatMapDomRefs() {
seatMapFormGap = dom.formGap;
seatMapFormImage = dom.formImage;
seatMapSearch = dom.search;
seatMapContext = dom.context;
seatMapUnassigned = dom.unassigned;
seatMapOfficeTabs = dom.officeTabs;
seatMapSidebarTitle = dom.sidebarTitle;
@@ -837,6 +842,98 @@ function getMemberMap() {
return new Map(seatMapState.members.map((member) => [Number(member.id), member]));
}
function buildSeatMapTeamTone(teamName) {
const team = String(teamName || "").trim();
if (!team) {
return {
accent: "rgba(120, 132, 119, 0.86)",
soft: "rgba(120, 132, 119, 0.18)",
label: "미분류",
};
}
const palette = [
{ accent: "rgba(13, 148, 136, 0.9)", soft: "rgba(13, 148, 136, 0.18)" },
{ accent: "rgba(202, 138, 4, 0.9)", soft: "rgba(202, 138, 4, 0.18)" },
{ accent: "rgba(5, 150, 105, 0.9)", soft: "rgba(5, 150, 105, 0.18)" },
{ accent: "rgba(180, 83, 9, 0.9)", soft: "rgba(180, 83, 9, 0.18)" },
{ accent: "rgba(20, 83, 45, 0.9)", soft: "rgba(20, 83, 45, 0.16)" },
{ accent: "rgba(11, 110, 79, 0.9)", soft: "rgba(11, 110, 79, 0.18)" },
];
let hash = 0;
for (const char of team) {
hash = ((hash * 31) + char.charCodeAt(0)) % 2147483647;
}
const picked = palette[Math.abs(hash) % palette.length];
return { ...picked, label: team };
}
function getSeatMapTeamStyle(member) {
const tone = buildSeatMapTeamTone(getSeatMapOrgUnitLabel(member));
return `--seatmap-team-accent:${tone.accent}; --seatmap-team-soft:${tone.soft};`;
}
function renderSeatMapTeamChip(member) {
const label = getSeatMapOrgUnitLabel(member);
const tone = buildSeatMapTeamTone(label);
return `<span class="seatmap-team-chip" style="${escapeHtml(getSeatMapTeamStyle({ team: label }))}">${escapeHtml(label)}</span>`;
}
function getSeatMapOrgPath(member) {
const values = [
member?.department,
member?.grp,
member?.division,
member?.team,
member?.cell,
]
.map((value) => String(value || "").trim())
.filter(Boolean);
return values.filter((value, index) => values.indexOf(value) === index);
}
function getSeatMapOrgUnitLabel(member) {
return String(
member?.team
|| member?.division
|| member?.grp
|| member?.department
|| member?.cell
|| "미분류"
).trim() || "미분류";
}
function getSeatMapOverlayTeamLabel(member) {
return String(member?.team || "").trim();
}
function renderSeatMapMemberContext(memberId) {
if (!seatMapContext) return;
const member = memberId ? getMemberMap().get(Number(memberId)) : null;
seatMapState.selectedMemberId = member ? Number(member.id) : null;
if (!member) {
seatMapContext.classList.add("hidden");
seatMapContext.innerHTML = "";
return;
}
const tone = buildSeatMapTeamTone(member.team);
const orgPath = getSeatMapOrgPath(member);
seatMapContext.classList.remove("hidden");
seatMapContext.innerHTML = `
<div class="seatmap-context-head">
<div class="seatmap-context-title">
<strong>${escapeHtml(member.name || "-")}</strong>
<span>${escapeHtml(member.rank || member.role || "구성원")}</span>
</div>
<span class="seatmap-context-badge" style="${escapeHtml(getSeatMapTeamStyle(member))}">${escapeHtml(tone.label)}</span>
</div>
<div class="seatmap-context-tree">
${orgPath.length
? orgPath.map((item) => `<span class="seatmap-context-node">${escapeHtml(item)}</span>`).join("")
: '<div class="seatmap-context-empty">표시할 상위 조직 정보가 없습니다.</div>'}
</div>
`;
}
function getPlacementForMember(memberId) {
return getPlacementSource().find((item) => Number(item.member_id) === Number(memberId)) || null;
}
@@ -871,9 +968,13 @@ function getSidebarMembers() {
return members.filter(memberMatchesSeatMapSearch);
}
function focusSeatMapMember(memberId) {
function focusSeatMapMember(memberId, options = {}) {
const showContext = options.showContext !== false;
const placement = getPlacementForMember(memberId);
const member = getMemberMap().get(Number(memberId));
if (showContext) {
renderSeatMapMemberContext(memberId);
}
if (!placement) {
setSeatMapStatus("해당 인원은 아직 배치되지 않았습니다.", "info");
return;
@@ -943,6 +1044,46 @@ function getSlotPlacementMap() {
return slotMap;
}
function buildSeatMapGridTeamOverlays(rows, cols, placementMap, memberMap) {
const groups = new Map();
placementMap.forEach((placement) => {
const member = memberMap.get(Number(placement.member_id));
if (!member) return;
const label = getSeatMapOverlayTeamLabel(member);
if (!label) return;
const rowIndex = Number(placement.row_index);
const colIndex = Number(placement.col_index);
if (!Number.isFinite(rowIndex) || !Number.isFinite(colIndex)) return;
if (!groups.has(label)) {
groups.set(label, {
label,
toneMember: { team: label },
minRow: rowIndex,
maxRow: rowIndex,
minCol: colIndex,
maxCol: colIndex,
count: 1,
});
return;
}
const group = groups.get(label);
group.minRow = Math.min(group.minRow, rowIndex);
group.maxRow = Math.max(group.maxRow, rowIndex);
group.minCol = Math.min(group.minCol, colIndex);
group.maxCol = Math.max(group.maxCol, colIndex);
group.count += 1;
});
return Array.from(groups.values())
.filter((group) => group.count >= 2 && group.maxRow < rows && group.maxCol < cols)
.map((group) => `
<div
class="seatmap-team-overlay"
data-team-label="${escapeHtml(group.label)}"
style="${escapeHtml(getSeatMapTeamStyle(group.toneMember))}; grid-column:${group.minCol + 1} / ${group.maxCol + 2}; grid-row:${group.minRow + 1} / ${group.maxRow + 2};"
></div>
`);
}
function upsertDraftPlacement(memberId, rowIndex, colIndex) {
const cellMap = getCellPlacementMap();
const existing = cellMap.get(`${rowIndex}:${colIndex}`);
@@ -1003,11 +1144,12 @@ function renderMemberCard(member, draggable) {
? `<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)}">
<div class="seatmap-member-card team-colored${draggable ? " draggable" : ""}" style="${escapeHtml(getSeatMapTeamStyle(member))}" 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>
<em>${escapeHtml(member.rank || "-")}</em>
<span class="seatmap-team-chip" style="${escapeHtml(getSeatMapTeamStyle({ team: getSeatMapOrgUnitLabel(member) }))}">${escapeHtml(getSeatMapOrgUnitLabel(member))}</span>
</span>
</div>
`;
@@ -1015,11 +1157,12 @@ function renderMemberCard(member, draggable) {
function renderUnassignedMemberCard(member, draggable) {
return `
<div class="seatmap-member-card seatmap-member-card-compact${draggable ? " draggable" : ""}" draggable="${draggable}" data-member-id="${Number(member.id)}">
<div class="seatmap-member-card seatmap-member-card-compact${draggable ? " draggable" : ""}" style="${escapeHtml(getSeatMapTeamStyle(member))}" 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>
${renderSeatMapTeamChip(member)}
</div>
`;
}
@@ -1027,14 +1170,13 @@ 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>
<em>${escapeHtml(member.rank || "-")}</em>
</span>
${badge}
${renderSeatMapTeamChip(member)}
</button>
`;
}
@@ -1054,14 +1196,16 @@ function renderSeatMapBoard() {
const gap = Number(seatMapState.seatMap.cell_gap || 0);
const editable = seatMapState.editMode && isAdmin();
const cells = [];
const overlays = buildSeatMapGridTeamOverlays(rows, cols, placementMap, memberMap);
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;
const teamStyle = member ? ` style="${escapeHtml(getSeatMapTeamStyle(member))}"` : "";
cells.push(`
<div class="seatmap-cell${placement ? " occupied" : ""}${editable ? " editable" : ""}" data-row="${rowIndex}" data-col="${colIndex}">
<div class="seatmap-cell${placement ? " occupied" : ""}${member ? " team-colored" : ""}${editable ? " editable" : ""}" data-row="${rowIndex}" data-col="${colIndex}"${teamStyle}>
<span class="seatmap-cell-label">${escapeHtml(computeSeatLabel(rowIndex, colIndex))}</span>
${member ? renderMemberCard(member, editable) : ""}
</div>
@@ -1072,7 +1216,7 @@ function renderSeatMapBoard() {
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 class="seatmap-grid">${overlays.join("")}${cells.join("")}</div>
</div>
`;
}
@@ -1175,6 +1319,7 @@ function renderSeatMapActions() {
function updateSeatMapDraftUi() {
renderSeatMapActions();
renderUnassignedMembers();
renderSeatMapMemberContext(seatMapState.selectedMemberId);
syncSeatMapViewerFrame();
}
@@ -1394,6 +1539,9 @@ function handleEmbeddedNavigationMessage(event) {
updateSeatMapDraftUi();
}
}
if (data.type === "seatmap-member-selected") {
renderSeatMapMemberContext(Number(data.memberId || 0) || null);
}
}
async function fetchJson(url, options) {
@@ -1437,6 +1585,7 @@ async function loadSeatMapData(force = false) {
seatMapState.placements = clonePlacements(layoutPayload.placements || []);
seatMapState.zoom = 1;
seatMapState.hoveredSlotId = null;
seatMapState.selectedMemberId = null;
seatMapState.editMode = canEditSeatMap();
resetSeatMapDraft();
seatMapState.loaded = true;
@@ -1450,6 +1599,7 @@ async function loadSeatMapData(force = false) {
seatMapState.placements = [];
seatMapState.zoom = 1;
seatMapState.hoveredSlotId = null;
seatMapState.selectedMemberId = null;
seatMapState.editMode = canEditSeatMap();
resetSeatMapDraft();
seatMapState.loaded = true;
@@ -1690,7 +1840,7 @@ function setActiveView(view) {
dbStatusFrame.src = resolveAppUrl(frameSrc);
}
if (isSeatMapAdmin || isSeatMapReadonly) {
loadSeatMapData();
loadSeatMapData(previousView !== currentView);
}
notifyEmbeddedTabActivated();
}
@@ -1886,14 +2036,15 @@ Object.values(seatMapDom).forEach((dom) => {
});
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));
const target = event.target.closest("[data-member-id]");
if (!target) return;
if (target.classList.contains("seatmap-member-search-card")) {
focusSeatMapMember(Number(target.dataset.memberId), { showContext: false });
return;
}
renderSeatMapMemberContext(Number(target.dataset.memberId));
if (canEditSeatMap()) return;
focusSeatMapMember(Number(button.dataset.memberId));
focusSeatMapMember(Number(target.dataset.memberId));
});
dom.unassigned?.addEventListener("dragover", (event) => {
if (!seatMapState.editMode) return;
@@ -1922,6 +2073,17 @@ Object.values(seatMapDom).forEach((dom) => {
if (!fitButton) return;
fitDxfSeatMapBoard();
});
dom.board?.addEventListener("click", (event) => {
const memberCard = event.target.closest(".seatmap-member-card[data-member-id]");
if (memberCard) {
renderSeatMapMemberContext(Number(memberCard.dataset.memberId));
return;
}
const cell = event.target.closest(".seatmap-cell[data-row][data-col]");
if (!cell) return;
const placement = getCellPlacementMap().get(`${Number(cell.dataset.row)}:${Number(cell.dataset.col)}`);
renderSeatMapMemberContext(placement ? Number(placement.member_id) : null);
});
dom.board?.addEventListener("dragover", (event) => {
if (!seatMapState.editMode) return;
const target = isSlotBasedSeatMap()