diff --git a/backend/app/ledger_runtime.py b/backend/app/ledger_runtime.py index c1a1958..2f2e41c 100644 --- a/backend/app/ledger_runtime.py +++ b/backend/app/ledger_runtime.py @@ -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", } diff --git a/backend/app/main.py b/backend/app/main.py index be4afcf..88f2abb 100755 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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 = ` ${assignment.name}
직급: ${assignment.rank || "-"}
+
팀: ${assignment.team || "미분류"}
상태: 배치완료
${viewerMode === "default" ? `` : ""} `; @@ -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(); }); diff --git a/docs/NEXT_SESSION_CHECKPOINT.md b/docs/NEXT_SESSION_CHECKPOINT.md index 3f7b9a1..f3efe97 100644 --- a/docs/NEXT_SESSION_CHECKPOINT.md +++ b/docs/NEXT_SESSION_CHECKPOINT.md @@ -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` 까지 확인 diff --git a/docs/REGRESSION_CHECKLIST.md b/docs/REGRESSION_CHECKLIST.md index 3cd1661..ac6509a 100644 --- a/docs/REGRESSION_CHECKLIST.md +++ b/docs/REGRESSION_CHECKLIST.md @@ -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`)를 반환해야 한다. + ## 작업 유형별 필수 추가 확인 ### 조직도 / 관리자모드 수정 시 diff --git a/docs/WORK_EXECUTION_FLOW.md b/docs/WORK_EXECUTION_FLOW.md index 6136f17..7ea207a 100644 --- a/docs/WORK_EXECUTION_FLOW.md +++ b/docs/WORK_EXECUTION_FLOW.md @@ -158,6 +158,7 @@ - DB 동기화 - 코드/worktree 동기화 +- 구조 정리나 서빙 경로 수정 직후에는 `./scripts/check_8081_smoke.sh` 로 핵심 런타임을 먼저 확인 ## 6. 실제 실행 diff --git a/docs/WORK_RULEBOOK.md b/docs/WORK_RULEBOOK.md index 7aee334..918e91a 100644 --- a/docs/WORK_RULEBOOK.md +++ b/docs/WORK_RULEBOOK.md @@ -119,6 +119,7 @@ 검증 기준 문서: - `docs/REGRESSION_CHECKLIST.md` +- 구조 정리, 라우트 분리, 기본 원본 API 변경 후에는 `./scripts/check_8081_smoke.sh` 를 먼저 통과시킨다. ## Rule 7. Seat Map Work Is High Risk diff --git a/frontend/public/app.js b/frontend/public/app.js index f7814ee..5ef280a 100644 --- a/frontend/public/app.js +++ b/frontend/public/app.js @@ -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 `${escapeHtml(label)}`; +} + +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 = ` +
+
+ ${escapeHtml(member.name || "-")} + ${escapeHtml(member.rank || member.role || "구성원")} +
+ ${escapeHtml(tone.label)} +
+
+ ${orgPath.length + ? orgPath.map((item) => `${escapeHtml(item)}`).join("") + : '
표시할 상위 조직 정보가 없습니다.
'} +
+ `; +} + 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) => ` +
+ `); +} + function upsertDraftPlacement(memberId, rowIndex, colIndex) { const cellMap = getCellPlacementMap(); const existing = cellMap.get(`${rowIndex}:${colIndex}`); @@ -1003,11 +1144,12 @@ function renderMemberCard(member, draggable) { ? `${escapeHtml(member.name)}` : `${escapeHtml(getInitials(member.name))}`; return ` -
+
${avatar} ${escapeHtml(member.name || "-")} - ${escapeHtml(member.department || member.team || member.rank || "-")} + ${escapeHtml(member.rank || "-")} + ${escapeHtml(getSeatMapOrgUnitLabel(member))}
`; @@ -1015,11 +1157,12 @@ function renderMemberCard(member, draggable) { function renderUnassignedMemberCard(member, draggable) { return ` -
+
${escapeHtml(member.name || "-")} ${escapeHtml(member.rank || "-")} + ${renderSeatMapTeamChip(member)}
`; } @@ -1027,14 +1170,13 @@ function renderUnassignedMemberCard(member, draggable) { function renderSeatMapSearchCard(member) { const placement = getPlacementForMember(Number(member.id)); if (!placement) return ""; - const badge = `${escapeHtml(placement.seat_label || "배치완료")}`; return ` `; } @@ -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(` -
+
${escapeHtml(computeSeatLabel(rowIndex, colIndex))} ${member ? renderMemberCard(member, editable) : ""}
@@ -1072,7 +1216,7 @@ function renderSeatMapBoard() { seatMapBoard.innerHTML = `
${escapeHtml(seatMapState.seatMap.name)} -
${cells.join("")}
+
${overlays.join("")}${cells.join("")}
`; } @@ -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() diff --git a/frontend/public/index.html b/frontend/public/index.html index 370eed6..b395b65 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -181,6 +181,7 @@ +
@@ -220,6 +221,7 @@ +
diff --git a/frontend/public/styles.css b/frontend/public/styles.css index 6003b4e..63bb78d 100644 --- a/frontend/public/styles.css +++ b/frontend/public/styles.css @@ -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; } diff --git a/scripts/check_8081_smoke.sh b/scripts/check_8081_smoke.sh new file mode 100644 index 0000000..56d8510 --- /dev/null +++ b/scripts/check_8081_smoke.sh @@ -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