feat: add seatmap context panel and smoke checks
This commit is contained in:
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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` 까지 확인
|
||||
|
||||
@@ -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`)를 반환해야 한다.
|
||||
|
||||
## 작업 유형별 필수 추가 확인
|
||||
|
||||
### 조직도 / 관리자모드 수정 시
|
||||
|
||||
@@ -158,6 +158,7 @@
|
||||
|
||||
- DB 동기화
|
||||
- 코드/worktree 동기화
|
||||
- 구조 정리나 서빙 경로 수정 직후에는 `./scripts/check_8081_smoke.sh` 로 핵심 런타임을 먼저 확인
|
||||
|
||||
## 6. 실제 실행
|
||||
|
||||
|
||||
@@ -119,6 +119,7 @@
|
||||
검증 기준 문서:
|
||||
|
||||
- `docs/REGRESSION_CHECKLIST.md`
|
||||
- 구조 정리, 라우트 분리, 기본 원본 API 변경 후에는 `./scripts/check_8081_smoke.sh` 를 먼저 통과시킨다.
|
||||
|
||||
## Rule 7. Seat Map Work Is High Risk
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
53
scripts/check_8081_smoke.sh
Normal file
53
scripts/check_8081_smoke.sh
Normal 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
|
||||
Reference in New Issue
Block a user