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 hashlib
import json import json
from pathlib import Path from pathlib import Path
from urllib.parse import quote
from fastapi import HTTPException from fastapi import HTTPException
from fastapi.responses import FileResponse, Response from fastapi.responses import FileResponse, Response
@@ -88,7 +89,7 @@ def build_business_ledger_default_response(cur) -> Response:
headers = { headers = {
"Content-Disposition": 'inline; filename="business-ledger-default.xlsx"', "Content-Disposition": 'inline; filename="business-ledger-default.xlsx"',
"X-Source-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", "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
"Pragma": "no-cache", "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"]), "member_id": int(member["id"]),
"name": str(member.get("name") or "-"), "name": str(member.get("name") or "-"),
"rank": str(member.get("rank") 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; 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() { function hideSeatPopup() {
popup.hidden = true; popup.hidden = true;
popup.innerHTML = ""; popup.innerHTML = "";
@@ -1870,6 +1909,7 @@ def build_center_chair_viewer_html(layout: dict[str, object]) -> str:
popup.innerHTML = ` popup.innerHTML = `
<strong>${assignment.name}</strong> <strong>${assignment.name}</strong>
<div>직급: ${assignment.rank || "-"}</div> <div>직급: ${assignment.rank || "-"}</div>
<div>팀: ${assignment.team || "미분류"}</div>
<div>상태: 배치완료</div> <div>상태: 배치완료</div>
${viewerMode === "default" ? `<button type="button" data-seatmap-delete="${chairKey}">자리 비우기</button>` : ""} ${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, key,
name: item.name || "-", name: item.name || "-",
rank: item.rank || "-", rank: item.rank || "-",
team: item.team || "",
department: item.department || "",
grp: item.grp || "",
division: item.division || "",
cell: item.cell || "",
memberId: Number(item.member_id || 0), memberId: Number(item.member_id || 0),
}; };
seatAssignments.set(key, assignment); seatAssignments.set(key, assignment);
@@ -1974,11 +2019,67 @@ def build_center_chair_viewer_html(layout: dict[str, object]) -> str:
focusedChairPulseUntil = 0; focusedChairPulseUntil = 0;
} }
if (!seatAssignments.size) return; if (!seatAssignments.size) return;
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0); const groupRects = new Map();
ctx.textBaseline = "middle";
for (const chair of chairGeometry) { for (const chair of chairGeometry) {
const assignment = getAssignment(chair.key); const assignment = getAssignment(chair.key);
if (!assignment) continue; 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); 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; if (center.x < -120 || center.x > rect.width + 120 || center.y < -50 || center.y > rect.height + 50) continue;
const primary = `${assignment.name}`; 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 boxX = center.x - boxWidth / 2;
const boxY = center.y - 46; const boxY = center.y - 46;
ctx.fillStyle = "rgba(255,255,255,0.96)"; ctx.fillStyle = "rgba(255,255,255,0.96)";
ctx.strokeStyle = "rgba(220,38,38,0.18)"; ctx.strokeStyle = tone.accent;
ctx.lineWidth = 1; ctx.lineWidth = 1;
ctx.beginPath(); ctx.beginPath();
ctx.roundRect(boxX, boxY, boxWidth, boxHeight, 10); 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.fillStyle = "#111827";
ctx.font = "700 12px Pretendard, sans-serif"; ctx.font = "700 12px Pretendard, sans-serif";
ctx.fillText(primary, boxX + 10, boxY + 12); ctx.fillText(primary, boxX + 10, boxY + 12);
ctx.fillStyle = "#6b7280"; ctx.fillStyle = tone.accent;
ctx.font = "600 10px Pretendard, sans-serif"; ctx.font = "600 10px Pretendard, sans-serif";
ctx.fillText(secondary, boxX + 10, boxY + 25); ctx.fillText(secondary, boxX + 10, boxY + 25);
} }
@@ -2032,12 +2133,18 @@ def build_center_chair_viewer_html(layout: dict[str, object]) -> str:
if (!picked) { if (!picked) {
selectedChairKey = null; selectedChairKey = null;
hideSeatPopup(); hideSeatPopup();
notifyParentSelection(null);
if (typeof requestDraw === "function") requestDraw(); if (typeof requestDraw === "function") requestDraw();
return; return;
} }
selectedChairKey = seatAssignments.has(String(picked.key)) ? String(picked.key) : null; selectedChairKey = seatAssignments.has(String(picked.key)) ? String(picked.key) : null;
if (selectedChairKey) showSeatPopup(selectedChairKey, event.clientX - rect.left, event.clientY - rect.top); if (selectedChairKey) {
else hideSeatPopup(); showSeatPopup(selectedChairKey, event.clientX - rect.left, event.clientY - rect.top);
notifyParentSelection(getAssignment(selectedChairKey));
} else {
hideSeatPopup();
notifyParentSelection(null);
}
if (typeof requestDraw === "function") requestDraw(); if (typeof requestDraw === "function") requestDraw();
}); });

View File

@@ -132,11 +132,33 @@
## Open Issues Relevant Now ## Open Issues Relevant Now
- `#2` 백엔드 영속 저장 구조 운영 마무리 - `#2` 백엔드 영속 저장 구조 운영 마무리
- `#7` 자리배치도 팀별 구역 색상 오버레이
- `#8` 자리배치도 좌석 클릭 시 개인 상위 조직 트리 표시
- `#14` 누적된 임시 로직 정리 및 중복 코드 제거 - `#14` 누적된 임시 로직 정리 및 중복 코드 제거
- `#16` 사업관리대장 메인 후속 정리 및 기준 분석 - `#16` 사업관리대장 메인 후속 정리 및 기준 분석
- `#19` 8081 백엔드 라우터/서빙 deeper 모듈 분리 - `#19` 8081 백엔드 라우터/서빙 deeper 모듈 분리
- `#21` organization 레거시 구조 승격 및 장기 고도화 - `#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 ## Recommended Next Work Order
1. `#2` 기준으로 DB 상태 화면과 저장 구조 검증 흐름 고도화 1. `#2` 기준으로 DB 상태 화면과 저장 구조 검증 흐름 고도화
@@ -156,3 +178,4 @@
- `#2` 기준 DB 상태 확인은 `/api/admin/db-status` 또는 허브의 `DB 상태` 탭(`/db-status.html`)을 먼저 본다. - `#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) 기준으로 본다. - 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`를 먼저 확인 - 작업 전 `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`가 정상인지 확인 - `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`은 기본적으로 `./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/...`인지 확인 - `8081` 기동 후 `docker inspect mh-dashboard-organization-dev-backend-1`에서 마운트 경로가 `.dev-worktree-8081/...`인지 확인
- 구조 정리, 라우트 분리, 기본 원본 API 변경 후에는 먼저 `./scripts/check_8081_smoke.sh` 를 실행한다
### 2. 데이터 동기화 범위 결정 ### 2. 데이터 동기화 범위 결정
@@ -52,6 +53,7 @@
- 메인 허브가 정상 렌더링된다. - 메인 허브가 정상 렌더링된다.
- 상단 탭 이동이 정상 동작한다. - 상단 탭 이동이 정상 동작한다.
- 로그인 상태가 비정상적으로 풀리지 않는다. - 로그인 상태가 비정상적으로 풀리지 않는다.
- `DB 상태` 탭이 정상 렌더링된다.
### B. 조직현황 ### B. 조직현황
@@ -100,6 +102,12 @@
- `GPD`, `TDC` 선택 시 각 소속 범위만 버튼 기준으로 보인다. - `GPD`, `TDC` 선택 시 각 소속 범위만 버튼 기준으로 보인다.
- 검색은 버튼 상태와 무관하게 전체 데이터를 검색한다. - 검색은 버튼 상태와 무관하게 전체 데이터를 검색한다.
### G. 사업관리대장 기본 원본
- `/api/integration/business-ledger-default``200` 이어야 한다.
- `사업관리대장` 탭 진입 시 기본 원본이 비어 있지 않다.
- 기본 원본 응답은 바이너리 XLSX 시그니처(`PK`)를 반환해야 한다.
## 작업 유형별 필수 추가 확인 ## 작업 유형별 필수 추가 확인
### 조직도 / 관리자모드 수정 시 ### 조직도 / 관리자모드 수정 시

View File

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

View File

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

View File

@@ -50,6 +50,7 @@ const seatMapDom = {
formGap: null, formGap: null,
formImage: document.getElementById("seatmap-admin-form-image"), formImage: document.getElementById("seatmap-admin-form-image"),
search: document.getElementById("seatmap-admin-search"), search: document.getElementById("seatmap-admin-search"),
context: document.getElementById("seatmap-admin-context"),
unassigned: document.getElementById("seatmap-admin-unassigned"), unassigned: document.getElementById("seatmap-admin-unassigned"),
officeTabs: document.getElementById("seatmap-admin-office-tabs"), officeTabs: document.getElementById("seatmap-admin-office-tabs"),
sidebarTitle: document.getElementById("seatmap-admin-sidebar-title"), sidebarTitle: document.getElementById("seatmap-admin-sidebar-title"),
@@ -75,6 +76,7 @@ const seatMapDom = {
formGap: null, formGap: null,
formImage: null, formImage: null,
search: document.getElementById("seatmap-readonly-search"), search: document.getElementById("seatmap-readonly-search"),
context: document.getElementById("seatmap-readonly-context"),
unassigned: document.getElementById("seatmap-readonly-unassigned"), unassigned: document.getElementById("seatmap-readonly-unassigned"),
officeTabs: document.getElementById("seatmap-readonly-office-tabs"), officeTabs: document.getElementById("seatmap-readonly-office-tabs"),
sidebarTitle: document.getElementById("seatmap-readonly-sidebar-title"), sidebarTitle: document.getElementById("seatmap-readonly-sidebar-title"),
@@ -100,6 +102,7 @@ let seatMapFormCols = seatMapDom.admin.formCols;
let seatMapFormGap = seatMapDom.admin.formGap; let seatMapFormGap = seatMapDom.admin.formGap;
let seatMapFormImage = seatMapDom.admin.formImage; let seatMapFormImage = seatMapDom.admin.formImage;
let seatMapSearch = seatMapDom.admin.search; let seatMapSearch = seatMapDom.admin.search;
let seatMapContext = seatMapDom.admin.context;
let seatMapUnassigned = seatMapDom.admin.unassigned; let seatMapUnassigned = seatMapDom.admin.unassigned;
let seatMapOfficeTabs = seatMapDom.admin.officeTabs; let seatMapOfficeTabs = seatMapDom.admin.officeTabs;
let seatMapSidebarTitle = seatMapDom.admin.sidebarTitle; let seatMapSidebarTitle = seatMapDom.admin.sidebarTitle;
@@ -135,6 +138,7 @@ const seatMapState = {
search: "", search: "",
status: "", status: "",
statusTone: "info", statusTone: "info",
selectedMemberId: null,
draggingMemberId: null, draggingMemberId: null,
zoom: 1, zoom: 1,
panning: false, panning: false,
@@ -491,6 +495,7 @@ function syncSeatMapDomRefs() {
seatMapFormGap = dom.formGap; seatMapFormGap = dom.formGap;
seatMapFormImage = dom.formImage; seatMapFormImage = dom.formImage;
seatMapSearch = dom.search; seatMapSearch = dom.search;
seatMapContext = dom.context;
seatMapUnassigned = dom.unassigned; seatMapUnassigned = dom.unassigned;
seatMapOfficeTabs = dom.officeTabs; seatMapOfficeTabs = dom.officeTabs;
seatMapSidebarTitle = dom.sidebarTitle; seatMapSidebarTitle = dom.sidebarTitle;
@@ -837,6 +842,98 @@ function getMemberMap() {
return new Map(seatMapState.members.map((member) => [Number(member.id), member])); 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) { function getPlacementForMember(memberId) {
return getPlacementSource().find((item) => Number(item.member_id) === Number(memberId)) || null; return getPlacementSource().find((item) => Number(item.member_id) === Number(memberId)) || null;
} }
@@ -871,9 +968,13 @@ function getSidebarMembers() {
return members.filter(memberMatchesSeatMapSearch); return members.filter(memberMatchesSeatMapSearch);
} }
function focusSeatMapMember(memberId) { function focusSeatMapMember(memberId, options = {}) {
const showContext = options.showContext !== false;
const placement = getPlacementForMember(memberId); const placement = getPlacementForMember(memberId);
const member = getMemberMap().get(Number(memberId)); const member = getMemberMap().get(Number(memberId));
if (showContext) {
renderSeatMapMemberContext(memberId);
}
if (!placement) { if (!placement) {
setSeatMapStatus("해당 인원은 아직 배치되지 않았습니다.", "info"); setSeatMapStatus("해당 인원은 아직 배치되지 않았습니다.", "info");
return; return;
@@ -943,6 +1044,46 @@ function getSlotPlacementMap() {
return slotMap; 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) { function upsertDraftPlacement(memberId, rowIndex, colIndex) {
const cellMap = getCellPlacementMap(); const cellMap = getCellPlacementMap();
const existing = cellMap.get(`${rowIndex}:${colIndex}`); 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"><img src="${photoUrl}" alt="${escapeHtml(member.name)}"></span>`
: `<span class="seatmap-member-avatar seatmap-member-avatar-fallback">${escapeHtml(getInitials(member.name))}</span>`; : `<span class="seatmap-member-avatar seatmap-member-avatar-fallback">${escapeHtml(getInitials(member.name))}</span>`;
return ` 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} ${avatar}
<span class="seatmap-member-text"> <span class="seatmap-member-text">
<strong>${escapeHtml(member.name || "-")}</strong> <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> </span>
</div> </div>
`; `;
@@ -1015,11 +1157,12 @@ function renderMemberCard(member, draggable) {
function renderUnassignedMemberCard(member, draggable) { function renderUnassignedMemberCard(member, draggable) {
return ` 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"> <span class="seatmap-member-text seatmap-member-text-inline">
<strong>${escapeHtml(member.name || "-")}</strong> <strong>${escapeHtml(member.name || "-")}</strong>
<em>${escapeHtml(member.rank || "-")}</em> <em>${escapeHtml(member.rank || "-")}</em>
</span> </span>
${renderSeatMapTeamChip(member)}
</div> </div>
`; `;
} }
@@ -1027,14 +1170,13 @@ function renderUnassignedMemberCard(member, draggable) {
function renderSeatMapSearchCard(member) { function renderSeatMapSearchCard(member) {
const placement = getPlacementForMember(Number(member.id)); const placement = getPlacementForMember(Number(member.id));
if (!placement) return ""; if (!placement) return "";
const badge = `<span class="seatmap-member-badge occupied">${escapeHtml(placement.seat_label || "배치완료")}</span>`;
return ` return `
<button class="seatmap-member-search-card" type="button" data-member-id="${Number(member.id)}"> <button class="seatmap-member-search-card" type="button" data-member-id="${Number(member.id)}">
<span class="seatmap-member-text seatmap-member-text-inline"> <span class="seatmap-member-text seatmap-member-text-inline">
<strong>${escapeHtml(member.name || "-")}</strong> <strong>${escapeHtml(member.name || "-")}</strong>
<em>${escapeHtml(member.rank || member.department || "-")}</em> <em>${escapeHtml(member.rank || "-")}</em>
</span> </span>
${badge} ${renderSeatMapTeamChip(member)}
</button> </button>
`; `;
} }
@@ -1054,14 +1196,16 @@ function renderSeatMapBoard() {
const gap = Number(seatMapState.seatMap.cell_gap || 0); const gap = Number(seatMapState.seatMap.cell_gap || 0);
const editable = seatMapState.editMode && isAdmin(); const editable = seatMapState.editMode && isAdmin();
const cells = []; const cells = [];
const overlays = buildSeatMapGridTeamOverlays(rows, cols, placementMap, memberMap);
for (let rowIndex = 0; rowIndex < rows; rowIndex += 1) { for (let rowIndex = 0; rowIndex < rows; rowIndex += 1) {
for (let colIndex = 0; colIndex < cols; colIndex += 1) { for (let colIndex = 0; colIndex < cols; colIndex += 1) {
const key = `${rowIndex}:${colIndex}`; const key = `${rowIndex}:${colIndex}`;
const placement = placementMap.get(key); const placement = placementMap.get(key);
const member = placement ? memberMap.get(Number(placement.member_id)) : null; const member = placement ? memberMap.get(Number(placement.member_id)) : null;
const teamStyle = member ? ` style="${escapeHtml(getSeatMapTeamStyle(member))}"` : "";
cells.push(` 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> <span class="seatmap-cell-label">${escapeHtml(computeSeatLabel(rowIndex, colIndex))}</span>
${member ? renderMemberCard(member, editable) : ""} ${member ? renderMemberCard(member, editable) : ""}
</div> </div>
@@ -1072,7 +1216,7 @@ function renderSeatMapBoard() {
seatMapBoard.innerHTML = ` seatMapBoard.innerHTML = `
<div class="seatmap-canvas" style="--seatmap-rows:${rows}; --seatmap-cols:${cols}; --seatmap-gap:${gap}px;"> <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)}"> <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> </div>
`; `;
} }
@@ -1175,6 +1319,7 @@ function renderSeatMapActions() {
function updateSeatMapDraftUi() { function updateSeatMapDraftUi() {
renderSeatMapActions(); renderSeatMapActions();
renderUnassignedMembers(); renderUnassignedMembers();
renderSeatMapMemberContext(seatMapState.selectedMemberId);
syncSeatMapViewerFrame(); syncSeatMapViewerFrame();
} }
@@ -1394,6 +1539,9 @@ function handleEmbeddedNavigationMessage(event) {
updateSeatMapDraftUi(); updateSeatMapDraftUi();
} }
} }
if (data.type === "seatmap-member-selected") {
renderSeatMapMemberContext(Number(data.memberId || 0) || null);
}
} }
async function fetchJson(url, options) { async function fetchJson(url, options) {
@@ -1437,6 +1585,7 @@ async function loadSeatMapData(force = false) {
seatMapState.placements = clonePlacements(layoutPayload.placements || []); seatMapState.placements = clonePlacements(layoutPayload.placements || []);
seatMapState.zoom = 1; seatMapState.zoom = 1;
seatMapState.hoveredSlotId = null; seatMapState.hoveredSlotId = null;
seatMapState.selectedMemberId = null;
seatMapState.editMode = canEditSeatMap(); seatMapState.editMode = canEditSeatMap();
resetSeatMapDraft(); resetSeatMapDraft();
seatMapState.loaded = true; seatMapState.loaded = true;
@@ -1450,6 +1599,7 @@ async function loadSeatMapData(force = false) {
seatMapState.placements = []; seatMapState.placements = [];
seatMapState.zoom = 1; seatMapState.zoom = 1;
seatMapState.hoveredSlotId = null; seatMapState.hoveredSlotId = null;
seatMapState.selectedMemberId = null;
seatMapState.editMode = canEditSeatMap(); seatMapState.editMode = canEditSeatMap();
resetSeatMapDraft(); resetSeatMapDraft();
seatMapState.loaded = true; seatMapState.loaded = true;
@@ -1690,7 +1840,7 @@ function setActiveView(view) {
dbStatusFrame.src = resolveAppUrl(frameSrc); dbStatusFrame.src = resolveAppUrl(frameSrc);
} }
if (isSeatMapAdmin || isSeatMapReadonly) { if (isSeatMapAdmin || isSeatMapReadonly) {
loadSeatMapData(); loadSeatMapData(previousView !== currentView);
} }
notifyEmbeddedTabActivated(); notifyEmbeddedTabActivated();
} }
@@ -1886,14 +2036,15 @@ Object.values(seatMapDom).forEach((dom) => {
}); });
dom.unassigned?.addEventListener("click", (event) => { dom.unassigned?.addEventListener("click", (event) => {
const button = event.target.closest("[data-member-id]"); const target = event.target.closest("[data-member-id]");
if (!button) return; if (!target) return;
if (button.classList.contains("seatmap-member-search-card")) { if (target.classList.contains("seatmap-member-search-card")) {
focusSeatMapMember(Number(button.dataset.memberId)); focusSeatMapMember(Number(target.dataset.memberId), { showContext: false });
return; return;
} }
renderSeatMapMemberContext(Number(target.dataset.memberId));
if (canEditSeatMap()) return; if (canEditSeatMap()) return;
focusSeatMapMember(Number(button.dataset.memberId)); focusSeatMapMember(Number(target.dataset.memberId));
}); });
dom.unassigned?.addEventListener("dragover", (event) => { dom.unassigned?.addEventListener("dragover", (event) => {
if (!seatMapState.editMode) return; if (!seatMapState.editMode) return;
@@ -1922,6 +2073,17 @@ Object.values(seatMapDom).forEach((dom) => {
if (!fitButton) return; if (!fitButton) return;
fitDxfSeatMapBoard(); 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) => { dom.board?.addEventListener("dragover", (event) => {
if (!seatMapState.editMode) return; if (!seatMapState.editMode) return;
const target = isSlotBasedSeatMap() const target = isSlotBasedSeatMap()

View File

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

View File

@@ -913,6 +913,39 @@ body {
padding: var(--seatmap-gap); 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 { .seatmap-cell {
position: relative; position: relative;
min-width: 0; min-width: 0;
@@ -920,6 +953,7 @@ body {
border: 1px dashed rgba(15, 23, 42, 0.14); border: 1px dashed rgba(15, 23, 42, 0.14);
background: rgba(255, 255, 255, 0.12); background: rgba(255, 255, 255, 0.12);
transition: border-color 0.18s ease, background 0.18s ease; transition: border-color 0.18s ease, background 0.18s ease;
z-index: 1;
} }
.seatmap-cell.editable:hover { .seatmap-cell.editable:hover {
@@ -931,6 +965,13 @@ body {
background: rgba(255, 255, 255, 0.18); 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 { .seatmap-cell-label {
position: absolute; position: absolute;
top: 4px; top: 4px;
@@ -959,6 +1000,12 @@ body {
overflow: hidden; 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 { .seatmap-member-card.draggable {
cursor: grab; cursor: grab;
} }
@@ -1008,11 +1055,26 @@ body {
} }
.seatmap-member-text em { .seatmap-member-text em {
color: rgba(226, 232, 240, 0.84); color: rgba(255, 247, 237, 0.96);
font-size: 10px; font-size: 10px;
font-weight: 800;
font-style: normal; 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 { .seatmap-sidebar {
height: 100%; height: 100%;
min-height: 0; min-height: 0;
@@ -1195,6 +1257,11 @@ body {
text-align: left; 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 { .seatmap-member-badge {
flex: 0 0 auto; flex: 0 0 auto;
display: inline-flex; display: inline-flex;
@@ -1219,8 +1286,8 @@ body {
min-height: 42px; min-height: 42px;
padding: 10px 12px; padding: 10px 12px;
border-radius: 12px; border-radius: 12px;
background: #3f4658; background: transparent;
border: 1px solid rgba(148, 163, 184, 0.14); border: 1px solid rgba(194, 170, 134, 0.34);
box-shadow: none; box-shadow: none;
} }
@@ -1232,12 +1299,14 @@ body {
.seatmap-member-text-inline strong { .seatmap-member-text-inline strong {
font-size: 13px; font-size: 13px;
color: #10251d;
} }
.seatmap-member-text-inline em { .seatmap-member-text-inline em {
display: inline; display: inline;
color: rgba(226, 232, 240, 0.8); color: #6f5b3e;
font-size: 11px; font-size: 11px;
font-weight: 800;
} }
.seatmap-slot .seatmap-member-card { .seatmap-slot .seatmap-member-card {
@@ -1266,6 +1335,81 @@ body {
display: none; 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 { .seatmap-dxf-stage {
cursor: grab; 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