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

@@ -3,6 +3,7 @@ from __future__ import annotations
import hashlib
import json
from pathlib import Path
from urllib.parse import quote
from fastapi import HTTPException
from fastapi.responses import FileResponse, Response
@@ -88,7 +89,7 @@ def build_business_ledger_default_response(cur) -> Response:
headers = {
"Content-Disposition": 'inline; filename="business-ledger-default.xlsx"',
"X-Source-Filename": "business-ledger-default.xlsx",
"X-Original-Filename": filename,
"X-Original-Filename": quote(filename),
"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
"Pragma": "no-cache",
}

View File

@@ -1663,6 +1663,11 @@ def build_center_chair_viewer_html(layout: dict[str, object]) -> str:
"member_id": int(member["id"]),
"name": str(member.get("name") or "-"),
"rank": str(member.get("rank") or "-"),
"team": str(member.get("team") or ""),
"department": str(member.get("department") or ""),
"grp": str(member.get("grp") or ""),
"division": str(member.get("division") or ""),
"cell": str(member.get("cell") or ""),
}
)
@@ -1844,6 +1849,40 @@ def build_center_chair_viewer_html(layout: dict[str, object]) -> str:
return seatAssignments.get(String(key)) || null;
}
function getTeamTone(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;
}
return { ...palette[Math.abs(hash) % palette.length], label: team };
}
function getAssignmentGroupLabel(assignment) {
return String((assignment && assignment.team) || "").trim();
}
function notifyParentSelection(assignment) {
window.parent.postMessage(
{
type: "seatmap-member-selected",
memberId: assignment ? Number(assignment.memberId || 0) : null,
},
window.location.origin,
);
}
function hideSeatPopup() {
popup.hidden = true;
popup.innerHTML = "";
@@ -1870,6 +1909,7 @@ def build_center_chair_viewer_html(layout: dict[str, object]) -> str:
popup.innerHTML = `
<strong>${assignment.name}</strong>
<div>직급: ${assignment.rank || "-"}</div>
<div>팀: ${assignment.team || "미분류"}</div>
<div>상태: 배치완료</div>
${viewerMode === "default" ? `<button type="button" data-seatmap-delete="${chairKey}">자리 비우기</button>` : ""}
`;
@@ -1908,6 +1948,11 @@ def build_center_chair_viewer_html(layout: dict[str, object]) -> str:
key,
name: item.name || "-",
rank: item.rank || "-",
team: item.team || "",
department: item.department || "",
grp: item.grp || "",
division: item.division || "",
cell: item.cell || "",
memberId: Number(item.member_id || 0),
};
seatAssignments.set(key, assignment);
@@ -1974,11 +2019,67 @@ def build_center_chair_viewer_html(layout: dict[str, object]) -> str:
focusedChairPulseUntil = 0;
}
if (!seatAssignments.size) return;
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
ctx.textBaseline = "middle";
const groupRects = new Map();
for (const chair of chairGeometry) {
const assignment = getAssignment(chair.key);
if (!assignment) continue;
const label = getAssignmentGroupLabel(assignment);
if (!groupRects.has(label)) {
groupRects.set(label, {
label,
tone: getTeamTone(label),
minX: chair.minX,
maxX: chair.maxX,
minY: chair.minY,
maxY: chair.maxY,
count: 1,
});
continue;
}
const group = groupRects.get(label);
group.minX = Math.min(group.minX, chair.minX);
group.maxX = Math.max(group.maxX, chair.maxX);
group.minY = Math.min(group.minY, chair.minY);
group.maxY = Math.max(group.maxY, chair.maxY);
group.count += 1;
}
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
ctx.textBaseline = "middle";
for (const group of groupRects.values()) {
if (!group.label || group.count < 2) continue;
const topLeft = worldToScreen(group.minX, group.maxY);
const bottomRight = worldToScreen(group.maxX, group.minY);
const pad = Math.max(26, 24 + camera.scale * 2200);
const boxX = Math.min(topLeft.x, bottomRight.x) - pad;
const boxY = Math.min(topLeft.y, bottomRight.y) - pad;
const boxWidth = Math.abs(bottomRight.x - topLeft.x) + pad * 2;
const boxHeight = Math.abs(bottomRight.y - topLeft.y) + pad * 2;
ctx.save();
const softFill = group.tone.soft
.replace("0.16", "0.24")
.replace("0.18", "0.28");
ctx.fillStyle = softFill;
ctx.strokeStyle = group.tone.accent;
ctx.lineWidth = 2.6;
ctx.shadowColor = group.tone.accent.replace("0.9", "0.18");
ctx.shadowBlur = 10;
ctx.beginPath();
ctx.roundRect(boxX, boxY, boxWidth, boxHeight, 22);
ctx.fill();
ctx.stroke();
ctx.fillStyle = "rgba(255,255,255,0.96)";
ctx.beginPath();
ctx.roundRect(boxX + 14, boxY + 14, Math.max(72, group.label.length * 12), 28, 999);
ctx.fill();
ctx.fillStyle = group.tone.accent;
ctx.font = "900 12px Pretendard, sans-serif";
ctx.fillText(group.label, boxX + 26, boxY + 28);
ctx.restore();
}
for (const chair of chairGeometry) {
const assignment = getAssignment(chair.key);
if (!assignment) continue;
const tone = getTeamTone(assignment.team);
const center = worldToScreen((chair.minX + chair.maxX) / 2, (chair.minY + chair.maxY) / 2);
if (center.x < -120 || center.x > rect.width + 120 || center.y < -50 || center.y > rect.height + 50) continue;
const primary = `${assignment.name}`;
@@ -1992,7 +2093,7 @@ def build_center_chair_viewer_html(layout: dict[str, object]) -> str:
const boxX = center.x - boxWidth / 2;
const boxY = center.y - 46;
ctx.fillStyle = "rgba(255,255,255,0.96)";
ctx.strokeStyle = "rgba(220,38,38,0.18)";
ctx.strokeStyle = tone.accent;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.roundRect(boxX, boxY, boxWidth, boxHeight, 10);
@@ -2001,7 +2102,7 @@ def build_center_chair_viewer_html(layout: dict[str, object]) -> str:
ctx.fillStyle = "#111827";
ctx.font = "700 12px Pretendard, sans-serif";
ctx.fillText(primary, boxX + 10, boxY + 12);
ctx.fillStyle = "#6b7280";
ctx.fillStyle = tone.accent;
ctx.font = "600 10px Pretendard, sans-serif";
ctx.fillText(secondary, boxX + 10, boxY + 25);
}
@@ -2032,12 +2133,18 @@ def build_center_chair_viewer_html(layout: dict[str, object]) -> str:
if (!picked) {
selectedChairKey = null;
hideSeatPopup();
notifyParentSelection(null);
if (typeof requestDraw === "function") requestDraw();
return;
}
selectedChairKey = seatAssignments.has(String(picked.key)) ? String(picked.key) : null;
if (selectedChairKey) showSeatPopup(selectedChairKey, event.clientX - rect.left, event.clientY - rect.top);
else hideSeatPopup();
if (selectedChairKey) {
showSeatPopup(selectedChairKey, event.clientX - rect.left, event.clientY - rect.top);
notifyParentSelection(getAssignment(selectedChairKey));
} else {
hideSeatPopup();
notifyParentSelection(null);
}
if (typeof requestDraw === "function") requestDraw();
});