feat: add seatmap context panel and smoke checks
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user