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();
});

View File

@@ -132,11 +132,33 @@
## Open Issues Relevant Now
- `#2` 백엔드 영속 저장 구조 운영 마무리
- `#7` 자리배치도 팀별 구역 색상 오버레이
- `#8` 자리배치도 좌석 클릭 시 개인 상위 조직 트리 표시
- `#14` 누적된 임시 로직 정리 및 중복 코드 제거
- `#16` 사업관리대장 메인 후속 정리 및 기준 분석
- `#19` 8081 백엔드 라우터/서빙 deeper 모듈 분리
- `#21` organization 레거시 구조 승격 및 장기 고도화
## Current Seatmap Work Note
- `#8`:
- 자리배치도 우측 패널에 선택 인원 상위 조직 트리 표시 로직을 추가했다.
- 좌석 클릭 / 구성원 카드 클릭 / fixed viewer 선택 이벤트가 부모 화면으로 올라오도록 연결했다.
- 검색 카드 클릭은 상단 패널을 띄우지 않고 좌석 포커스만 하도록 분리했다.
- `#7`:
- 해석 기준은 `개별 좌석 색칠`이 아니라 `팀이 모여 있는 좌석 군집을 하나의 구역처럼 보여주는 오버레이`다.
- grid 배치도와 fixed/DXF viewer 양쪽에 팀 구역 오버레이 코드를 넣었다.
- 현재 증상은 `처음 반짝 보였다가 사라지는 현상`이며, 데이터 부재가 아니라 viewer draw/layer 타이밍 문제로 보인다.
- 다음 작업 시작 시 `#7`을 먼저 재확인한다.
- 자리배치도 카드 텍스트 규칙:
- `이름 - 직급 - 팀(또는 다음 조직 fallback)` 순서
- 검색 목록 카드에는 `chair-00` 같은 좌석 배지를 노출하지 않음
- 미배치 인원 카드는 외곽선만 사용하고 내부 채움색은 사용하지 않음
- 사업관리대장 기본 원본 API 오류:
- 원인은 DB가 아니라 `X-Original-Filename` 한글 헤더 인코딩 오류였음
- [backend/app/ledger_runtime.py](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/backend/app/ledger_runtime.py) 에서 URL-encoded 헤더로 수정
- `./scripts/check_8081_smoke.sh``ledger-default-api` 체크를 넣음
## Recommended Next Work Order
1. `#2` 기준으로 DB 상태 화면과 저장 구조 검증 흐름 고도화
@@ -156,3 +178,4 @@
- `#2` 기준 DB 상태 확인은 `/api/admin/db-status` 또는 허브의 `DB 상태` 탭(`/db-status.html`)을 먼저 본다.
- DB 테이블 유지/주의/원본·추적/정리 후보 분류는 [architecture/DB_TABLE_CATALOG.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/architecture/DB_TABLE_CATALOG.md) 기준으로 본다.
- 작업 전 `git status`, dev 컨테이너 상태, `/api/health`, `/legacy/organization`, `/integrations/payment`, `/integrations/ledger`, `/integrations/mh`, `/api/admin/db-status`를 먼저 확인
- 구조 정리나 라우트 변경 직후에는 `./scripts/check_8081_smoke.sh` 를 먼저 실행해 `ledger-default-api` 까지 확인

View File

@@ -27,6 +27,7 @@
- `docker compose ps`에서 `backend`, `frontend`, `proxy`, `db`가 정상인지 확인
- `8081`은 기본적으로 `./scripts/start_8081.sh` 또는 `./scripts/prepare_dev_worktree.sh``.dev-worktree-8081` 에서 `docker compose -p mh-dashboard-organization-dev --env-file .env -f docker-compose.8081.yml up -d --build` 로 기동
- `8081` 기동 후 `docker inspect mh-dashboard-organization-dev-backend-1`에서 마운트 경로가 `.dev-worktree-8081/...`인지 확인
- 구조 정리, 라우트 분리, 기본 원본 API 변경 후에는 먼저 `./scripts/check_8081_smoke.sh` 를 실행한다
### 2. 데이터 동기화 범위 결정
@@ -52,6 +53,7 @@
- 메인 허브가 정상 렌더링된다.
- 상단 탭 이동이 정상 동작한다.
- 로그인 상태가 비정상적으로 풀리지 않는다.
- `DB 상태` 탭이 정상 렌더링된다.
### B. 조직현황
@@ -100,6 +102,12 @@
- `GPD`, `TDC` 선택 시 각 소속 범위만 버튼 기준으로 보인다.
- 검색은 버튼 상태와 무관하게 전체 데이터를 검색한다.
### G. 사업관리대장 기본 원본
- `/api/integration/business-ledger-default``200` 이어야 한다.
- `사업관리대장` 탭 진입 시 기본 원본이 비어 있지 않다.
- 기본 원본 응답은 바이너리 XLSX 시그니처(`PK`)를 반환해야 한다.
## 작업 유형별 필수 추가 확인
### 조직도 / 관리자모드 수정 시

View File

@@ -158,6 +158,7 @@
- DB 동기화
- 코드/worktree 동기화
- 구조 정리나 서빙 경로 수정 직후에는 `./scripts/check_8081_smoke.sh` 로 핵심 런타임을 먼저 확인
## 6. 실제 실행

View File

@@ -119,6 +119,7 @@
검증 기준 문서:
- `docs/REGRESSION_CHECKLIST.md`
- 구조 정리, 라우트 분리, 기본 원본 API 변경 후에는 `./scripts/check_8081_smoke.sh` 를 먼저 통과시킨다.
## Rule 7. Seat Map Work Is High Risk

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()

View File

@@ -181,6 +181,7 @@
<span class="hidden">구성원 검색</span>
<input id="seatmap-admin-search" type="search" placeholder="이름 또는 부서 검색">
</label>
<div id="seatmap-admin-context" class="seatmap-context-panel hidden"></div>
<div id="seatmap-admin-unassigned" class="seatmap-member-list"></div>
</section>
</aside>
@@ -220,6 +221,7 @@
<span class="hidden">구성원 검색</span>
<input id="seatmap-readonly-search" type="search" placeholder="이름 또는 부서 검색">
</label>
<div id="seatmap-readonly-context" class="seatmap-context-panel hidden"></div>
<div id="seatmap-readonly-unassigned" class="seatmap-member-list"></div>
</section>
</aside>

View File

@@ -913,6 +913,39 @@ body {
padding: var(--seatmap-gap);
}
.seatmap-team-overlay {
pointer-events: none;
align-self: stretch;
justify-self: stretch;
border-radius: 20px;
border: 2px solid color-mix(in srgb, var(--seatmap-team-accent, rgba(13, 148, 136, 0.3)) 62%, white);
background: linear-gradient(
180deg,
color-mix(in srgb, var(--seatmap-team-soft, rgba(13, 148, 136, 0.12)) 100%, transparent),
color-mix(in srgb, var(--seatmap-team-soft, rgba(13, 148, 136, 0.08)) 90%, transparent)
);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.22), 0 8px 18px rgba(16, 37, 29, 0.08);
margin: 4px;
z-index: 0;
}
.seatmap-team-overlay::before {
content: attr(data-team-label);
position: absolute;
top: 10px;
left: 12px;
display: inline-flex;
align-items: center;
min-height: 26px;
padding: 0 11px;
border-radius: 999px;
background: color-mix(in srgb, var(--seatmap-team-soft, rgba(13, 148, 136, 0.14)) 84%, white);
color: color-mix(in srgb, var(--seatmap-team-accent, #0f766e) 86%, #10251d);
font-size: 11px;
font-weight: 900;
letter-spacing: 0.02em;
}
.seatmap-cell {
position: relative;
min-width: 0;
@@ -920,6 +953,7 @@ body {
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;
z-index: 1;
}
.seatmap-cell.editable:hover {
@@ -931,6 +965,13 @@ body {
background: rgba(255, 255, 255, 0.18);
}
.seatmap-cell.occupied.team-colored {
border-color: color-mix(in srgb, var(--seatmap-team-accent, rgba(15, 118, 110, 0.72)) 62%, white);
background:
linear-gradient(180deg, color-mix(in srgb, var(--seatmap-team-soft, rgba(15, 118, 110, 0.14)) 86%, transparent), rgba(255, 255, 255, 0.14)),
rgba(255, 255, 255, 0.18);
}
.seatmap-cell-label {
position: absolute;
top: 4px;
@@ -959,6 +1000,12 @@ body {
overflow: hidden;
}
.seatmap-member-card.team-colored {
border-color: color-mix(in srgb, var(--seatmap-team-accent, rgba(245, 158, 11, 0.95)) 42%, rgba(255,255,255,0.4));
background:
linear-gradient(180deg, color-mix(in srgb, var(--seatmap-team-soft, rgba(245, 158, 11, 0.18)) 68%, rgba(15, 23, 42, 0.84)) 0%, rgba(15, 23, 42, 0.84) 100%);
}
.seatmap-member-card.draggable {
cursor: grab;
}
@@ -1008,11 +1055,26 @@ body {
}
.seatmap-member-text em {
color: rgba(226, 232, 240, 0.84);
color: rgba(255, 247, 237, 0.96);
font-size: 10px;
font-weight: 800;
font-style: normal;
}
.seatmap-team-chip {
display: inline-flex;
align-items: center;
max-width: 100%;
padding: 2px 6px;
border-radius: 999px;
background: color-mix(in srgb, var(--seatmap-team-soft, rgba(245, 158, 11, 0.18)) 88%, white);
color: color-mix(in srgb, var(--seatmap-team-accent, #b45309) 85%, #10251d);
font-size: 10px;
font-weight: 900;
line-height: 1;
letter-spacing: 0.01em;
}
.seatmap-sidebar {
height: 100%;
min-height: 0;
@@ -1195,6 +1257,11 @@ body {
text-align: left;
}
.seatmap-member-search-card.team-colored {
border-color: color-mix(in srgb, var(--seatmap-team-accent, rgba(245, 158, 11, 0.24)) 34%, rgba(226, 232, 240, 0.9));
background: linear-gradient(180deg, color-mix(in srgb, var(--seatmap-team-soft, rgba(245, 158, 11, 0.1)) 78%, white), #fff);
}
.seatmap-member-badge {
flex: 0 0 auto;
display: inline-flex;
@@ -1219,8 +1286,8 @@ body {
min-height: 42px;
padding: 10px 12px;
border-radius: 12px;
background: #3f4658;
border: 1px solid rgba(148, 163, 184, 0.14);
background: transparent;
border: 1px solid rgba(194, 170, 134, 0.34);
box-shadow: none;
}
@@ -1232,12 +1299,14 @@ body {
.seatmap-member-text-inline strong {
font-size: 13px;
color: #10251d;
}
.seatmap-member-text-inline em {
display: inline;
color: rgba(226, 232, 240, 0.8);
color: #6f5b3e;
font-size: 11px;
font-weight: 800;
}
.seatmap-slot .seatmap-member-card {
@@ -1266,6 +1335,81 @@ body {
display: none;
}
.seatmap-context-panel {
display: grid;
gap: 10px;
padding: 14px;
border: 1px solid rgba(194, 170, 134, 0.28);
border-radius: 18px;
background: linear-gradient(180deg, rgba(255, 252, 247, 0.96), rgba(246, 239, 227, 0.92));
box-shadow: 0 14px 28px rgba(16, 37, 29, 0.08);
}
.seatmap-context-panel.hidden {
display: none;
}
.seatmap-context-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.seatmap-context-title {
display: grid;
gap: 4px;
}
.seatmap-context-title strong {
color: #10251d;
font-size: 14px;
font-weight: 900;
}
.seatmap-context-title span {
color: #6b7280;
font-size: 11px;
font-weight: 700;
}
.seatmap-context-badge {
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 0 10px;
border-radius: 999px;
background: color-mix(in srgb, var(--seatmap-team-soft, rgba(245, 158, 11, 0.14)) 88%, white);
color: color-mix(in srgb, var(--seatmap-team-accent, #b45309) 84%, #10251d);
font-size: 11px;
font-weight: 900;
}
.seatmap-context-tree {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.seatmap-context-node {
display: inline-flex;
align-items: center;
min-height: 30px;
padding: 0 11px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.84);
border: 1px solid rgba(194, 170, 134, 0.28);
color: #30423a;
font-size: 11px;
font-weight: 800;
}
.seatmap-context-empty {
color: #7b7b6c;
font-size: 12px;
line-height: 1.5;
}
.seatmap-dxf-stage {
cursor: grab;
}

View File

@@ -0,0 +1,53 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
echo "[smoke] checking dev containers"
docker ps --format '{{.Names}}' | grep -qx 'mh-dashboard-organization-dev-backend-1'
docker ps --format '{{.Names}}' | grep -qx 'mh-dashboard-organization-dev-proxy-1'
echo "[smoke] running 8081 endpoint checks"
docker exec mh-dashboard-organization-dev-backend-1 python - <<'PY'
import sys
import urllib.request
checks = [
("health", "http://127.0.0.1:8000/api/health", b'"status"'),
("db-status-api", "http://127.0.0.1:8000/api/admin/db-status", b'"tables"'),
("ledger-default-api", "http://127.0.0.1:8000/api/integration/business-ledger-default", b'PK'),
("legacy-organization", "http://127.0.0.1:8000/legacy/organization", b'organization'),
("payment", "http://127.0.0.1:8000/integrations/payment", b'const App = () =>'),
("ledger", "http://127.0.0.1:8000/integrations/ledger", b'xlsx.full.min.js'),
("mh", "http://127.0.0.1:8000/integrations/mh", b'const App = () =>'),
("proxy-root", "http://proxy/", b'app.js'),
("proxy-db-status", "http://proxy/db-status.html", b'전체 테이블 현황'),
]
failed = []
for name, url, needle in checks:
try:
with urllib.request.urlopen(url, timeout=8) as response:
body = response.read()
status = getattr(response, "status", response.getcode())
if status != 200:
failed.append(f"{name}: unexpected status {status}")
continue
if needle not in body:
failed.append(f"{name}: missing expected marker {needle!r}")
continue
print(f"[ok] {name} -> {status}")
except Exception as exc:
failed.append(f"{name}: {exc}")
if failed:
print("[smoke] failures detected:")
for item in failed:
print(f" - {item}")
sys.exit(1)
print("[smoke] all checks passed")
PY