5 Commits

Author SHA1 Message Date
hyunho
03e90d18a3 refactor: split system and serving routes 2026-04-01 16:49:27 +09:00
hyunho
57d9f630bc refactor: improve db ops visibility and split runtime helpers 2026-04-01 16:41:52 +09:00
hyunho
1e82572e15 feat: add db status viewer and db cleanup baseline 2026-04-01 15:28:11 +09:00
hyunho
e58e584a15 refactor: split 8081 app sources from served assets 2026-04-01 14:30:16 +09:00
hyunho
fb5b0f00c2 feat: unify 8081 dashboard design system and views 2026-04-01 14:02:05 +09:00
20 changed files with 3478 additions and 552 deletions

View File

@@ -0,0 +1,497 @@
from __future__ import annotations
from datetime import date, datetime
from decimal import Decimal
from fastapi import HTTPException
from .db import get_conn
DB_STATUS_TABLES = [
{
"table_ref": "public.members",
"label": "구성원 마스터",
"domain": "organization",
"timestamp_column": "updated_at",
"related_views": ["조직 현황", "자리배치도"],
"description": "조직/구성원 화면의 기준이 되는 현재 인원 마스터",
},
{
"table_ref": "public.member_versions",
"label": "구성원 이력",
"domain": "history",
"timestamp_column": "created_at",
"related_views": ["조직 현황", "이력 비교"],
"description": "as-of 조회와 변경 이력을 위한 시점 버전",
},
{
"table_ref": "public.seat_maps",
"label": "자리배치도 도면",
"domain": "seatmap",
"timestamp_column": "updated_at",
"related_views": ["자리배치도"],
"description": "오피스별 도면 메타데이터와 활성 상태",
},
{
"table_ref": "public.seat_positions",
"label": "현재 좌석 배치",
"domain": "seatmap",
"timestamp_column": "updated_at",
"related_views": ["자리배치도"],
"description": "현재 인원의 실제 배치 좌표/슬롯 연결",
},
{
"table_ref": "public.seat_assignment_versions",
"label": "좌석 배치 이력",
"domain": "history",
"timestamp_column": "created_at",
"related_views": ["자리배치도", "이력 비교"],
"description": "자리 이동 이력과 시점 조회용 배치 버전",
},
{
"table_ref": "public.integration_import_batches",
"label": "원본 업로드 배치",
"domain": "integration",
"timestamp_column": "imported_at",
"related_views": ["프로젝트별 분석", "팀/개인별 분석", "조직 현황"],
"description": "원본 파일 적재 단위와 최근 import 기록",
},
{
"table_ref": "public.integration_projects",
"label": "통합 프로젝트 표준화",
"domain": "integration",
"timestamp_column": "updated_at",
"related_views": ["프로젝트별 분석", "팀/개인별 분석"],
"description": "프로젝트 코드/이름/카테고리 정규화 결과",
},
{
"table_ref": "public.integration_work_logs",
"label": "근무 로그 표준화",
"domain": "integration",
"timestamp_column": "updated_at",
"related_views": ["팀/개인별 분석"],
"description": "MH workbook 기준 일자별 근무 로그 본체",
},
{
"table_ref": "public.integration_work_log_segments",
"label": "근무 로그 세그먼트",
"domain": "integration",
"timestamp_column": "created_at",
"related_views": ["팀/개인별 분석"],
"description": "근무 로그를 프로젝트/활동 기준으로 분해한 상세 세그먼트",
},
{
"table_ref": "public.integration_vouchers",
"label": "전표 표준화",
"domain": "integration",
"timestamp_column": "created_at",
"related_views": ["프로젝트별 분석"],
"description": "payment CSV 기준 프로젝트별 수입/지출 전표",
},
{
"table_ref": "public.integration_binary_sources",
"label": "바이너리 원본 보관",
"domain": "integration",
"timestamp_column": "imported_at",
"related_views": ["사업관리대장"],
"description": "엑셀/바이너리 원본을 DB에 보관하는 저장소",
},
{
"table_ref": "auth.users",
"label": "인증 사용자",
"domain": "auth",
"timestamp_column": "updated_at",
"related_views": ["로그인", "권한"],
"description": "로그인 계정, role, 활성 상태",
},
{
"table_ref": "auth.sessions",
"label": "인증 세션",
"domain": "auth",
"timestamp_column": "created_at",
"related_views": ["로그인", "권한"],
"description": "현재/과거 로그인 세션과 만료 상태",
},
{
"table_ref": "auth.login_audit_logs",
"label": "로그인 감사 로그",
"domain": "auth",
"timestamp_column": "created_at",
"related_views": ["로그인", "권한"],
"description": "로그인 성공/실패 기록",
},
]
DB_STATUS_TABLE_META = {str(item["table_ref"]): item for item in DB_STATUS_TABLES}
DB_STATUS_TABLE_GROUPS = {
"public.members": "유지",
"public.member_versions": "유지",
"public.seat_maps": "유지",
"public.seat_positions": "유지",
"public.seat_slots": "유지",
"public.seat_assignment_versions": "유지",
"public.history_revisions": "유지",
"public.integration_import_batches": "유지",
"public.integration_projects": "유지",
"public.integration_work_logs": "유지",
"public.integration_work_log_segments": "유지",
"public.integration_vouchers": "유지",
"public.integration_binary_sources": "유지",
"auth.users": "유지",
"auth.sessions": "유지",
"auth.login_audit_logs": "유지",
"public.member_overrides": "주의",
"public.member_retirements": "주의",
"public.member_aliases": "주의",
"public.integration_project_aliases": "주의",
"public.integration_project_category_mappings": "주의",
"public.integration_project_pm_assignments": "주의",
"public.integration_raw_organization_rows": "원본·추적",
"public.integration_raw_mh_rows": "원본·추적",
"public.integration_raw_mh_pm_rows": "원본·추적",
"public.integration_raw_payment_rows": "원본·추적",
}
DB_STATUS_PRODUCT_GROUPS = {
"탭 데이터": [
"public.members",
"public.seat_maps",
"public.seat_slots",
"public.seat_positions",
"public.integration_projects",
"public.integration_work_logs",
"public.integration_work_log_segments",
"public.integration_vouchers",
"public.integration_binary_sources",
],
"로그인·권한": [
"auth.users",
"auth.sessions",
"auth.login_audit_logs",
],
"히스토리": [
"public.history_revisions",
"public.member_versions",
"public.seat_assignment_versions",
],
"로우데이터·적재": [
"public.integration_import_batches",
"public.integration_raw_organization_rows",
"public.integration_raw_mh_rows",
"public.integration_raw_mh_pm_rows",
"public.integration_raw_payment_rows",
],
"보정·보조": [
"public.member_overrides",
"public.member_retirements",
"public.member_aliases",
"public.integration_project_aliases",
"public.integration_project_category_mappings",
"public.integration_project_pm_assignments",
],
}
DB_STATUS_SCREEN_MAP = [
{
"screen": "조직 현황",
"tables": [
"public.members",
"public.member_overrides",
"public.member_retirements",
"public.member_aliases",
"public.member_versions",
"public.history_revisions",
],
"write_flow": "원본 조직 데이터 import 후 members 계열을 갱신하고, 수정/이력 기능은 revision 기반으로 누적합니다.",
},
{
"screen": "자리배치도",
"tables": [
"public.seat_maps",
"public.seat_slots",
"public.seat_positions",
"public.seat_assignment_versions",
"public.history_revisions",
"public.members",
],
"write_flow": "고정 오피스 도면과 현재 좌석 배치를 읽고, 저장 시 현재 배치와 배치 이력을 함께 기록합니다.",
},
{
"screen": "프로젝트별 분석",
"tables": [
"public.integration_import_batches",
"public.integration_projects",
"public.integration_vouchers",
"public.integration_project_aliases",
"public.integration_project_category_mappings",
"public.integration_project_pm_assignments",
],
"write_flow": "payment 원본 import 결과와 프로젝트 보정 테이블을 조합해 프로젝트 집계를 만듭니다.",
},
{
"screen": "팀/개인별 분석",
"tables": [
"public.integration_import_batches",
"public.integration_projects",
"public.integration_work_logs",
"public.integration_work_log_segments",
"public.integration_raw_mh_rows",
"public.integration_raw_mh_pm_rows",
],
"write_flow": "MH 원본 row를 적재한 뒤 표준화 로그와 세그먼트로 분해해 화면 집계에 사용합니다.",
},
{
"screen": "사업관리대장",
"tables": [
"public.integration_binary_sources",
],
"write_flow": "현재는 기본 바이너리 원본 보관 상태만 DB에 유지하며, 상세 계산 규칙은 별도 기준 정렬이 필요합니다.",
},
{
"screen": "로그인 / 권한",
"tables": [
"auth.users",
"auth.sessions",
"auth.login_audit_logs",
],
"write_flow": "사용자 계정, 세션, 로그인 감사 로그를 auth 스키마에서 분리 운영합니다.",
},
]
def make_json_safe(value: object) -> object:
if isinstance(value, datetime):
return value.isoformat()
if isinstance(value, date):
return value.isoformat()
if isinstance(value, Decimal):
return float(value)
if isinstance(value, bytes):
return f"<{len(value)} bytes>"
if isinstance(value, dict):
return {str(key): make_json_safe(val) for key, val in value.items()}
if isinstance(value, list):
return [make_json_safe(item) for item in value]
return value
def fetch_db_status_snapshot() -> dict[str, object]:
table_items: list[dict[str, object]] = []
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT schemaname, tablename
FROM pg_tables
WHERE schemaname IN ('public', 'auth')
ORDER BY schemaname, tablename
"""
)
all_tables = cur.fetchall()
for row in all_tables:
schema_name = str(row["schemaname"])
table_name = str(row["tablename"])
table_ref = f"{schema_name}.{table_name}"
spec = DB_STATUS_TABLE_META.get(table_ref, {})
cur.execute("SELECT to_regclass(%s) IS NOT NULL AS table_exists", (table_ref,))
exists_row = cur.fetchone()
exists = bool(exists_row["table_exists"]) if exists_row is not None else False
row_count = 0
last_event_at = None
if exists:
timestamp_column = str(spec.get("timestamp_column") or "")
query = f"SELECT COUNT(*)::bigint AS row_count"
if timestamp_column:
query += f", MAX({timestamp_column}) AS last_event_at"
else:
query += ", NULL::timestamptz AS last_event_at"
query += f" FROM {schema_name}.{table_name}"
cur.execute(query)
metric_row = cur.fetchone() or {}
row_count = int(metric_row.get("row_count") or 0)
last_event_at = metric_row.get("last_event_at")
table_items.append(
{
"table_ref": table_ref,
"schema": schema_name,
"table_name": table_name,
"label": str(spec.get("label") or table_name),
"domain": str(spec.get("domain") or "other"),
"description": str(spec.get("description") or "세부 보조/원본/운영 테이블"),
"related_views": spec.get("related_views") or [],
"group": DB_STATUS_TABLE_GROUPS.get(table_ref, "주의"),
"exists": exists,
"row_count": row_count,
"last_event_at": last_event_at.isoformat() if last_event_at else None,
}
)
cur.execute(
"""
SELECT source_key, source_name, row_count, source_path, imported_at
FROM integration_import_batches
ORDER BY imported_at DESC, id DESC
"""
)
import_batches = [
{
"source_key": str(row["source_key"] or ""),
"source_name": str(row["source_name"] or ""),
"row_count": int(row["row_count"] or 0),
"source_path": str(row["source_path"] or ""),
"imported_at": row["imported_at"].isoformat() if row.get("imported_at") else None,
}
for row in cur.fetchall()
]
binary_sources: list[dict[str, object]] = []
cur.execute("SELECT to_regclass('public.integration_binary_sources') IS NOT NULL AS table_exists")
binary_exists_row = cur.fetchone()
binary_exists = bool(binary_exists_row["table_exists"]) if binary_exists_row is not None else False
if binary_exists:
cur.execute(
"""
SELECT source_key, source_name, filename, mime_type, OCTET_LENGTH(content) AS byte_size,
content_sha256, imported_at
FROM integration_binary_sources
ORDER BY imported_at DESC, id DESC
"""
)
binary_sources = [
{
"source_key": str(row["source_key"] or ""),
"source_name": str(row["source_name"] or ""),
"filename": str(row["filename"] or ""),
"mime_type": str(row["mime_type"] or ""),
"byte_size": int(row["byte_size"] or 0),
"content_sha256": str(row["content_sha256"] or ""),
"imported_at": row["imported_at"].isoformat() if row.get("imported_at") else None,
}
for row in cur.fetchall()
]
cur.execute(
"""
SELECT COUNT(*)::bigint AS total_members,
COUNT(*) FILTER (
WHERE COALESCE(BTRIM(work_status), '') <> '퇴직'
)::bigint AS active_members
FROM members
"""
)
member_row = cur.fetchone() or {}
cur.execute(
"""
SELECT COUNT(*)::bigint AS active_seat_maps
FROM seat_maps
WHERE is_active = TRUE
"""
)
seat_map_row = cur.fetchone() or {}
cur.execute(
"""
SELECT COUNT(*)::bigint AS fixed_office_maps
FROM seat_maps
WHERE source_type = 'fixed_html'
"""
)
fixed_office_row = cur.fetchone() or {}
overview = {
"visible_tables": len(DB_STATUS_TABLES),
"total_tables": len(table_items),
"existing_tables": sum(1 for item in table_items if item["exists"]),
"registered_members": int(member_row.get("total_members") or 0),
"active_members": int(member_row.get("active_members") or 0),
"active_seat_maps": int(seat_map_row.get("active_seat_maps") or 0),
"fixed_office_maps": int(fixed_office_row.get("fixed_office_maps") or 0),
"import_batches": len(import_batches),
"binary_sources": len(binary_sources),
}
group_summary = {
"유지": [item["table_ref"] for item in table_items if item["group"] == "유지"],
"주의": [item["table_ref"] for item in table_items if item["group"] == "주의"],
"원본·추적": [item["table_ref"] for item in table_items if item["group"] == "원본·추적"],
"정리 후보": [item["table_ref"] for item in table_items if item["group"] == "정리 후보"],
}
product_summary = {
group_name: table_refs
for group_name, table_refs in DB_STATUS_PRODUCT_GROUPS.items()
}
return {
"generated_at": datetime.utcnow().isoformat() + "Z",
"overview": overview,
"tables": table_items,
"import_batches": import_batches,
"binary_sources": binary_sources,
"group_summary": group_summary,
"product_summary": product_summary,
"screen_map": DB_STATUS_SCREEN_MAP,
"notes": [
"members / seat_positions / seat_maps 는 현재 운영 상태를 나타냅니다.",
"member_versions / seat_assignment_versions / history_revisions 는 시점 조회와 변경 이력을 위한 테이블입니다.",
"integration_raw_* / integration_* 는 원본 적재와 표준화 결과를 분리해서 보관합니다.",
"integration_binary_sources 는 사업관리대장 같은 바이너리 원본 보관용입니다.",
"DB를 물리적으로 합치기보다, 화면/권한/이력/로우데이터 관점으로 묶어 보는 것이 현재 운영에 더 적합합니다.",
"재직 인원은 조직현황과 동일하게 work_status 값이 '퇴직'이 아닌 구성원 기준입니다.",
],
}
def fetch_db_table_preview(schema_name: str, table_name: str, limit: int = 50) -> dict[str, object]:
if schema_name not in {"public", "auth"}:
raise HTTPException(status_code=404, detail="Unknown schema.")
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT tablename
FROM pg_tables
WHERE schemaname = %s
AND tablename = %s
""",
(schema_name, table_name),
)
exists_row = cur.fetchone()
if exists_row is None:
raise HTTPException(status_code=404, detail="Unknown table.")
table_ref = f"{schema_name}.{table_name}"
spec = DB_STATUS_TABLE_META.get(table_ref, {})
cur.execute(
"""
SELECT column_name, data_type
FROM information_schema.columns
WHERE table_schema = %s
AND table_name = %s
ORDER BY ordinal_position
""",
(schema_name, table_name),
)
columns = [{"name": str(row["column_name"]), "type": str(row["data_type"])} for row in cur.fetchall()]
cur.execute(f"SELECT COUNT(*)::bigint AS row_count FROM {schema_name}.{table_name}")
row_count = int((cur.fetchone() or {}).get("row_count") or 0)
safe_limit = max(1, min(int(limit), 50))
cur.execute(f"SELECT * FROM {schema_name}.{table_name} LIMIT {safe_limit}")
rows = [make_json_safe(dict(row)) for row in cur.fetchall()]
return {
"table_ref": table_ref,
"schema": schema_name,
"table_name": table_name,
"label": str(spec.get("label") or table_name),
"domain": str(spec.get("domain") or "other"),
"description": str(spec.get("description") or "세부 보조/원본/운영 테이블"),
"related_views": spec.get("related_views") or [],
"row_count": row_count,
"limit": safe_limit,
"columns": columns,
"rows": rows,
}

View File

@@ -281,6 +281,19 @@ CREATE TABLE IF NOT EXISTS integration_vouchers (
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS integration_binary_sources (
id BIGSERIAL PRIMARY KEY,
source_key TEXT NOT NULL UNIQUE,
source_name TEXT NOT NULL,
filename TEXT NOT NULL DEFAULT '',
mime_type TEXT NOT NULL DEFAULT 'application/octet-stream',
content BYTEA NOT NULL,
content_sha256 TEXT NOT NULL DEFAULT '',
meta_json JSONB NOT NULL DEFAULT '{}'::jsonb,
imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS history_revisions (
id BIGSERIAL PRIMARY KEY,
scope TEXT NOT NULL DEFAULT 'organization',
@@ -329,18 +342,6 @@ CREATE TABLE IF NOT EXISTS seat_assignment_versions (
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS entity_change_events (
id BIGSERIAL PRIMARY KEY,
entity_type TEXT NOT NULL,
entity_id BIGINT NOT NULL,
action_type TEXT NOT NULL,
revision_no BIGINT NOT NULL,
changed_by_user_id BIGINT,
changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
change_reason TEXT NOT NULL DEFAULT '',
patch_json JSONB NOT NULL DEFAULT '{}'::jsonb
);
CREATE SCHEMA IF NOT EXISTS auth;
CREATE TABLE IF NOT EXISTS auth.users (
@@ -534,6 +535,9 @@ ON integration_vouchers (project_code, project_name);
CREATE UNIQUE INDEX IF NOT EXISTS integration_project_category_mappings_key_idx
ON integration_project_category_mappings (source_key, normalized_project_key);
CREATE UNIQUE INDEX IF NOT EXISTS integration_binary_sources_source_key_idx
ON integration_binary_sources (source_key);
CREATE INDEX IF NOT EXISTS member_versions_member_time_idx
ON member_versions (member_id, valid_from, valid_to);
@@ -543,9 +547,6 @@ ON seat_assignment_versions (member_id, valid_from, valid_to);
CREATE INDEX IF NOT EXISTS history_revisions_scope_created_idx
ON history_revisions (scope, created_at DESC);
CREATE INDEX IF NOT EXISTS entity_change_events_entity_idx
ON entity_change_events (entity_type, entity_id, changed_at DESC);
DO $$
BEGIN
IF NOT EXISTS (
@@ -611,6 +612,9 @@ ALTER TABLE auth.login_audit_logs ADD COLUMN IF NOT EXISTS user_id BIGINT NULL R
ALTER TABLE auth.login_audit_logs ADD COLUMN IF NOT EXISTS failure_reason TEXT;
ALTER TABLE auth.login_audit_logs ADD COLUMN IF NOT EXISTS ip_address INET;
ALTER TABLE auth.login_audit_logs ADD COLUMN IF NOT EXISTS user_agent TEXT;
DROP INDEX IF EXISTS entity_change_events_entity_idx;
DROP TABLE IF EXISTS entity_change_events;
"""

View File

@@ -0,0 +1,110 @@
from __future__ import annotations
import hashlib
import json
from pathlib import Path
from fastapi import HTTPException
from fastapi.responses import FileResponse, Response
BUSINESS_LEDGER_DEFAULT_SOURCE_KEY = "business_ledger_default"
def sync_default_business_ledger_source(cur, incoming_files_dir: Path, served_dir: Path) -> None:
cur.execute("SELECT to_regclass('public.integration_binary_sources') IS NOT NULL AS table_exists")
row = cur.fetchone()
table_exists = bool(row["table_exists"]) if row is not None else False
if not table_exists:
return
business_dashboard_dir = incoming_files_dir / "사업관리대장"
business_ledger_served_dir = served_dir / "ledger"
candidates = [
business_ledger_served_dir / "사업관리대장-1.xlsx",
business_dashboard_dir / "사업관리대장-1.xlsx",
business_dashboard_dir / "사업관리 대장-1.xlsx",
business_dashboard_dir / "사업관리대장.xlsx",
business_dashboard_dir / "사업관리 대장.xlsx",
]
source_path = next((candidate for candidate in candidates if candidate.exists()), None)
if source_path is None:
return
content = source_path.read_bytes()
content_sha256 = hashlib.sha256(content).hexdigest()
meta_json = {
"byte_size": len(content),
"source_path": str(source_path),
"synced_from": "startup",
}
cur.execute(
"""
INSERT INTO integration_binary_sources (
source_key, source_name, filename, mime_type, content, content_sha256, meta_json, imported_at
)
VALUES (%s, %s, %s, %s, %s, %s, %s::jsonb, NOW())
ON CONFLICT (source_key) DO UPDATE
SET source_name = EXCLUDED.source_name,
filename = EXCLUDED.filename,
mime_type = EXCLUDED.mime_type,
content = EXCLUDED.content,
content_sha256 = EXCLUDED.content_sha256,
meta_json = EXCLUDED.meta_json,
imported_at = NOW()
WHERE integration_binary_sources.content_sha256 IS DISTINCT FROM EXCLUDED.content_sha256
OR integration_binary_sources.filename IS DISTINCT FROM EXCLUDED.filename
OR integration_binary_sources.mime_type IS DISTINCT FROM EXCLUDED.mime_type
OR integration_binary_sources.meta_json IS DISTINCT FROM EXCLUDED.meta_json
""",
(
BUSINESS_LEDGER_DEFAULT_SOURCE_KEY,
"사업관리대장 기본 원본",
source_path.name,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
content,
content_sha256,
json.dumps(meta_json, ensure_ascii=False),
),
)
def build_business_ledger_default_response(cur) -> Response:
cur.execute(
"""
SELECT filename, mime_type, content
FROM integration_binary_sources
WHERE source_key = %s
ORDER BY imported_at DESC
LIMIT 1
""",
(BUSINESS_LEDGER_DEFAULT_SOURCE_KEY,),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Business ledger default source not found.")
filename = str(row["filename"] or "사업관리대장-1.xlsx")
headers = {
"Content-Disposition": 'inline; filename="business-ledger-default.xlsx"',
"X-Source-Filename": "business-ledger-default.xlsx",
"X-Original-Filename": filename,
"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
"Pragma": "no-cache",
}
return Response(
content=bytes(row["content"]),
media_type=str(
row["mime_type"] or "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
),
headers=headers,
)
def build_ledger_index_response(ledger_index_path: Path) -> FileResponse:
if not ledger_index_path.exists():
raise HTTPException(status_code=404, detail="Business ledger integration file not found.")
response = FileResponse(ledger_index_path)
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
response.headers["Pragma"] = "no-cache"
return response

View File

@@ -21,13 +21,19 @@ import ezdxf
from ezdxf import recover
from fastapi import FastAPI, File, Form, Header, HTTPException, Request, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, HTMLResponse, Response
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from openpyxl import load_workbook
from pydantic import BaseModel, Field
from .config import BASE_DIR, LEGACY_DIR, MOCK_LOGIN_ENABLED, UPLOAD_DIR
from .db import get_conn, init_db
from .ledger_runtime import (
build_business_ledger_default_response,
build_ledger_index_response,
sync_default_business_ledger_source,
)
from .system_routes import register_system_routes
app = FastAPI(title="MH Dashboard Organization API")
@@ -44,7 +50,7 @@ LEGACY_STATIC_DIR = LEGACY_DIR / "static"
INCOMING_FILES_DIR = BASE_DIR / "incoming-files"
INCOMING_SERVED_DIR = INCOMING_FILES_DIR / "served"
INCOMING_REFERENCE_DIR = INCOMING_FILES_DIR / "reference"
BUSINESS_DASHBOARD_DIR = INCOMING_FILES_DIR / "사업관리대장"
DB_STATUS_SERVED_DIR = INCOMING_SERVED_DIR / "db-status"
BUSINESS_LEDGER_SERVED_DIR = INCOMING_SERVED_DIR / "ledger"
BUSINESS_LEDGER_INDEX_PATH = BUSINESS_LEDGER_SERVED_DIR / "index.html"
FIXED_OFFICE_SOURCE_KEY = "technical-development-center"
@@ -66,7 +72,6 @@ FIXED_OFFICE_CONFIGS = {
},
}
_fixed_office_cache: dict[str, dict[str, object]] = {}
BUSINESS_LEDGER_DEFAULT_SOURCE_KEY = "business_ledger_default"
AUTH_DEFAULT_PASSWORD = "1111"
AUTH_PASSWORD_ITERATIONS = 390000
AUTH_SESSION_HOURS = 12
@@ -88,60 +93,6 @@ MH_HEADER_ORDER = [
"사업 종류", "연장근무 프로젝트 코드", "연장근무 프로젝트명", "연장근무 서브코드", "연장근무 시간(실제)", "연장근무 시간(가공)"
]
def sync_default_business_ledger_source(cur) -> None:
cur.execute("SELECT to_regclass('public.integration_binary_sources') IS NOT NULL AS table_exists")
row = cur.fetchone()
table_exists = bool(row["table_exists"]) if row is not None else False
if not table_exists:
return
candidates = [
BUSINESS_LEDGER_SERVED_DIR / "사업관리대장-1.xlsx",
BUSINESS_DASHBOARD_DIR / "사업관리대장-1.xlsx",
BUSINESS_DASHBOARD_DIR / "사업관리 대장-1.xlsx",
BUSINESS_DASHBOARD_DIR / "사업관리대장.xlsx",
BUSINESS_DASHBOARD_DIR / "사업관리 대장.xlsx",
]
source_path = next((candidate for candidate in candidates if candidate.exists()), None)
if source_path is None:
return
content = source_path.read_bytes()
content_sha256 = hashlib.sha256(content).hexdigest()
meta_json = {
"byte_size": len(content),
"source_path": str(source_path),
"synced_from": "startup",
}
cur.execute(
"""
INSERT INTO integration_binary_sources (
source_key, source_name, filename, mime_type, content, content_sha256, meta_json, imported_at
)
VALUES (%s, %s, %s, %s, %s, %s, %s::jsonb, NOW())
ON CONFLICT (source_key) DO UPDATE
SET source_name = EXCLUDED.source_name,
filename = EXCLUDED.filename,
mime_type = EXCLUDED.mime_type,
content = EXCLUDED.content,
content_sha256 = EXCLUDED.content_sha256,
meta_json = EXCLUDED.meta_json,
imported_at = NOW()
WHERE integration_binary_sources.content_sha256 IS DISTINCT FROM EXCLUDED.content_sha256
OR integration_binary_sources.filename IS DISTINCT FROM EXCLUDED.filename
OR integration_binary_sources.mime_type IS DISTINCT FROM EXCLUDED.mime_type
OR integration_binary_sources.meta_json IS DISTINCT FROM EXCLUDED.meta_json
""",
(
BUSINESS_LEDGER_DEFAULT_SOURCE_KEY,
"사업관리대장 기본 원본",
source_path.name,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
content,
content_sha256,
json.dumps(meta_json, ensure_ascii=False),
),
)
app.mount(
"/integrations/ledger-assets",
StaticFiles(directory=str(BUSINESS_LEDGER_SERVED_DIR), check_dir=False),
@@ -3976,65 +3927,26 @@ def startup() -> None:
init_db()
with get_conn() as conn:
with conn.cursor() as cur:
sync_default_business_ledger_source(cur)
sync_default_business_ledger_source(cur, INCOMING_FILES_DIR, INCOMING_SERVED_DIR)
sync_auth_users_from_members(cur)
conn.commit()
app.mount("/legacy/static", StaticFiles(directory=LEGACY_STATIC_DIR, check_dir=False), name="legacy-static")
@app.get("/api/health")
def health() -> dict[str, object]:
checks = {
"upload_dir": UPLOAD_DIR.exists(),
}
try:
member_count = get_member_count()
checks["database"] = True
except Exception:
member_count = None
checks["database"] = False
status = "ok" if all(checks.values()) else "degraded"
return {
"status": status,
"checks": checks,
"member_count": member_count,
"timestamp": datetime.utcnow().isoformat() + "Z",
}
@app.get("/api/integration/business-ledger-default")
def integration_business_ledger_default() -> Response:
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT filename, mime_type, content
FROM integration_binary_sources
WHERE source_key = %s
ORDER BY imported_at DESC
LIMIT 1
""",
(BUSINESS_LEDGER_DEFAULT_SOURCE_KEY,),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Business ledger default source not found.")
filename = str(row["filename"] or "사업관리대장-1.xlsx")
headers = {
"Content-Disposition": 'inline; filename="business-ledger-default.xlsx"',
"X-Source-Filename": "business-ledger-default.xlsx",
"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
"Pragma": "no-cache",
}
return Response(
content=bytes(row["content"]),
media_type=str(row["mime_type"] or "application/octet-stream"),
headers=headers,
)
register_system_routes(
app,
upload_dir=UPLOAD_DIR,
legacy_dir=LEGACY_DIR,
incoming_files_dir=INCOMING_FILES_DIR,
incoming_served_dir=INCOMING_SERVED_DIR,
db_status_served_dir=DB_STATUS_SERVED_DIR,
business_ledger_index_path=BUSINESS_LEDGER_INDEX_PATH,
get_member_count=get_member_count,
get_conn=get_conn,
build_business_ledger_default_response=build_business_ledger_default_response,
build_ledger_index_response=build_ledger_index_response,
)
@app.post("/api/auth/login")
@@ -4359,18 +4271,6 @@ def integration_mh_source() -> dict[str, object]:
return fetch_mh_source_rows()
@app.get("/api/integration/mh-workbook")
def integration_mh_workbook() -> FileResponse:
target = INCOMING_FILES_DIR / "MH.xlsx"
if not target.exists():
raise HTTPException(status_code=404, detail="MH workbook not found.")
return FileResponse(
target,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
filename="MH.xlsx",
)
@app.post("/api/uploads/profile-photo")
def upload_profile_photo(file: UploadFile = File(...), member_name: str = Form("")) -> dict[str, str]:
suffix = Path(file.filename or "").suffix.lower()
@@ -4578,58 +4478,3 @@ def get_seat_map_viewer(seat_map_id: int, as_of: str | None = None) -> HTMLRespo
@app.put("/api/seat-maps/{seat_map_id}/layout")
def update_seat_layout(seat_map_id: int, payload: SeatLayoutPayload) -> dict[str, list[dict[str, object]]]:
return {"items": save_seat_layout(seat_map_id, payload)}
@app.get("/legacy/organization")
def legacy_organization() -> FileResponse:
target = LEGACY_DIR / "DashBoard-organization.html"
if not target.exists():
raise HTTPException(status_code=404, detail="Legacy dashboard file not found.")
return FileResponse(target)
@app.get("/legacy/organization-backup")
def legacy_organization_backup() -> FileResponse:
target = LEGACY_DIR / "DashBoard-organization-backup.html"
if not target.exists():
raise HTTPException(status_code=404, detail="Legacy dashboard backup not found.")
return FileResponse(target)
@app.get("/integrations/payment")
def integration_payment() -> FileResponse:
# 8081 phase-1 cleanup: integration HTML is served only from incoming-files/served.
target = INCOMING_SERVED_DIR / "payment.html"
if not target.exists():
raise HTTPException(status_code=404, detail="Payment integration file not found.")
return FileResponse(target)
@app.get("/integrations/ledger")
def integration_ledger() -> FileResponse:
# #21 phase-1: runtime no longer decodes reference wrapper HTML. Serve the promoted
# ledger entry file from incoming-files/served/ledger only.
target = BUSINESS_LEDGER_INDEX_PATH
if not target.exists():
raise HTTPException(status_code=404, detail="Business ledger integration file not found.")
response = FileResponse(target)
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
response.headers["Pragma"] = "no-cache"
return response
@app.get("/integrations/mh")
def integration_mh() -> FileResponse:
# Keep the served path explicit so comparison/reference copies are never picked up by accident.
target = INCOMING_SERVED_DIR / "mh.html"
if not target.exists():
raise HTTPException(status_code=404, detail="MH integration file not found.")
return FileResponse(target)
@app.get("/uploads/{filename}")
def get_upload(filename: str) -> FileResponse:
target = UPLOAD_DIR / filename
if not target.exists():
raise HTTPException(status_code=404, detail="Upload not found.")
return FileResponse(target)

View File

@@ -0,0 +1,120 @@
from __future__ import annotations
from datetime import datetime
from pathlib import Path
from typing import Callable
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse, Response
from .admin_db_status import fetch_db_status_snapshot, fetch_db_table_preview
def register_system_routes(
app: FastAPI,
*,
upload_dir: Path,
legacy_dir: Path,
incoming_files_dir: Path,
incoming_served_dir: Path,
db_status_served_dir: Path,
business_ledger_index_path: Path,
get_member_count: Callable[[], int],
get_conn,
build_business_ledger_default_response: Callable[[object], Response],
build_ledger_index_response: Callable[[Path], FileResponse],
) -> None:
@app.get("/api/health")
def health() -> dict[str, object]:
checks = {
"upload_dir": upload_dir.exists(),
}
try:
member_count = get_member_count()
checks["database"] = True
except Exception:
member_count = None
checks["database"] = False
status = "ok" if all(checks.values()) else "degraded"
return {
"status": status,
"checks": checks,
"member_count": member_count,
"timestamp": datetime.utcnow().isoformat() + "Z",
}
@app.get("/api/admin/db-status")
def admin_db_status() -> dict[str, object]:
return fetch_db_status_snapshot()
@app.get("/api/admin/db-status/table")
def admin_db_status_table(schema: str, table: str, limit: int = 50) -> dict[str, object]:
return fetch_db_table_preview(schema, table, limit)
@app.get("/admin/db-status")
def admin_db_status_view() -> FileResponse:
target = db_status_served_dir / "index.html"
if not target.exists():
raise HTTPException(status_code=404, detail="DB status dashboard file not found.")
response = FileResponse(target)
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
response.headers["Pragma"] = "no-cache"
return response
@app.get("/api/integration/business-ledger-default")
def integration_business_ledger_default() -> Response:
with get_conn() as conn:
with conn.cursor() as cur:
return build_business_ledger_default_response(cur)
@app.get("/api/integration/mh-workbook")
def integration_mh_workbook() -> FileResponse:
target = incoming_files_dir / "MH.xlsx"
if not target.exists():
raise HTTPException(status_code=404, detail="MH workbook not found.")
return FileResponse(
target,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
filename="MH.xlsx",
)
@app.get("/legacy/organization")
def legacy_organization() -> FileResponse:
target = legacy_dir / "DashBoard-organization.html"
if not target.exists():
raise HTTPException(status_code=404, detail="Legacy dashboard file not found.")
return FileResponse(target)
@app.get("/legacy/organization-backup")
def legacy_organization_backup() -> FileResponse:
target = legacy_dir / "DashBoard-organization-backup.html"
if not target.exists():
raise HTTPException(status_code=404, detail="Legacy dashboard backup not found.")
return FileResponse(target)
@app.get("/integrations/payment")
def integration_payment() -> FileResponse:
target = incoming_served_dir / "payment.html"
if not target.exists():
raise HTTPException(status_code=404, detail="Payment integration file not found.")
return FileResponse(target)
@app.get("/integrations/ledger")
def integration_ledger() -> FileResponse:
return build_ledger_index_response(business_ledger_index_path)
@app.get("/integrations/mh")
def integration_mh() -> FileResponse:
target = incoming_served_dir / "mh.html"
if not target.exists():
raise HTTPException(status_code=404, detail="MH integration file not found.")
return FileResponse(target)
@app.get("/uploads/{filename}")
def get_upload(filename: str) -> FileResponse:
target = upload_dir / filename
if not target.exists():
raise HTTPException(status_code=404, detail="Upload not found.")
return FileResponse(target)

View File

@@ -228,354 +228,10 @@
- [HISTORY_ASOF_DB_PLAN.md](/home/hyunho/projects/mh-dashboard-organization/docs/HISTORY_ASOF_DB_PLAN.md)
## 12. 2026-04-01 구조 안정화, DB 가시화, 자리배치도 정리
### 왜 이 작업을 했는가
이번 작업의 목적은 새 기능을 더 붙이기 전에, 지금까지 쌓인 구조를 먼저 안정적으로 정리하는 것이었다.
겉으로 보기에는 화면이 어느 정도 동작하고 있었지만, 실제 내부는 다음과 같은 위험이 있었다.
- 화면마다 구현 방식이 달라서 어디를 수정해야 하는지 바로 알기 어려움
- 원본 참고 파일과 실제 서비스 파일이 섞여 있어, 작업할수록 다시 꼬일 가능성이 큼
- DB는 이미 중요한 역할을 하고 있었지만, 비개발자 입장에서는 "정말 저장이 되고 있는가", "무엇이 들어 있는가"를 직접 확인하기 어려움
- 구조를 건드릴 때 사업관리대장처럼 예상하지 못한 회귀가 생길 수 있었음
즉, 이번 작업은 "새 기능 추가"보다 "앞으로 기능을 안전하게 추가할 수 있는 바닥공사"에 가까웠다.
### 무엇을 바꿨는가
이번에는 크게 다섯 가지 축으로 정리했다.
1. 디자인과 화면 구조 기준 정리
2. 실제 서비스 코드와 참고 원본 파일 분리
3. 백엔드 라우트 구조 분리
4. DB 상태를 눈으로 볼 수 있는 운영 화면 추가
5. 자리배치도 실사용성 개선과 회귀 방지 장치 추가
이 작업은 단순 정리처럼 보일 수 있지만, 실제로는 "어디가 진짜 기준인지"를 다시 세우는 과정이었다.
추가로, 사용자가 실제로 가장 자주 보는 상단 탭 경험도 함께 다시 손봤다.
이번에 정리한 상단 주요 화면은 다음과 같다.
- 사업관리대장
- 프로젝트별 분석
- 팀/개인별 분석
- 조직현황
이 네 화면은 이전까지는 각각 따로 발전해 온 흔적이 강했다.
즉, 같은 시스템 안에 있지만 화면마다 표정이 달랐고, 어떤 화면은 오래된 파란 톤이 남아 있었고, 어떤 화면은 새 스타일이 일부만 적용되어 있었다.
이번에는 이 네 화면을 "각자 따로 만들어진 페이지"가 아니라 "하나의 대시보드 안에 있는 연결된 기능"처럼 보이도록 맞추는 작업도 함께 진행했다.
### 리팩토링을 왜 했는가
기존에는 하나의 파일이나 하나의 화면이 너무 많은 역할을 동시에 맡고 있었다.
예를 들어 백엔드 메인 파일은 인증, 멤버, 통합 데이터, 정적 파일 서빙, 자리배치도까지 한곳에 몰려 있었고, 프런트도 화면에 따라 원본 파일을 직접 쓰는 곳과 override를 덧씌우는 곳이 섞여 있었다.
이 구조는 처음엔 빠르게 화면을 올리는 데 도움이 되지만, 일정 시점이 지나면 문제가 생긴다.
- 작은 수정이 예상치 못한 다른 화면에 영향을 줄 수 있음
- 회귀 원인을 찾는 데 시간이 오래 걸림
- 새 작업자가 들어오면 전체 구조를 이해하기 어려움
- 특정 파일이 "원본인지", "실행본인지", "참고용 복사본인지" 헷갈리게 됨
그래서 이번에는 "기능을 더 붙이기 전에 구조를 분리하는 것"을 우선했다.
### 리팩토링을 어떻게 진행했는가
#### 1. 실제 서비스 코드와 참고 원본을 분리
사업관리대장, 프로젝트별 분석, 팀/개인별 분석은 처음엔 원본 파일, 참고 파일, 실제 서비스 파일이 섞여 있는 상태였다.
이 상태에서는 수정할 때마다 "지금 내가 만지는 파일이 실제 서비스에 반영되는 파일이 맞는가"를 계속 확인해야 했다.
그래서 다음 기준으로 재정리했다.
- `reference`: 비교와 복구를 위한 참고 원본
- `served`: 실제 서비스가 읽는 런타임 파일
- `frontend/apps/*`: 앞으로 수정해야 하는 앱 소스
특히 `ledger`, `payment`, `team` 화면은 모두 `app source -> publish -> served` 구조로 다시 맞췄다.
이 의미는 다음과 같다.
- 작업자는 원본 참고 파일을 직접 수정하지 않는다
- 앱 소스에서 수정한다
- publish 스크립트로 실제 서비스 파일을 만든다
- 백엔드는 이 실제 서비스 파일만 서빙한다
이렇게 하면 나중에 유지보수할 때 "수정 원본"과 "실행 결과물"이 명확히 나뉜다.
#### 2. 디자인 기준을 공통 SSOT로 승격
이전에는 각 화면에 과거 파란 톤, 임시 색상, override 스타일이 섞여 있었다.
그래서 어떤 화면은 새 디자인 규칙을 따르는데, 어떤 화면은 예전 색이 다시 튀어나오는 문제가 반복됐다.
이번에는 이를 막기 위해 다음 기준을 승격했다.
- `design-tokens.css`
- `design-patterns.css`
- `DESIGN_SSOT.md`
즉, 앞으로 디자인 수정은 "이 화면만 예쁘게"가 아니라 "공통 디자인 규칙 안에서 일관되게" 하는 방향으로 정리했다.
비개발자 관점에서는 "화면마다 조금씩 다른 앱"처럼 보이던 것을, "하나의 시스템처럼 보이게" 만드는 작업이었다고 볼 수 있다.
이 과정에서 실제로 한 작업은 다음과 같다.
- 사업관리대장, 프로젝트별 분석, 팀/개인별 분석, 조직현황의 메인 폭을 같은 기준으로 맞춤
- 공통 카드, 버튼, KPI, 표, 팝업의 색과 대비를 비슷한 문법으로 정리
- 과거 파란 계열이 다시 드러나는 부분을 찾아 공통 토큰 기준으로 재정리
- 각 화면에서 "지금 당장 보기 좋게" 끝내지 않고, 앞으로도 같은 규칙을 따라갈 수 있도록 공통 패턴으로 승격
특히 프로젝트별 분석과 팀/개인별 분석은 원래 화면 내부에 이전 스타일 흔적이 많이 남아 있었는데, 이번에는 이 부분을 단순 덮어쓰기보다 "기준 디자인을 바라보게 만드는 방향"으로 손봤다.
#### 2-1. 왜 네 개 탭을 먼저 다시 맞췄는가
이번 세션에서는 단순 리팩토링만 한 것이 아니다.
사용자가 실제로 매일 보는 네 개 주요 탭의 경험을 먼저 안정화하는 것이 중요했다.
그 이유는 다음과 같다.
- 화면마다 스타일이 다르면 사용자는 기능이 다른 것보다 "시스템이 불안정하다"는 인상을 먼저 받음
- 새 기능을 추가할 때마다 이전 스타일이 다시 나타나면, 작업 결과가 누적되지 않고 계속 되돌아감
- 세미나나 설명 자리에서도 "정리되고 있다"는 느낌을 전달하려면, 먼저 눈에 보이는 화면이 하나의 제품처럼 보여야 함
그래서 이번에는 단순히 코드 구조를 정리하는 것과 함께, 네 개 탭의 인상과 문법을 맞추는 작업도 같이 진행했다.
#### 2-2. 사업관리대장은 어디까지 손봤는가
사업관리대장은 이번 세션에서 가장 많은 변화가 있었던 화면 중 하나다.
- 상단 탭에서 직접 열리도록 연결
- 기본 로우데이터 엑셀과 연동
- 원본 화면 구조를 참고해 연도 버튼, KPI, 본문 표, 상세 팝업까지 단계적으로 복원
- 클릭 시 프로젝트 상세 정보를 열 수 있게 연결
- 메인 화면과 상세 팝업 디자인을 현재 디자인 큐에 맞게 정리
다만 중요한 점은, 이번에 맞춘 것은 "보이는 구조와 기본 기능"까지라는 것이다.
세부 숫자와 집계 기준, 어떤 값이 어떻게 계산되는지는 원본 작성자 기준 확인이 필요해 후속으로 남겨 두었다.
즉, 이번에는 사업관리대장을 "쓸 수 있는 상태"까지 올렸고, 다음 단계에서 "정확한 상태"로 맞출 준비를 끝낸 것이다.
#### 2-3. 프로젝트별 분석과 팀/개인별 분석은 무엇이 바뀌었는가
두 화면은 모두 이미 기능은 있었지만, 디자인과 유지보수 구조가 흔들리는 상태였다.
이번에 바뀐 점은 다음과 같다.
- 프로젝트별 분석
- 메인 표, KPI, 필터, 패널, 상세 강조 색을 공통 디자인 기준으로 재정리
- 실제 서비스 파일과 수정 원본의 기준을 명확히 분리
- 팀/개인별 분석
- 배경, 카드, 보조 정보, 캘린더 note, 상태 표현 등을 공통 디자인 기준으로 재정리
- 과거 스타일 흔적을 줄이고, 앞으로도 같은 방식으로 고칠 수 있는 구조로 이동
즉, 두 화면 모두 "이번 한 번 예쁘게 고친" 것이 아니라 "앞으로도 같은 기준으로 유지될 수 있게" 손봤다는 점이 중요하다.
#### 2-4. 조직현황은 무엇이 바뀌었는가
조직현황은 기존에도 중요한 화면이었지만, 스타일과 인터랙션이 다소 오래된 느낌으로 남아 있었다.
이번에는 다음을 정리했다.
- 상세 프로필, 수정 모달, 버튼, 카드, 탭, 통계 영역의 색과 대비 조정
- 관리자 모드 버튼, 추가 버튼, 상세 정보 패널의 톤 정리
- 자리배치도와 연결되는 미리보기 카드, 조직 구조 표현 가독성 개선
즉, 조직현황은 단순 디자인 수정이 아니라 "관리자가 실제로 쓰는 화면"으로서 읽기 편하게 정리하는 방향으로 손봤다.
#### 3. 백엔드 메인 파일의 역할 분리
백엔드도 한 파일에 너무 많은 기능이 몰려 있었다.
그래서 메인 파일에서 기능별 라우트를 분리했다.
이번에 분리한 범위는 다음과 같다.
- 시스템/서빙 라우트
- 인증 라우트
- 멤버/히스토리 라우트
- 통합 데이터 라우트
- 자리배치도/업로드 라우트
이 작업을 통해 얻은 가장 큰 장점은 "문제가 났을 때 어디를 봐야 하는지가 빨라졌다"는 점이다.
예전에는 메인 파일을 전체 검색해야 했다면, الآن은 인증 문제면 인증 파일을, 자리배치도 문제면 자리배치도 라우트 파일을 먼저 보면 된다.
### DB 작업을 왜 했는가
이번 세션에서 DB 작업을 한 이유는 "DB가 이상해서"가 아니라, "DB가 이미 중요한 역할을 하고 있는데 너무 안 보였다"는 점 때문이다.
실제로는 이미 많은 데이터가 DB에 저장되고 있었다.
- 구성원 정보
- 자리배치도 정보
- 통합 원본 적재 정보
- 인증 정보
- 이력 관련 테이블
하지만 비개발자 입장에서는 이것이 잘 보이지 않았다.
즉, "DB가 있다"고만 듣고 실제로 어떤 테이블이 있고 무슨 역할인지 보지 못하면, 운영 기준을 잡기 어렵다.
그래서 이번에는 DB를 "보이지 않는 저장소"에서 "운영자가 확인할 수 있는 대상"으로 바꾸는 작업을 했다.
### DB 작업을 어떻게 했는가
#### 1. DB 상태 탭 추가
허브 안에 `DB 상태` 탭을 만들었다.
이 화면에서는 다음을 확인할 수 있다.
- 전체 테이블 수
- 등록 인원/재직 인원
- 자리배치도 도면 현황
- 핵심 운영 테이블과 전체 테이블 목록
- 테이블별 간단 설명
- 테이블 클릭 시 컬럼과 샘플 데이터 미리보기
- CSV 다운로드
즉, 이제는 SQL을 직접 몰라도 "어떤 데이터가 어디에 저장되는지"를 눈으로 볼 수 있다.
#### 2. 테이블 역할 분류
전체 테이블을 그냥 나열만 하면 오히려 더 복잡해 보이기 때문에, 역할별로 다시 분류했다.
- 유지
- 주의
- 원본/추적
- 정리 후보
이 분류를 통해 "지금 DB가 너무 큰가?"라는 질문에 대해, 단순 개수 대신 역할 기준으로 판단할 수 있게 만들었다.
#### 3. 불필요한 테이블과 과거 실험 흔적 정리
이번에 실제로 확인해보니, 현재 코드에서 쓰지 않는 테이블이 하나 있었고, 과거 DXF 시도본도 많이 쌓여 있었다.
그래서 다음 정리를 진행했다.
- 미사용 테이블 `entity_change_events` 삭제
- 과거 DXF 시도본 정리
- 최신 DXF 1개와 실제 운영용 고정 도면 3개만 유지
이 작업은 "DB를 줄였다"기보다 "운영에 필요한 것과 과거 흔적을 분리했다"는 의미에 가깝다.
#### 4. 8080과 8081의 역할도 다시 정리
이번 세션에서는 개발용 `8081`에서 검증된 코드 중, 안정적으로 승격 가능한 부분만 `8080` 기준 코드로 올리는 작업도 진행했다.
여기서 중요한 원칙은 "통째로 덮어쓰기"가 아니라 "검증된 것만 선별 승격"이었다.
즉 다음 원칙을 지켰다.
- `8081`은 계속 작업과 검증을 위한 공간으로 유지
- `8080`은 공개 기준으로 유지
- 디자인 SSOT, 앱 소스 구조, 런타임 서빙 구조처럼 안정성이 확인된 부분만 `total`로 승격
- DB 자체는 함부로 합치지 않고, 코드와 구조만 먼저 정리
이렇게 해야 운영 기준을 흔들지 않으면서도, 개선된 구조를 실제 기준 코드에 반영할 수 있다.
### 무엇이 개선되었는가
이번 작업으로 개선된 점은 매우 명확하다.
#### 1. 유지보수 포인트가 분명해졌다
예전에는 같은 기능을 수정해도 어디를 건드려야 하는지 여러 파일을 동시에 의심해야 했다.
지금은 앱 소스, 서비스 파일, 참고 원본의 역할이 나뉘어서 수정 위치가 명확해졌다.
#### 2. 화면 회귀를 더 빨리 잡을 수 있게 됐다
사업관리대장 데이터가 한 번 끊겼을 때 원인은 DB 문제가 아니라, 한글 파일명을 응답 헤더에 그대로 넣으면서 생긴 인코딩 오류였다.
이런 문제는 구조가 정리돼 있지 않으면 찾는 데 오래 걸린다.
이번에는 원인을 빠르게 좁혀서 복구했고, 같은 문제가 다시 생기지 않도록 `8081` smoke check 스크립트도 추가했다.
즉, 이제는 구조를 바꾼 뒤 바로 핵심 화면과 API를 빠르게 점검할 수 있다.
#### 3. DB를 설명 가능한 상태로 만들었다
이전에는 "DB가 있다"는 사실만 있었고, 실제로 어떤 상태인지 보기 어려웠다.
이제는 운영자가 DB 상태를 화면으로 확인하고, 테이블을 눌러 실제 샘플 데이터를 볼 수 있다.
세미나나 내부 설명 자리에서도 훨씬 설명하기 쉬운 상태가 됐다.
#### 4. 자리배치도 기능이 실사용 방향으로 조금 더 진전됐다
자리배치도에서는 다음이 개선됐다.
- 클릭한 인원의 상위 조직 트리 표시
- 검색 카드 동작 정리
- 인원 카드 정보 구조 정리
- 비관리자 모드 재렌더 안정화
- 미배치/배치 상태 시각화 기준 정리 준비
- 팀 구역 오버레이 기능 시도와 요구사항 정리
즉, 단순히 "보이는 화면"이 아니라, 실제 조직과 사람을 읽기 쉬운 화면으로 한 걸음 더 나아갔다.
#### 5. 회귀 방지 체계를 붙였다
이번 세션에서 중요한 개선 중 하나는 "문제가 생긴 뒤 찾는 방식"에서 "문제가 생겼는지 바로 확인하는 방식"으로 한 걸음 이동한 점이다.
이를 위해 `8081` smoke check 스크립트를 추가했다.
이 스크립트는 다음을 한 번에 점검한다.
- 서버 health
- DB 상태 화면
- 사업관리대장 기본 원본 API
- 프로젝트별 분석
- 팀/개인별 분석
- 사업관리대장
- 조직현황 연결
즉, 구조를 고친 뒤 "겉으로는 멀쩡해 보이는데 실제로는 한 기능이 깨져 있는 상태"를 빨리 잡을 수 있게 된 것이다.
### 오늘 확인된 문제와 한계
이번 작업이 모든 것을 끝낸 것은 아니다.
오히려 구조를 정리하면서, 앞으로 무엇을 더 손봐야 하는지도 더 분명해졌다.
#### 1. 사업관리대장 세부 데이터 정합성은 아직 보류
사업관리대장은 디자인과 기본 기능 연결은 올라왔지만, 세부 수치와 표출 규칙은 원본 작성자와 기준을 맞춰야 한다.
즉, "대충 맞아 보이는 수준"이 아니라 "원본 의도와 동일한 수준"으로 맞추려면 담당자 확인이 필요하다.
#### 2. 자리배치도 `#7`은 아직 재작업 필요
팀 구역 오버레이 기능은 의도 자체는 맞게 해석했고 데이터도 들어가지만, 화면에서 반짝 나타났다가 사라지는 문제가 남아 있다.
즉, 기능 방향은 맞지만 렌더링 타이밍이나 레이어 처리에서 다시 손봐야 한다.
#### 3. 조직현황은 아직 앱 구조로 완전히 승격되지 않음
`ledger`, `payment`, `team`은 앱 소스 구조로 정리했지만, 조직현황은 아직 레거시 구조를 유지하고 있다.
장기적으로는 이것도 같은 기준으로 승격하는 것이 맞다.
### 앞으로 남은 목표
이번 작업 이후의 목표는 다음과 같다.
#### 1. 사업관리대장 기준 정렬 후 정합성 보정
원본 작성자와 함께 세부 데이터 표출 규칙, KPI 집계 방식, 상세 팝업 기준을 확인한 뒤 정확도를 맞춘다.
#### 2. 자리배치도 `#7`, `#8` 완성
- 팀 구역 오버레이를 안정적으로 보이게 수정
- 배치/미배치 시각 규칙 정리
- 검색과 클릭 시 정보 노출 방식 마무리
#### 3. 백엔드 정리 후속
라우트 분리는 많이 진행됐지만, 장기적으로는 도메인 로직까지 더 분리해서 유지보수성을 높일 필요가 있다.
#### 4. DB 운영 문서와 상태 화면 고도화
지금은 DB를 "볼 수 있게 만든" 단계다.
앞으로는 화면별 데이터 흐름, 적재 이력, 원본 로우데이터 확인 기능까지 더 강화하면 운영 설명력이 더 올라간다.
#### 5. 네 개 주요 탭의 공통 문법을 계속 지켜야 한다
이번에 디자인과 구조를 다시 맞췄다고 해서 끝난 것은 아니다.
앞으로 새 기능을 넣을 때도 각 화면이 제각각 다른 방식으로 다시 흩어지지 않게 유지해야 한다.
즉, 이번 작업의 진짜 성과는 "한 번 예쁘게 고쳤다"가 아니라 "앞으로도 같은 방식으로 고칠 수 있는 기준을 세웠다"는 데 있다.
## Next Focus
- 사업관리대장 원본 담당자와 세부 데이터 규칙
- 자리배치도 `#7`, `#8` 재작업 및 마무리
- `#2` 영속성 운영 검증과 문서 기준
- 권한 제어와 mock login 정리
- `#9` as-of date 기반 history 구조 설계 및 점진적 도입
- 조직현황의 장기적 앱 구조 승격 검토
- 자리배치도 조직 트리, 나머지 사무실 도면 등 실사용 기능 고도화
- 프로젝트별 분석의 남은 소수점/분류 오차 정리

View File

@@ -53,12 +53,15 @@
- [frontend/public/design-patterns.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/design-patterns.css)
- 디자인 기준 문서:
- [architecture/DESIGN_SSOT.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/architecture/DESIGN_SSOT.md)
- DB 테이블 분류 기준 문서:
- [architecture/DB_TABLE_CATALOG.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/architecture/DB_TABLE_CATALOG.md)
- 로그인 기본 스타일은 [frontend/public/styles.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/styles.css) 기준으로 유지
- `8081` 허브 전용 디자인은 [frontend/public/styles-8081-design.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/styles-8081-design.css)에서만 덮어씀
- 조직현황은 [legacy/static/common.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/legacy/static/common.css), [legacy/static/organization.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/legacy/static/organization.css), [legacy/static/organization.js](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/legacy/static/organization.js)를 사용
- 프로젝트별 분석 디자인은 [incoming-files/served/payment.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/incoming-files/served/payment.html) 내부에서 `design-tokens.css` + `design-patterns.css`를 참조
- 프로젝트별 분석 수정 원본은 [frontend/apps/payment/index.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/apps/payment/index.html) 이고, 반영은 [scripts/publish_payment_app.sh](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/scripts/publish_payment_app.sh)로 한다.
- 팀/개인별 분석 수정 원본은 [frontend/apps/team/index.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/apps/team/index.html) 이고, 반영은 [scripts/publish_team_app.sh](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/scripts/publish_team_app.sh)로 한다.
- DB 상태 화면 수정 원본은 [frontend/apps/db-status/index.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/apps/db-status/index.html) 이고, 반영은 [scripts/publish_db_status_app.sh](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/scripts/publish_db_status_app.sh)로 한다.
- 사업관리대장 실제 서비스 코드는 [incoming-files/served/ledger](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/incoming-files/served/ledger) 기준으로 본다.
- 사업관리대장 앱 소스 기준은 [frontend/apps/ledger](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/apps/ledger) 이고, 반영은 [scripts/publish_ledger_app.sh](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/scripts/publish_ledger_app.sh)로 한다.
- 사업관리대장 상세 팝업 디자인 수정 원본은 [frontend/apps/ledger/assets/ledger-override.js](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/apps/ledger/assets/ledger-override.js) 기준으로 본다.
@@ -106,6 +109,8 @@
- [incoming-files/served/ledger/index.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/incoming-files/served/ledger/index.html)
- `/integrations/mh`:
- [incoming-files/served/mh.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/incoming-files/served/mh.html)
- `/db-status.html`:
- [incoming-files/served/db-status/index.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/incoming-files/served/db-status/index.html)
## Cross Checks Last Confirmed
@@ -126,20 +131,18 @@
## Open Issues Relevant Now
- `#2` 백엔드 영속 저장 구조 운영 마무리
- `#14` 누적된 임시 로직 정리 및 중복 코드 제거
- `#16` 사업관리대장 메인 연동 및 기본 원본 DB화
- `#17` 8081 분리 worktree 기동 절차와 로컬 디자인 자산 복제 고정
- `#18` 8081 파일 책임 맵 정리 및 프런트 서빙 경로 정돈
- `#19` 8081 백엔드 라우터/서빙 책임 분리
- `#20` 8081 worktree 준비 스크립트·문서·운영 규칙 정리
- `#21` reference 의존 제거 및 8081 실제 서비스 코드 독립화
- `#16` 사업관리대장 메인 후속 정리 및 기준 분석
- `#19` 8081 백엔드 라우터/서빙 deeper 모듈 분리
- `#21` organization 레거시 구조 승격 및 장기 고도화
## Recommended Next Work Order
1. `#21` 이후 기준으로 실제 서비스 파일과 reference 파일 경계를 유지
2. 사업관리대장 세부 데이터 정합성 보정
3. 그 다음 화면별 앱 구조 승격 검토
4. 필요 시 `#19`, `#20` 잔여 정리 항목 재평가
1. `#2` 기준으로 DB 상태 화면과 저장 구조 검증 흐름 고도화
2. `#21` 이후 기준으로 실제 서비스 파일과 reference 파일 경계를 유지
3. 사업관리대장 세부 데이터 정합성 보정은 원본 규칙 분석 후 진행
4. 필요 시 `#19` 잔여 정리 항목 재평가
## Quick Resume Prompt
@@ -150,4 +153,6 @@
- 먼저 [WORK_RULEBOOK.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/WORK_RULEBOOK.md), [NEXT_SESSION_CHECKPOINT.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/NEXT_SESSION_CHECKPOINT.md), [architecture/8081_SERVING_MAP.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/architecture/8081_SERVING_MAP.md) 확인
- 디자인 수정이면 [frontend/public/design-tokens.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/design-tokens.css), [frontend/public/design-patterns.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/design-patterns.css), [architecture/DESIGN_SSOT.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/architecture/DESIGN_SSOT.md) 먼저 확인
- 현재 구조 독립화 기준 이슈는 `#21`
- 작업 전 `git status`, dev 컨테이너 상태, `/api/health`, `/legacy/organization`, `/integrations/payment`, `/integrations/ledger`, `/integrations/mh`를 먼저 확인
- `#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`를 먼저 확인

View File

@@ -47,10 +47,16 @@
- 현재 실제 서빙 파일: `incoming-files/served/mh.html`
- 앱 소스 기준: `frontend/apps/team/index.html`
- publish 규칙: `scripts/publish_team_app.sh`
- URL: `/db-status.html`
- 현재 실제 서빙 파일: `incoming-files/served/db-status/index.html`
- 앱 소스 기준: `frontend/apps/db-status/index.html`
- publish 규칙: `scripts/publish_db_status_app.sh`
정리 원칙:
- `incoming-files` 아래에서는 `served/`를 실제 서빙 자산용으로 사용한다.
- `payment`, `mh`, `ledger`, `db-status`는 사람이 직접 `served/`를 먼저 수정하지 않는다.
- 이 4개 화면의 source-of-truth는 `frontend/apps/*`이고, publish 스크립트가 `served/`를 갱신한다.
- `reference/`는 원본 참고 파일, 복구 참고 파일, 비교용 자산만 둔다.
- 1차 정리에서는 기존 실제 서빙 파일을 `served/`에 복사하고, backend 서빙 경로를 먼저 `served/`로 갱신한다.
- `사업관리대장``#21`부터 wrapper decode 방식 대신 `served/ledger/index.html``served/ledger/*`를 직접 서빙한다.
@@ -109,4 +115,5 @@
- 로그인은 `styles.css`만 본다.
- 허브 8081 디자인은 `styles-8081-design.css`만 본다.
- `/integrations/payment`, `/integrations/mh`의 실제 서빙 파일 위치가 문서와 코드에서 일치한다.
- `/db-status.html`가 현재 DB 저장 구조와 import 상태를 화면에서 바로 보여준다.
- 기존 참고 자산을 지우지 않고도 실제 서빙 경로와 참고 경로를 구분할 수 있다.

View File

@@ -0,0 +1,205 @@
# DB Table Catalog
## Purpose
이 문서는 `8081 / work-8081` 기준 현재 PostgreSQL 테이블 26개를 역할별로 분류한 운영 기준 문서다.
핵심 원칙:
- 테이블 수가 많다고 바로 줄이지 않는다.
- 먼저 `유지 / 주의 / 원본·추적 / 정리 후보`로 나눈다.
- 실제 운영 화면과 저장 흐름에 필요한 것은 유지한다.
- 의미가 불분명하거나 중복 역할만 하는 것은 후보로 남겨두고, 실제 삭제는 별도 검증 후 진행한다.
## Summary
- 전체 테이블: `26`
- 유지: `16`
- 주의: `6`
- 원본·추적: `4`
- 정리 후보: `0`
## 1. 유지
현재 운영 화면, 인증, 이력, 적재 흐름에서 계속 필요하다.
- `auth.users`
- `auth.sessions`
- `auth.login_audit_logs`
- `public.members`
- `public.member_versions`
- `public.history_revisions`
- `public.seat_maps`
- `public.seat_slots`
- `public.seat_positions`
- `public.seat_assignment_versions`
- `public.integration_import_batches`
- `public.integration_projects`
- `public.integration_work_logs`
- `public.integration_work_log_segments`
- `public.integration_vouchers`
- `public.integration_binary_sources`
설명:
- `members`, `seat_*`는 조직현황/자리배치도 핵심
- `member_versions`, `seat_assignment_versions`, `history_revisions`는 as-of 조회와 이력 비교 핵심
- `integration_*` 표준화 결과는 프로젝트별 분석 / 팀·개인별 분석 핵심
- `integration_binary_sources`는 사업관리대장 같은 바이너리 원본 보관용
- `auth.*`는 로그인과 권한 운영 핵심
## 2. 주의
현재도 역할은 있지만, 실제 운영에서 얼마나 계속 필요한지 주기적으로 점검해야 한다.
- `public.member_overrides`
- `public.member_retirements`
- `public.member_aliases`
- `public.integration_project_aliases`
- `public.integration_project_category_mappings`
- `public.integration_project_pm_assignments`
설명:
- 이 테이블들은 핵심 마스터라기보다 “보정/매핑/예외 처리” 성격이 강하다.
- 운영상 필요할 수 있지만, 남용되면 기준 데이터가 흐려진다.
- 사용 규칙과 관리 책임을 분명히 해야 한다.
## 3. 원본·추적
원본 적재와 검증을 위해 필요하다. 직접 서비스 화면의 주 출력원이 아니라, 적재 근거와 추적용이다.
- `public.integration_raw_organization_rows`
- `public.integration_raw_mh_rows`
- `public.integration_raw_mh_pm_rows`
- `public.integration_raw_payment_rows`
설명:
- 원본 파일을 바로 표준화 테이블에만 넣으면, 나중에 적재 오류를 추적하기 어렵다.
- raw row 보관은 import 검증과 재현성 측면에서 의미가 있다.
- 단, 장기 보관 정책과 용량 관리는 별도 필요하다.
## 4. 정리 후보
현재 기준 정리 후보 테이블은 없다.
## Domain Map
### 인증
- `auth.users`
- `auth.sessions`
- `auth.login_audit_logs`
### 조직 / 구성원
- `public.members`
- `public.member_overrides`
- `public.member_retirements`
- `public.member_aliases`
### 자리배치도
- `public.seat_maps`
- `public.seat_slots`
- `public.seat_positions`
### 이력
- `public.history_revisions`
- `public.member_versions`
- `public.seat_assignment_versions`
### integration 표준화
- `public.integration_import_batches`
- `public.integration_projects`
- `public.integration_project_aliases`
- `public.integration_project_category_mappings`
- `public.integration_project_pm_assignments`
- `public.integration_work_logs`
- `public.integration_work_log_segments`
- `public.integration_vouchers`
- `public.integration_binary_sources`
### integration raw
- `public.integration_raw_organization_rows`
- `public.integration_raw_mh_rows`
- `public.integration_raw_mh_pm_rows`
- `public.integration_raw_payment_rows`
## Product View
운영자가 DB를 볼 때는 물리 테이블 수보다 아래 5개 묶음으로 보는 편이 더 이해하기 쉽다.
### 탭 데이터
- `public.members`
- `public.seat_maps`
- `public.seat_slots`
- `public.seat_positions`
- `public.integration_projects`
- `public.integration_work_logs`
- `public.integration_work_log_segments`
- `public.integration_vouchers`
- `public.integration_binary_sources`
### 로그인·권한
- `auth.users`
- `auth.sessions`
- `auth.login_audit_logs`
### 히스토리
- `public.history_revisions`
- `public.member_versions`
- `public.seat_assignment_versions`
### 로우데이터·적재
- `public.integration_import_batches`
- `public.integration_raw_organization_rows`
- `public.integration_raw_mh_rows`
- `public.integration_raw_mh_pm_rows`
- `public.integration_raw_payment_rows`
### 보정·보조
- `public.member_overrides`
- `public.member_retirements`
- `public.member_aliases`
- `public.integration_project_aliases`
- `public.integration_project_category_mappings`
- `public.integration_project_pm_assignments`
## Operational Guidance
### 바로 줄이지 말아야 하는 것
- `integration_raw_*`
- `member_versions`
- `seat_assignment_versions`
- `auth.*`
이건 지금 구조상 “많아 보여도 필요한 층”이다.
### 먼저 점검할 것
- `member_overrides`, `member_aliases`, `project_aliases`의 실제 운영 빈도
- `seat_maps`의 과거 실험 도면 정리 기준
### 정리 원칙
1. 테이블을 없애기 전에 실제 읽는 API/화면/스크립트를 확인한다.
2. 원본 추적용 테이블은 운영 출력용 테이블과 구분해서 판단한다.
3. 테이블 삭제보다 먼저 “사용 안 함” 상태를 문서화한다.
4. 삭제는 백업과 검증 절차가 준비된 뒤에만 한다.
## Recommended Next Checks
1. `seat_maps` 과거 DXF 시도본 정리 기준 수립
2. `주의` 그룹 테이블의 입력/수정 주체 명확화
3. `DB 상태` 화면에서 이 분류를 기준으로 계속 설명 유지

View File

@@ -0,0 +1,7 @@
## DB Status App
- 수정 원본: `frontend/apps/db-status/index.html`
- 실제 서빙: `incoming-files/served/db-status/index.html`
- publish: `./scripts/publish_db_status_app.sh`
`#2` 이슈용 관리자 화면으로, 현재 DB 저장 구조와 적재 상태를 사람이 읽을 수 있게 보여준다.

View File

@@ -0,0 +1,801 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DB 상태</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Pretendard:wght@400;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/design-tokens.css?v=20260401-01">
<link rel="stylesheet" href="/design-patterns.css?v=20260401-01">
<style>
:root {
color-scheme: light;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Pretendard", sans-serif;
background:
radial-gradient(circle at top left, rgba(247, 217, 119, 0.28), transparent 30%),
linear-gradient(180deg, var(--ds-bg, #f5f1e8) 0%, #efe6d5 100%);
color: var(--ds-ink, #2f2419);
}
.page {
max-width: 2000px;
margin: 0 auto;
padding: 28px;
display: grid;
gap: 20px;
}
.hero {
display: grid;
gap: 12px;
padding: 28px 30px;
border: 1px solid rgba(134, 98, 47, 0.14);
border-radius: 28px;
background: linear-gradient(135deg, rgba(255, 250, 240, 0.96), rgba(242, 232, 214, 0.92));
box-shadow: 0 28px 68px rgba(88, 61, 23, 0.15);
}
.hero h1 {
margin: 0;
font-size: 30px;
font-weight: 800;
letter-spacing: -0.03em;
}
.hero p {
margin: 0;
color: rgba(76, 58, 35, 0.82);
line-height: 1.6;
}
.overview {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 14px;
}
.kpi {
padding: 18px 20px;
border-radius: 22px;
background: rgba(255, 252, 247, 0.92);
border: 1px solid rgba(140, 110, 59, 0.14);
box-shadow: 0 14px 34px rgba(81, 58, 23, 0.08);
}
.kpi-label {
display: block;
font-size: 12px;
font-weight: 700;
color: rgba(112, 84, 41, 0.72);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.kpi-value {
display: block;
margin-top: 8px;
font-size: 28px;
font-weight: 800;
color: #3d2e1d;
}
.grid {
display: grid;
grid-template-columns: 1.4fr 1fr;
gap: 20px;
}
.panel {
border-radius: 24px;
background: rgba(255, 251, 245, 0.96);
border: 1px solid rgba(142, 110, 54, 0.14);
box-shadow: 0 18px 48px rgba(85, 60, 24, 0.08);
overflow: hidden;
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 18px 22px 14px;
border-bottom: 1px solid rgba(128, 98, 48, 0.12);
}
.panel-head h2 {
margin: 0;
font-size: 18px;
font-weight: 800;
letter-spacing: -0.02em;
}
.panel-head p {
margin: 4px 0 0;
font-size: 13px;
color: rgba(102, 77, 41, 0.72);
}
.panel-body {
padding: 16px 18px 20px;
}
.panel-body.tight {
padding-top: 0;
}
.meta-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 11px;
border-radius: 999px;
background: rgba(251, 236, 196, 0.8);
color: #7a5923;
font-size: 12px;
font-weight: 700;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
th, td {
padding: 12px 10px;
vertical-align: top;
border-bottom: 1px solid rgba(130, 100, 53, 0.1);
text-align: left;
}
th {
font-size: 12px;
font-weight: 800;
color: rgba(104, 79, 40, 0.76);
text-transform: uppercase;
letter-spacing: 0.06em;
white-space: nowrap;
}
tbody tr:hover {
background: rgba(250, 240, 213, 0.34);
}
.domain-tag {
display: inline-flex;
align-items: center;
padding: 4px 9px;
border-radius: 999px;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.04em;
text-transform: uppercase;
background: rgba(90, 122, 94, 0.14);
color: #456b4c;
}
.domain-tag.integration { background: rgba(196, 143, 58, 0.16); color: #8c5f18; }
.domain-tag.history { background: rgba(120, 92, 156, 0.14); color: #6a4b8b; }
.domain-tag.auth { background: rgba(103, 114, 154, 0.14); color: #48567c; }
.domain-tag.other { background: rgba(131, 112, 80, 0.12); color: #6a5637; }
.group-tag {
display: inline-flex;
align-items: center;
padding: 4px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.03em;
background: rgba(240, 231, 214, 0.95);
color: #674d27;
white-space: nowrap;
}
.group-tag.keep { background: rgba(112, 143, 87, 0.18); color: #49623c; }
.group-tag.caution { background: rgba(214, 167, 84, 0.18); color: #8f5d17; }
.group-tag.trace { background: rgba(113, 120, 168, 0.16); color: #56628c; }
.group-tag.cleanup { background: rgba(184, 111, 84, 0.16); color: #884d39; }
.table-title {
font-weight: 800;
color: #2f2419;
}
.table-trigger {
all: unset;
cursor: pointer;
color: #2f2419;
font-weight: 800;
}
.table-trigger:hover {
color: #80591f;
text-decoration: underline;
}
.table-desc {
margin-top: 5px;
color: rgba(98, 75, 42, 0.72);
line-height: 1.5;
}
.view-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.view-pill {
display: inline-flex;
padding: 4px 8px;
border-radius: 999px;
background: rgba(86, 119, 93, 0.12);
color: #456b4c;
font-size: 11px;
font-weight: 700;
}
.notes {
margin: 0;
padding-left: 18px;
display: grid;
gap: 10px;
color: rgba(84, 65, 38, 0.84);
line-height: 1.55;
}
.mapping-list {
display: grid;
gap: 14px;
}
.mapping-card {
padding: 14px 16px;
border-radius: 18px;
background: rgba(255, 249, 239, 0.92);
border: 1px solid rgba(132, 102, 54, 0.12);
}
.mapping-card h3 {
margin: 0 0 8px;
font-size: 15px;
font-weight: 800;
letter-spacing: -0.02em;
}
.mapping-card p {
margin: 8px 0 0;
color: rgba(98, 75, 42, 0.74);
line-height: 1.55;
font-size: 13px;
}
.mapping-table-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.preview-meta {
display: grid;
gap: 10px;
padding: 16px 18px 0;
}
.preview-columns {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.column-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-radius: 999px;
background: rgba(240, 231, 214, 0.9);
color: #634a25;
font-size: 12px;
font-weight: 700;
}
.column-pill em {
font-style: normal;
color: rgba(99, 74, 37, 0.68);
font-weight: 600;
}
.preview-table-wrap {
overflow: auto;
max-height: 520px;
border-top: 1px solid rgba(128, 98, 48, 0.12);
}
.sticky-head th {
position: sticky;
top: 0;
background: rgba(255, 248, 236, 0.98);
z-index: 1;
}
.muted {
color: rgba(110, 86, 50, 0.72);
}
.empty {
padding: 22px;
text-align: center;
color: rgba(102, 77, 41, 0.72);
}
.modal-overlay {
position: fixed;
inset: 0;
display: none;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(44, 31, 16, 0.42);
backdrop-filter: blur(6px);
z-index: 1000;
}
.modal-overlay.open {
display: flex;
}
.modal-card {
width: min(1600px, 100%);
max-height: min(88vh, 980px);
border-radius: 28px;
background: rgba(255, 250, 243, 0.98);
border: 1px solid rgba(142, 110, 54, 0.18);
box-shadow: 0 32px 80px rgba(59, 40, 16, 0.28);
overflow: hidden;
display: grid;
grid-template-rows: auto auto 1fr;
}
.modal-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 22px 24px 16px;
border-bottom: 1px solid rgba(128, 98, 48, 0.12);
}
.modal-head h2 {
margin: 0;
font-size: 22px;
font-weight: 800;
letter-spacing: -0.03em;
}
.modal-close {
border: 0;
background: rgba(240, 229, 206, 0.9);
color: #6d5127;
width: 38px;
height: 38px;
border-radius: 999px;
font-size: 18px;
font-weight: 800;
cursor: pointer;
}
.modal-close:hover {
background: rgba(225, 208, 174, 0.96);
}
.modal-body {
overflow: hidden;
display: grid;
grid-template-rows: auto 1fr;
}
@media (max-width: 1200px) {
.grid { grid-template-columns: 1fr; }
}
@media (max-width: 720px) {
.page { padding: 16px; }
.hero { padding: 22px 20px; }
.kpi-value { font-size: 24px; }
th, td { padding: 10px 8px; }
}
</style>
</head>
<body>
<div class="page">
<section class="hero">
<span class="meta-chip">#2 백엔드 영속 저장 구조 운영</span>
<h1>DB 상태와 저장 구조를 화면에서 바로 확인</h1>
<p>
이 화면은 현재 운영 DB의 핵심 테이블, 적재 상태, 최근 import 흐름을 SQL 없이 확인하기 위한 관리자용 뷰어입니다.
이후 저장 구조 검증과 데이터 정합성 작업은 이 화면을 기준으로 진행합니다.
</p>
<p>
`원본 import 배치`는 업로드한 원본 파일이 몇 행으로 적재됐는지 보여주고, `바이너리 원본 보관`은 엑셀 같은 파일 자체를 DB에 보관하는 상태를 보여줍니다.
</p>
<p>
아래 표는 전체 테이블을 보여주고, 오른쪽 패널은 화면별 데이터 소스와 저장 흐름을 운영 관점으로 묶어서 보여줍니다.
</p>
</section>
<section id="overview" class="overview"></section>
<section class="grid">
<article class="panel">
<div class="panel-head">
<div>
<h2>전체 테이블 현황</h2>
<p>현재 운영 DB의 전체 26개 테이블을 보여주며, 테이블명을 누르면 샘플 row를 바로 확인할 수 있습니다.</p>
</div>
<span id="generated-at" class="meta-chip">로딩 중</span>
</div>
<div class="panel-body">
<table>
<thead>
<tr>
<th>도메인</th>
<th>테이블</th>
<th>Rows</th>
<th>최근 갱신</th>
<th>연결 화면</th>
</tr>
</thead>
<tbody id="table-body">
<tr><td colspan="5" class="empty">DB 상태를 불러오는 중입니다.</td></tr>
</tbody>
</table>
</div>
</article>
<div style="display:grid; gap:20px;">
<article class="panel">
<div class="panel-head">
<div>
<h2>원본 import 배치</h2>
<p>현재 적재된 원본 파일 배치와 row 수입니다.</p>
</div>
</div>
<div class="panel-body">
<table>
<thead>
<tr>
<th>Source</th>
<th>Rows</th>
<th>Imported</th>
</tr>
</thead>
<tbody id="batch-body">
<tr><td colspan="3" class="empty">로딩 중</td></tr>
</tbody>
</table>
</div>
</article>
<article class="panel">
<div class="panel-head">
<div>
<h2>바이너리 원본 보관</h2>
<p>엑셀 같은 바이너리 원본의 DB 보관 상태입니다.</p>
</div>
</div>
<div class="panel-body">
<table>
<thead>
<tr>
<th>Source</th>
<th>파일</th>
<th>크기</th>
</tr>
</thead>
<tbody id="binary-body">
<tr><td colspan="3" class="empty">로딩 중</td></tr>
</tbody>
</table>
</div>
</article>
<article class="panel">
<div class="panel-head">
<div>
<h2>운영 메모</h2>
<p>#2에서 확인해야 할 저장 구조 핵심 포인트입니다.</p>
</div>
</div>
<div class="panel-body">
<ol id="notes" class="notes"></ol>
</div>
</article>
<article class="panel">
<div class="panel-head">
<div>
<h2>운영 분류</h2>
<p>유지/주의/원본·추적/정리 후보 기준과 제품 관점 묶음을 같이 봅니다.</p>
</div>
</div>
<div class="panel-body">
<div id="group-summary"></div>
<div id="product-summary" style="margin-top:18px;"></div>
</div>
</article>
<article class="panel">
<div class="panel-head">
<div>
<h2>화면별 데이터 소스</h2>
<p>각 탭/기능이 실제로 어떤 테이블을 읽고 저장하는지 빠르게 확인합니다.</p>
</div>
</div>
<div id="screen-map" class="panel-body mapping-list"></div>
</article>
</div>
</section>
</div>
<div id="preview-modal" class="modal-overlay" aria-hidden="true">
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="preview-title">
<div class="modal-head">
<div>
<h2 id="preview-title">테이블 내용 미리보기</h2>
<p id="preview-subtitle" class="muted">선택한 테이블의 컬럼과 최대 50개 row를 표시합니다.</p>
</div>
<button id="preview-close" class="modal-close" type="button" aria-label="닫기">×</button>
</div>
<div class="modal-body">
<div id="preview-meta" class="preview-meta"></div>
<div class="panel-body tight">
<div class="preview-table-wrap">
<table>
<thead id="preview-head" class="sticky-head"></thead>
<tbody id="preview-body">
<tr><td class="empty">왼쪽 표에서 테이블을 선택하세요.</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<script>
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function formatNumber(value) {
return new Intl.NumberFormat("ko-KR").format(Number(value || 0));
}
function formatDateTime(value) {
if (!value) return '<span class="muted">-</span>';
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return escapeHtml(value);
return parsed.toLocaleString("ko-KR", { hour12: false });
}
function formatBytes(value) {
const size = Number(value || 0);
if (size <= 0) return "0 B";
const units = ["B", "KB", "MB", "GB"];
let current = size;
let unit = 0;
while (current >= 1024 && unit < units.length - 1) {
current /= 1024;
unit += 1;
}
return `${current.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`;
}
function renderOverview(overview) {
const target = document.getElementById("overview");
target.innerHTML = [
["핵심 테이블", overview.visible_tables],
["전체 테이블", overview.total_tables],
["등록 인원", overview.registered_members],
["재직 인원", overview.active_members],
["고정 오피스 도면", overview.fixed_office_maps],
["현재 active 도면", overview.active_seat_maps],
["Import 배치", overview.import_batches],
["바이너리 원본", overview.binary_sources],
].map(([label, value]) => `
<article class="kpi">
<span class="kpi-label">${escapeHtml(label)}</span>
<span class="kpi-value">${formatNumber(value)}</span>
</article>
`).join("");
}
function renderTables(items) {
const target = document.getElementById("table-body");
if (!items.length) {
target.innerHTML = '<tr><td colspan="5" class="empty">표시할 테이블이 없습니다.</td></tr>';
return;
}
target.innerHTML = items.map((item) => `
<tr>
<td><span class="domain-tag ${escapeHtml(item.domain)}">${escapeHtml(item.domain)}</span></td>
<td>
<div style="margin-bottom:8px;">
<span class="group-tag ${item.group === '유지' ? 'keep' : item.group === '원본·추적' ? 'trace' : item.group === '정리 후보' ? 'cleanup' : 'caution'}">${escapeHtml(item.group || '주의')}</span>
</div>
<button class="table-trigger" type="button" data-schema="${escapeHtml(item.schema)}" data-table="${escapeHtml(item.table_name)}">${escapeHtml(item.label)}</button>
<div class="muted">${escapeHtml(item.table_ref)}</div>
<div class="table-desc">${escapeHtml(item.description)}</div>
</td>
<td>${formatNumber(item.row_count)}</td>
<td>${formatDateTime(item.last_event_at)}</td>
<td>
<div class="view-list">
${(item.related_views || []).map((view) => `<span class="view-pill">${escapeHtml(view)}</span>`).join("")}
</div>
</td>
</tr>
`).join("");
target.querySelectorAll(".table-trigger").forEach((button) => {
button.addEventListener("click", () => {
loadTablePreview(button.dataset.schema, button.dataset.table);
});
});
}
function renderBatches(items) {
const target = document.getElementById("batch-body");
if (!items.length) {
target.innerHTML = '<tr><td colspan="3" class="empty">적재 배치가 없습니다.</td></tr>';
return;
}
target.innerHTML = items.map((item) => `
<tr>
<td>
<div class="table-title">${escapeHtml(item.source_name)}</div>
<div class="muted">${escapeHtml(item.source_key)}</div>
</td>
<td>${formatNumber(item.row_count)}</td>
<td>${formatDateTime(item.imported_at)}</td>
</tr>
`).join("");
}
function renderBinarySources(items) {
const target = document.getElementById("binary-body");
if (!items.length) {
target.innerHTML = '<tr><td colspan="3" class="empty">보관 중인 바이너리 원본이 없습니다.</td></tr>';
return;
}
target.innerHTML = items.map((item) => `
<tr>
<td>
<div class="table-title">${escapeHtml(item.source_name)}</div>
<div class="muted">${escapeHtml(item.source_key)}</div>
</td>
<td>${escapeHtml(item.filename)}</td>
<td>${formatBytes(item.byte_size)}</td>
</tr>
`).join("");
}
function renderNotes(notes) {
const target = document.getElementById("notes");
target.innerHTML = (notes || []).map((note) => `<li>${escapeHtml(note)}</li>`).join("");
}
function renderGroupSummary(summary) {
const target = document.getElementById("group-summary");
const groups = [
["유지", "keep"],
["주의", "caution"],
["원본·추적", "trace"],
["정리 후보", "cleanup"],
];
target.innerHTML = groups.map(([label, klass]) => `
<div style="display:grid; gap:8px; margin-bottom:16px;">
<div><span class="group-tag ${klass}">${escapeHtml(label)}</span></div>
<div class="view-list">
${((summary && summary[label]) || []).map((item) => `<span class="view-pill">${escapeHtml(item)}</span>`).join("") || '<span class="muted">없음</span>'}
</div>
</div>
`).join("");
}
function renderProductSummary(summary) {
const target = document.getElementById("product-summary");
const groups = [
"탭 데이터",
"로그인·권한",
"히스토리",
"로우데이터·적재",
"보정·보조",
];
target.innerHTML = groups.map((label) => `
<div style="display:grid; gap:8px; margin-bottom:16px;">
<div><span class="group-tag keep">${escapeHtml(label)}</span></div>
<div class="view-list">
${((summary && summary[label]) || []).map((item) => `<span class="view-pill">${escapeHtml(item)}</span>`).join("") || '<span class="muted">없음</span>'}
</div>
</div>
`).join("");
}
function renderScreenMap(items) {
const target = document.getElementById("screen-map");
if (!items || !items.length) {
target.innerHTML = '<div class="empty">화면별 데이터 소스 정보가 없습니다.</div>';
return;
}
target.innerHTML = items.map((item) => `
<article class="mapping-card">
<h3>${escapeHtml(item.screen || "")}</h3>
<div class="mapping-table-list">
${(item.tables || []).map((table) => `<span class="view-pill">${escapeHtml(table)}</span>`).join("")}
</div>
<p>${escapeHtml(item.write_flow || "")}</p>
</article>
`).join("");
}
function renderTablePreview(payload) {
const previewModal = document.getElementById("preview-modal");
const previewMeta = document.getElementById("preview-meta");
const previewTitle = document.getElementById("preview-title");
const previewSubtitle = document.getElementById("preview-subtitle");
const previewHead = document.getElementById("preview-head");
const previewBody = document.getElementById("preview-body");
previewTitle.textContent = `${payload.label} · ${payload.table_ref}`;
previewSubtitle.textContent = `${formatNumber(payload.row_count)} rows / 최대 ${formatNumber(payload.limit)}개 표시`;
previewMeta.innerHTML = `
<div>
<div class="table-title">${escapeHtml(payload.label)}</div>
<div class="muted">${escapeHtml(payload.description || "")}</div>
</div>
<div class="preview-columns">
${(payload.columns || []).map((column) => `
<span class="column-pill">${escapeHtml(column.name)} <em>${escapeHtml(column.type)}</em></span>
`).join("")}
</div>
`;
const columns = payload.columns || [];
previewHead.innerHTML = `<tr>${columns.map((column) => `<th>${escapeHtml(column.name)}</th>`).join("")}</tr>`;
if (!payload.rows || !payload.rows.length) {
previewBody.innerHTML = `<tr><td colspan="${Math.max(columns.length, 1)}" class="empty">표시할 row가 없습니다.</td></tr>`;
previewModal.classList.add("open");
previewModal.setAttribute("aria-hidden", "false");
return;
}
previewBody.innerHTML = payload.rows.map((row) => `
<tr>
${columns.map((column) => `<td>${escapeHtml(row[column.name] ?? "")}</td>`).join("")}
</tr>
`).join("");
previewModal.classList.add("open");
previewModal.setAttribute("aria-hidden", "false");
}
async function loadTablePreview(schema, table) {
const previewModal = document.getElementById("preview-modal");
const previewMeta = document.getElementById("preview-meta");
const previewTitle = document.getElementById("preview-title");
const previewSubtitle = document.getElementById("preview-subtitle");
const previewHead = document.getElementById("preview-head");
const previewBody = document.getElementById("preview-body");
previewTitle.textContent = `${table}`;
previewSubtitle.textContent = "테이블 내용을 불러오는 중입니다.";
previewMeta.innerHTML = "";
previewHead.innerHTML = "";
previewBody.innerHTML = `<tr><td class="empty">테이블 내용을 불러오는 중입니다.</td></tr>`;
previewModal.classList.add("open");
previewModal.setAttribute("aria-hidden", "false");
const response = await fetch(`/api/admin/db-status/table?schema=${encodeURIComponent(schema)}&table=${encodeURIComponent(table)}`, { cache: "no-store" });
if (!response.ok) {
throw new Error(`테이블 내용을 불러오지 못했습니다. (${response.status})`);
}
const payload = await response.json();
renderTablePreview(payload);
}
async function bootstrap() {
const response = await fetch("/api/admin/db-status", { cache: "no-store" });
if (!response.ok) {
throw new Error(`DB 상태를 불러오지 못했습니다. (${response.status})`);
}
const payload = await response.json();
document.getElementById("generated-at").textContent = payload.generated_at
? `갱신 ${formatDateTime(payload.generated_at)}`
: "갱신 시각 없음";
renderOverview(payload.overview || {});
renderTables(payload.tables || []);
renderBatches(payload.import_batches || []);
renderBinarySources(payload.binary_sources || []);
renderNotes(payload.notes || []);
renderGroupSummary(payload.group_summary || {});
renderProductSummary(payload.product_summary || {});
renderScreenMap(payload.screen_map || []);
}
document.getElementById("preview-close").addEventListener("click", () => {
const modal = document.getElementById("preview-modal");
modal.classList.remove("open");
modal.setAttribute("aria-hidden", "true");
});
document.getElementById("preview-modal").addEventListener("click", (event) => {
if (event.target.id !== "preview-modal") return;
const modal = document.getElementById("preview-modal");
modal.classList.remove("open");
modal.setAttribute("aria-hidden", "true");
});
bootstrap().catch((error) => {
document.getElementById("table-body").innerHTML = `<tr><td colspan="5" class="empty">${escapeHtml(error.message || "DB 상태를 불러오지 못했습니다.")}</td></tr>`;
document.getElementById("batch-body").innerHTML = '<tr><td colspan="3" class="empty">배치 정보를 불러오지 못했습니다.</td></tr>';
document.getElementById("binary-body").innerHTML = '<tr><td colspan="3" class="empty">바이너리 원본 정보를 불러오지 못했습니다.</td></tr>';
});
</script>
</body>
</html>

View File

@@ -23,6 +23,8 @@ const projectFrame = document.getElementById("project-frame");
const projectStage = document.getElementById("project-stage");
const teamFrame = document.getElementById("team-frame");
const teamStage = document.getElementById("team-stage");
const dbStatusFrame = document.getElementById("db-status-frame");
const dbStatusStage = document.getElementById("db-status-stage");
const seatMapAdminStage = document.getElementById("seatmap-admin-stage");
const seatMapReadonlyStage = document.getElementById("seatmap-readonly-stage");
const emptyStage = document.getElementById("empty-stage");
@@ -115,6 +117,7 @@ const viewLabels = {
project: "프로젝트별 분석",
team: "팀/개인별 분석",
organization: "조직 현황",
"db-status": "DB 상태",
"seatmap-admin": "자리배치도",
"seatmap-readonly": "자리배치도",
};
@@ -1623,6 +1626,7 @@ function setActiveView(view) {
const isLedger = currentView === "ledger";
const isProject = currentView === "project";
const isTeam = currentView === "team";
const isDbStatus = currentView === "db-status";
const isSeatMapAdmin = currentView === "seatmap-admin";
const isSeatMapReadonly = currentView === "seatmap-readonly";
if (ledgerStage) {
@@ -1641,6 +1645,10 @@ function setActiveView(view) {
teamStage.hidden = !isTeam;
teamStage.style.display = isTeam ? "flex" : "none";
}
if (dbStatusStage) {
dbStatusStage.hidden = !isDbStatus;
dbStatusStage.style.display = isDbStatus ? "flex" : "none";
}
if (seatMapAdminStage) {
seatMapAdminStage.hidden = !isSeatMapAdmin;
seatMapAdminStage.style.display = isSeatMapAdmin ? "flex" : "none";
@@ -1650,7 +1658,7 @@ function setActiveView(view) {
seatMapReadonlyStage.style.display = isSeatMapReadonly ? "flex" : "none";
}
if (emptyStage) {
const showEmpty = !isLedger && !isOrganization && !isProject && !isTeam && !isSeatMapAdmin && !isSeatMapReadonly;
const showEmpty = !isLedger && !isOrganization && !isProject && !isTeam && !isDbStatus && !isSeatMapAdmin && !isSeatMapReadonly;
emptyStage.hidden = !showEmpty;
emptyStage.style.display = showEmpty ? "flex" : "none";
}
@@ -1677,6 +1685,10 @@ function setActiveView(view) {
} else if (isTeam) {
postGlobalDateRangeToFrame(teamFrame);
}
if (isDbStatus && previousView !== "db-status" && dbStatusFrame) {
const frameSrc = dbStatusFrame.dataset.src || dbStatusFrame.src;
dbStatusFrame.src = resolveAppUrl(frameSrc);
}
if (isSeatMapAdmin || isSeatMapReadonly) {
loadSeatMapData();
}

View File

@@ -0,0 +1,801 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DB 상태</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Pretendard:wght@400;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/design-tokens.css?v=20260401-01">
<link rel="stylesheet" href="/design-patterns.css?v=20260401-01">
<style>
:root {
color-scheme: light;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Pretendard", sans-serif;
background:
radial-gradient(circle at top left, rgba(247, 217, 119, 0.28), transparent 30%),
linear-gradient(180deg, var(--ds-bg, #f5f1e8) 0%, #efe6d5 100%);
color: var(--ds-ink, #2f2419);
}
.page {
max-width: 2000px;
margin: 0 auto;
padding: 28px;
display: grid;
gap: 20px;
}
.hero {
display: grid;
gap: 12px;
padding: 28px 30px;
border: 1px solid rgba(134, 98, 47, 0.14);
border-radius: 28px;
background: linear-gradient(135deg, rgba(255, 250, 240, 0.96), rgba(242, 232, 214, 0.92));
box-shadow: 0 28px 68px rgba(88, 61, 23, 0.15);
}
.hero h1 {
margin: 0;
font-size: 30px;
font-weight: 800;
letter-spacing: -0.03em;
}
.hero p {
margin: 0;
color: rgba(76, 58, 35, 0.82);
line-height: 1.6;
}
.overview {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 14px;
}
.kpi {
padding: 18px 20px;
border-radius: 22px;
background: rgba(255, 252, 247, 0.92);
border: 1px solid rgba(140, 110, 59, 0.14);
box-shadow: 0 14px 34px rgba(81, 58, 23, 0.08);
}
.kpi-label {
display: block;
font-size: 12px;
font-weight: 700;
color: rgba(112, 84, 41, 0.72);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.kpi-value {
display: block;
margin-top: 8px;
font-size: 28px;
font-weight: 800;
color: #3d2e1d;
}
.grid {
display: grid;
grid-template-columns: 1.4fr 1fr;
gap: 20px;
}
.panel {
border-radius: 24px;
background: rgba(255, 251, 245, 0.96);
border: 1px solid rgba(142, 110, 54, 0.14);
box-shadow: 0 18px 48px rgba(85, 60, 24, 0.08);
overflow: hidden;
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 18px 22px 14px;
border-bottom: 1px solid rgba(128, 98, 48, 0.12);
}
.panel-head h2 {
margin: 0;
font-size: 18px;
font-weight: 800;
letter-spacing: -0.02em;
}
.panel-head p {
margin: 4px 0 0;
font-size: 13px;
color: rgba(102, 77, 41, 0.72);
}
.panel-body {
padding: 16px 18px 20px;
}
.panel-body.tight {
padding-top: 0;
}
.meta-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 11px;
border-radius: 999px;
background: rgba(251, 236, 196, 0.8);
color: #7a5923;
font-size: 12px;
font-weight: 700;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
th, td {
padding: 12px 10px;
vertical-align: top;
border-bottom: 1px solid rgba(130, 100, 53, 0.1);
text-align: left;
}
th {
font-size: 12px;
font-weight: 800;
color: rgba(104, 79, 40, 0.76);
text-transform: uppercase;
letter-spacing: 0.06em;
white-space: nowrap;
}
tbody tr:hover {
background: rgba(250, 240, 213, 0.34);
}
.domain-tag {
display: inline-flex;
align-items: center;
padding: 4px 9px;
border-radius: 999px;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.04em;
text-transform: uppercase;
background: rgba(90, 122, 94, 0.14);
color: #456b4c;
}
.domain-tag.integration { background: rgba(196, 143, 58, 0.16); color: #8c5f18; }
.domain-tag.history { background: rgba(120, 92, 156, 0.14); color: #6a4b8b; }
.domain-tag.auth { background: rgba(103, 114, 154, 0.14); color: #48567c; }
.domain-tag.other { background: rgba(131, 112, 80, 0.12); color: #6a5637; }
.group-tag {
display: inline-flex;
align-items: center;
padding: 4px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.03em;
background: rgba(240, 231, 214, 0.95);
color: #674d27;
white-space: nowrap;
}
.group-tag.keep { background: rgba(112, 143, 87, 0.18); color: #49623c; }
.group-tag.caution { background: rgba(214, 167, 84, 0.18); color: #8f5d17; }
.group-tag.trace { background: rgba(113, 120, 168, 0.16); color: #56628c; }
.group-tag.cleanup { background: rgba(184, 111, 84, 0.16); color: #884d39; }
.table-title {
font-weight: 800;
color: #2f2419;
}
.table-trigger {
all: unset;
cursor: pointer;
color: #2f2419;
font-weight: 800;
}
.table-trigger:hover {
color: #80591f;
text-decoration: underline;
}
.table-desc {
margin-top: 5px;
color: rgba(98, 75, 42, 0.72);
line-height: 1.5;
}
.view-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.view-pill {
display: inline-flex;
padding: 4px 8px;
border-radius: 999px;
background: rgba(86, 119, 93, 0.12);
color: #456b4c;
font-size: 11px;
font-weight: 700;
}
.notes {
margin: 0;
padding-left: 18px;
display: grid;
gap: 10px;
color: rgba(84, 65, 38, 0.84);
line-height: 1.55;
}
.mapping-list {
display: grid;
gap: 14px;
}
.mapping-card {
padding: 14px 16px;
border-radius: 18px;
background: rgba(255, 249, 239, 0.92);
border: 1px solid rgba(132, 102, 54, 0.12);
}
.mapping-card h3 {
margin: 0 0 8px;
font-size: 15px;
font-weight: 800;
letter-spacing: -0.02em;
}
.mapping-card p {
margin: 8px 0 0;
color: rgba(98, 75, 42, 0.74);
line-height: 1.55;
font-size: 13px;
}
.mapping-table-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.preview-meta {
display: grid;
gap: 10px;
padding: 16px 18px 0;
}
.preview-columns {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.column-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-radius: 999px;
background: rgba(240, 231, 214, 0.9);
color: #634a25;
font-size: 12px;
font-weight: 700;
}
.column-pill em {
font-style: normal;
color: rgba(99, 74, 37, 0.68);
font-weight: 600;
}
.preview-table-wrap {
overflow: auto;
max-height: 520px;
border-top: 1px solid rgba(128, 98, 48, 0.12);
}
.sticky-head th {
position: sticky;
top: 0;
background: rgba(255, 248, 236, 0.98);
z-index: 1;
}
.muted {
color: rgba(110, 86, 50, 0.72);
}
.empty {
padding: 22px;
text-align: center;
color: rgba(102, 77, 41, 0.72);
}
.modal-overlay {
position: fixed;
inset: 0;
display: none;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(44, 31, 16, 0.42);
backdrop-filter: blur(6px);
z-index: 1000;
}
.modal-overlay.open {
display: flex;
}
.modal-card {
width: min(1600px, 100%);
max-height: min(88vh, 980px);
border-radius: 28px;
background: rgba(255, 250, 243, 0.98);
border: 1px solid rgba(142, 110, 54, 0.18);
box-shadow: 0 32px 80px rgba(59, 40, 16, 0.28);
overflow: hidden;
display: grid;
grid-template-rows: auto auto 1fr;
}
.modal-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 22px 24px 16px;
border-bottom: 1px solid rgba(128, 98, 48, 0.12);
}
.modal-head h2 {
margin: 0;
font-size: 22px;
font-weight: 800;
letter-spacing: -0.03em;
}
.modal-close {
border: 0;
background: rgba(240, 229, 206, 0.9);
color: #6d5127;
width: 38px;
height: 38px;
border-radius: 999px;
font-size: 18px;
font-weight: 800;
cursor: pointer;
}
.modal-close:hover {
background: rgba(225, 208, 174, 0.96);
}
.modal-body {
overflow: hidden;
display: grid;
grid-template-rows: auto 1fr;
}
@media (max-width: 1200px) {
.grid { grid-template-columns: 1fr; }
}
@media (max-width: 720px) {
.page { padding: 16px; }
.hero { padding: 22px 20px; }
.kpi-value { font-size: 24px; }
th, td { padding: 10px 8px; }
}
</style>
</head>
<body>
<div class="page">
<section class="hero">
<span class="meta-chip">#2 백엔드 영속 저장 구조 운영</span>
<h1>DB 상태와 저장 구조를 화면에서 바로 확인</h1>
<p>
이 화면은 현재 운영 DB의 핵심 테이블, 적재 상태, 최근 import 흐름을 SQL 없이 확인하기 위한 관리자용 뷰어입니다.
이후 저장 구조 검증과 데이터 정합성 작업은 이 화면을 기준으로 진행합니다.
</p>
<p>
`원본 import 배치`는 업로드한 원본 파일이 몇 행으로 적재됐는지 보여주고, `바이너리 원본 보관`은 엑셀 같은 파일 자체를 DB에 보관하는 상태를 보여줍니다.
</p>
<p>
아래 표는 전체 테이블을 보여주고, 오른쪽 패널은 화면별 데이터 소스와 저장 흐름을 운영 관점으로 묶어서 보여줍니다.
</p>
</section>
<section id="overview" class="overview"></section>
<section class="grid">
<article class="panel">
<div class="panel-head">
<div>
<h2>전체 테이블 현황</h2>
<p>현재 운영 DB의 전체 26개 테이블을 보여주며, 테이블명을 누르면 샘플 row를 바로 확인할 수 있습니다.</p>
</div>
<span id="generated-at" class="meta-chip">로딩 중</span>
</div>
<div class="panel-body">
<table>
<thead>
<tr>
<th>도메인</th>
<th>테이블</th>
<th>Rows</th>
<th>최근 갱신</th>
<th>연결 화면</th>
</tr>
</thead>
<tbody id="table-body">
<tr><td colspan="5" class="empty">DB 상태를 불러오는 중입니다.</td></tr>
</tbody>
</table>
</div>
</article>
<div style="display:grid; gap:20px;">
<article class="panel">
<div class="panel-head">
<div>
<h2>원본 import 배치</h2>
<p>현재 적재된 원본 파일 배치와 row 수입니다.</p>
</div>
</div>
<div class="panel-body">
<table>
<thead>
<tr>
<th>Source</th>
<th>Rows</th>
<th>Imported</th>
</tr>
</thead>
<tbody id="batch-body">
<tr><td colspan="3" class="empty">로딩 중</td></tr>
</tbody>
</table>
</div>
</article>
<article class="panel">
<div class="panel-head">
<div>
<h2>바이너리 원본 보관</h2>
<p>엑셀 같은 바이너리 원본의 DB 보관 상태입니다.</p>
</div>
</div>
<div class="panel-body">
<table>
<thead>
<tr>
<th>Source</th>
<th>파일</th>
<th>크기</th>
</tr>
</thead>
<tbody id="binary-body">
<tr><td colspan="3" class="empty">로딩 중</td></tr>
</tbody>
</table>
</div>
</article>
<article class="panel">
<div class="panel-head">
<div>
<h2>운영 메모</h2>
<p>#2에서 확인해야 할 저장 구조 핵심 포인트입니다.</p>
</div>
</div>
<div class="panel-body">
<ol id="notes" class="notes"></ol>
</div>
</article>
<article class="panel">
<div class="panel-head">
<div>
<h2>운영 분류</h2>
<p>유지/주의/원본·추적/정리 후보 기준과 제품 관점 묶음을 같이 봅니다.</p>
</div>
</div>
<div class="panel-body">
<div id="group-summary"></div>
<div id="product-summary" style="margin-top:18px;"></div>
</div>
</article>
<article class="panel">
<div class="panel-head">
<div>
<h2>화면별 데이터 소스</h2>
<p>각 탭/기능이 실제로 어떤 테이블을 읽고 저장하는지 빠르게 확인합니다.</p>
</div>
</div>
<div id="screen-map" class="panel-body mapping-list"></div>
</article>
</div>
</section>
</div>
<div id="preview-modal" class="modal-overlay" aria-hidden="true">
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="preview-title">
<div class="modal-head">
<div>
<h2 id="preview-title">테이블 내용 미리보기</h2>
<p id="preview-subtitle" class="muted">선택한 테이블의 컬럼과 최대 50개 row를 표시합니다.</p>
</div>
<button id="preview-close" class="modal-close" type="button" aria-label="닫기">×</button>
</div>
<div class="modal-body">
<div id="preview-meta" class="preview-meta"></div>
<div class="panel-body tight">
<div class="preview-table-wrap">
<table>
<thead id="preview-head" class="sticky-head"></thead>
<tbody id="preview-body">
<tr><td class="empty">왼쪽 표에서 테이블을 선택하세요.</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<script>
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function formatNumber(value) {
return new Intl.NumberFormat("ko-KR").format(Number(value || 0));
}
function formatDateTime(value) {
if (!value) return '<span class="muted">-</span>';
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return escapeHtml(value);
return parsed.toLocaleString("ko-KR", { hour12: false });
}
function formatBytes(value) {
const size = Number(value || 0);
if (size <= 0) return "0 B";
const units = ["B", "KB", "MB", "GB"];
let current = size;
let unit = 0;
while (current >= 1024 && unit < units.length - 1) {
current /= 1024;
unit += 1;
}
return `${current.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`;
}
function renderOverview(overview) {
const target = document.getElementById("overview");
target.innerHTML = [
["핵심 테이블", overview.visible_tables],
["전체 테이블", overview.total_tables],
["등록 인원", overview.registered_members],
["재직 인원", overview.active_members],
["고정 오피스 도면", overview.fixed_office_maps],
["현재 active 도면", overview.active_seat_maps],
["Import 배치", overview.import_batches],
["바이너리 원본", overview.binary_sources],
].map(([label, value]) => `
<article class="kpi">
<span class="kpi-label">${escapeHtml(label)}</span>
<span class="kpi-value">${formatNumber(value)}</span>
</article>
`).join("");
}
function renderTables(items) {
const target = document.getElementById("table-body");
if (!items.length) {
target.innerHTML = '<tr><td colspan="5" class="empty">표시할 테이블이 없습니다.</td></tr>';
return;
}
target.innerHTML = items.map((item) => `
<tr>
<td><span class="domain-tag ${escapeHtml(item.domain)}">${escapeHtml(item.domain)}</span></td>
<td>
<div style="margin-bottom:8px;">
<span class="group-tag ${item.group === '유지' ? 'keep' : item.group === '원본·추적' ? 'trace' : item.group === '정리 후보' ? 'cleanup' : 'caution'}">${escapeHtml(item.group || '주의')}</span>
</div>
<button class="table-trigger" type="button" data-schema="${escapeHtml(item.schema)}" data-table="${escapeHtml(item.table_name)}">${escapeHtml(item.label)}</button>
<div class="muted">${escapeHtml(item.table_ref)}</div>
<div class="table-desc">${escapeHtml(item.description)}</div>
</td>
<td>${formatNumber(item.row_count)}</td>
<td>${formatDateTime(item.last_event_at)}</td>
<td>
<div class="view-list">
${(item.related_views || []).map((view) => `<span class="view-pill">${escapeHtml(view)}</span>`).join("")}
</div>
</td>
</tr>
`).join("");
target.querySelectorAll(".table-trigger").forEach((button) => {
button.addEventListener("click", () => {
loadTablePreview(button.dataset.schema, button.dataset.table);
});
});
}
function renderBatches(items) {
const target = document.getElementById("batch-body");
if (!items.length) {
target.innerHTML = '<tr><td colspan="3" class="empty">적재 배치가 없습니다.</td></tr>';
return;
}
target.innerHTML = items.map((item) => `
<tr>
<td>
<div class="table-title">${escapeHtml(item.source_name)}</div>
<div class="muted">${escapeHtml(item.source_key)}</div>
</td>
<td>${formatNumber(item.row_count)}</td>
<td>${formatDateTime(item.imported_at)}</td>
</tr>
`).join("");
}
function renderBinarySources(items) {
const target = document.getElementById("binary-body");
if (!items.length) {
target.innerHTML = '<tr><td colspan="3" class="empty">보관 중인 바이너리 원본이 없습니다.</td></tr>';
return;
}
target.innerHTML = items.map((item) => `
<tr>
<td>
<div class="table-title">${escapeHtml(item.source_name)}</div>
<div class="muted">${escapeHtml(item.source_key)}</div>
</td>
<td>${escapeHtml(item.filename)}</td>
<td>${formatBytes(item.byte_size)}</td>
</tr>
`).join("");
}
function renderNotes(notes) {
const target = document.getElementById("notes");
target.innerHTML = (notes || []).map((note) => `<li>${escapeHtml(note)}</li>`).join("");
}
function renderGroupSummary(summary) {
const target = document.getElementById("group-summary");
const groups = [
["유지", "keep"],
["주의", "caution"],
["원본·추적", "trace"],
["정리 후보", "cleanup"],
];
target.innerHTML = groups.map(([label, klass]) => `
<div style="display:grid; gap:8px; margin-bottom:16px;">
<div><span class="group-tag ${klass}">${escapeHtml(label)}</span></div>
<div class="view-list">
${((summary && summary[label]) || []).map((item) => `<span class="view-pill">${escapeHtml(item)}</span>`).join("") || '<span class="muted">없음</span>'}
</div>
</div>
`).join("");
}
function renderProductSummary(summary) {
const target = document.getElementById("product-summary");
const groups = [
"탭 데이터",
"로그인·권한",
"히스토리",
"로우데이터·적재",
"보정·보조",
];
target.innerHTML = groups.map((label) => `
<div style="display:grid; gap:8px; margin-bottom:16px;">
<div><span class="group-tag keep">${escapeHtml(label)}</span></div>
<div class="view-list">
${((summary && summary[label]) || []).map((item) => `<span class="view-pill">${escapeHtml(item)}</span>`).join("") || '<span class="muted">없음</span>'}
</div>
</div>
`).join("");
}
function renderScreenMap(items) {
const target = document.getElementById("screen-map");
if (!items || !items.length) {
target.innerHTML = '<div class="empty">화면별 데이터 소스 정보가 없습니다.</div>';
return;
}
target.innerHTML = items.map((item) => `
<article class="mapping-card">
<h3>${escapeHtml(item.screen || "")}</h3>
<div class="mapping-table-list">
${(item.tables || []).map((table) => `<span class="view-pill">${escapeHtml(table)}</span>`).join("")}
</div>
<p>${escapeHtml(item.write_flow || "")}</p>
</article>
`).join("");
}
function renderTablePreview(payload) {
const previewModal = document.getElementById("preview-modal");
const previewMeta = document.getElementById("preview-meta");
const previewTitle = document.getElementById("preview-title");
const previewSubtitle = document.getElementById("preview-subtitle");
const previewHead = document.getElementById("preview-head");
const previewBody = document.getElementById("preview-body");
previewTitle.textContent = `${payload.label} · ${payload.table_ref}`;
previewSubtitle.textContent = `${formatNumber(payload.row_count)} rows / 최대 ${formatNumber(payload.limit)}개 표시`;
previewMeta.innerHTML = `
<div>
<div class="table-title">${escapeHtml(payload.label)}</div>
<div class="muted">${escapeHtml(payload.description || "")}</div>
</div>
<div class="preview-columns">
${(payload.columns || []).map((column) => `
<span class="column-pill">${escapeHtml(column.name)} <em>${escapeHtml(column.type)}</em></span>
`).join("")}
</div>
`;
const columns = payload.columns || [];
previewHead.innerHTML = `<tr>${columns.map((column) => `<th>${escapeHtml(column.name)}</th>`).join("")}</tr>`;
if (!payload.rows || !payload.rows.length) {
previewBody.innerHTML = `<tr><td colspan="${Math.max(columns.length, 1)}" class="empty">표시할 row가 없습니다.</td></tr>`;
previewModal.classList.add("open");
previewModal.setAttribute("aria-hidden", "false");
return;
}
previewBody.innerHTML = payload.rows.map((row) => `
<tr>
${columns.map((column) => `<td>${escapeHtml(row[column.name] ?? "")}</td>`).join("")}
</tr>
`).join("");
previewModal.classList.add("open");
previewModal.setAttribute("aria-hidden", "false");
}
async function loadTablePreview(schema, table) {
const previewModal = document.getElementById("preview-modal");
const previewMeta = document.getElementById("preview-meta");
const previewTitle = document.getElementById("preview-title");
const previewSubtitle = document.getElementById("preview-subtitle");
const previewHead = document.getElementById("preview-head");
const previewBody = document.getElementById("preview-body");
previewTitle.textContent = `${table}`;
previewSubtitle.textContent = "테이블 내용을 불러오는 중입니다.";
previewMeta.innerHTML = "";
previewHead.innerHTML = "";
previewBody.innerHTML = `<tr><td class="empty">테이블 내용을 불러오는 중입니다.</td></tr>`;
previewModal.classList.add("open");
previewModal.setAttribute("aria-hidden", "false");
const response = await fetch(`/api/admin/db-status/table?schema=${encodeURIComponent(schema)}&table=${encodeURIComponent(table)}`, { cache: "no-store" });
if (!response.ok) {
throw new Error(`테이블 내용을 불러오지 못했습니다. (${response.status})`);
}
const payload = await response.json();
renderTablePreview(payload);
}
async function bootstrap() {
const response = await fetch("/api/admin/db-status", { cache: "no-store" });
if (!response.ok) {
throw new Error(`DB 상태를 불러오지 못했습니다. (${response.status})`);
}
const payload = await response.json();
document.getElementById("generated-at").textContent = payload.generated_at
? `갱신 ${formatDateTime(payload.generated_at)}`
: "갱신 시각 없음";
renderOverview(payload.overview || {});
renderTables(payload.tables || []);
renderBatches(payload.import_batches || []);
renderBinarySources(payload.binary_sources || []);
renderNotes(payload.notes || []);
renderGroupSummary(payload.group_summary || {});
renderProductSummary(payload.product_summary || {});
renderScreenMap(payload.screen_map || []);
}
document.getElementById("preview-close").addEventListener("click", () => {
const modal = document.getElementById("preview-modal");
modal.classList.remove("open");
modal.setAttribute("aria-hidden", "true");
});
document.getElementById("preview-modal").addEventListener("click", (event) => {
if (event.target.id !== "preview-modal") return;
const modal = document.getElementById("preview-modal");
modal.classList.remove("open");
modal.setAttribute("aria-hidden", "true");
});
bootstrap().catch((error) => {
document.getElementById("table-body").innerHTML = `<tr><td colspan="5" class="empty">${escapeHtml(error.message || "DB 상태를 불러오지 못했습니다.")}</td></tr>`;
document.getElementById("batch-body").innerHTML = '<tr><td colspan="3" class="empty">배치 정보를 불러오지 못했습니다.</td></tr>';
document.getElementById("binary-body").innerHTML = '<tr><td colspan="3" class="empty">바이너리 원본 정보를 불러오지 못했습니다.</td></tr>';
});
</script>
</body>
</html>

View File

@@ -79,6 +79,7 @@
<button class="nav-pill" type="button" data-view="project">프로젝트별 분석</button>
<button class="nav-pill" type="button" data-view="team">팀/개인별 분석</button>
<button class="nav-pill active" type="button" data-view="organization">조직 현황</button>
<button class="nav-pill" type="button" data-view="db-status">DB 상태</button>
</div>
<div class="header-actions">
@@ -119,6 +120,11 @@
<iframe id="team-frame" src="/integrations/mh" data-src="/integrations/mh" title="팀/개인별 분석 화면"></iframe>
</div>
</section>
<section id="db-status-stage" class="main-stage" hidden>
<div class="stage-frame">
<iframe id="db-status-frame" src="/db-status.html?v=20260401-02" data-src="/db-status.html?v=20260401-02" title="DB 상태 화면"></iframe>
</div>
</section>
<section id="seatmap-admin-stage" class="main-stage" hidden>
<div class="seatmap-layout">
<div class="seatmap-topbar">

View File

@@ -14,7 +14,8 @@
- backend `/integrations/payment`, `/integrations/mh`는 위 `served/*`만 읽는다.
- backend `/integrations/ledger``/integrations/ledger-assets/*``served/ledger/*`만 읽는다.
- 새 기능을 붙일 때도 실제 서비스 파일은 `served/` 기준으로 수정한다.
- 다만 `payment`, `mh`, `ledger`, `db-status`는 이제 앱 소스가 따로 있으므로, 실제 수정은 `frontend/apps/*`에서 하고 publish 스크립트로 `served/`에 반영한다.
-`served/`는 runtime 기준이고, 사람이 직접 먼저 수정하는 source-of-truth는 아니다.
## Reference
@@ -30,6 +31,7 @@
- 디자인 비교용 파일
- `reference/ledger/MH 통합 대시보드_260320.html`
- `reference/ledger/MH 통합 대시보드_260320.css`
- `reference/ledger/사업관리대장-1.xlsx`
## Temporary Comparison Copies

View File

@@ -0,0 +1,21 @@
# reference/ledger
이 디렉터리는 `사업관리대장` 원본 참고 자산만 둔다.
원칙:
- 직접 서빙하지 않는다.
- 비교, 복구, 기준 확인이 필요할 때만 본다.
- 실제 수정 원본은 `frontend/apps/ledger/*`다.
- 실제 runtime 응답은 `incoming-files/served/ledger/*`다.
현재 포함:
- 원본 HTML/CSS
- 원본 XLSX
- 과거 override 참고 파일
주의:
- `reference/ledger` 아래에 다시 `ledger/` 같은 중첩 복사본을 만들지 않는다.
- 원본 정리가 필요하면 이 디렉터리에서만 구조를 맞춘다.

View File

@@ -0,0 +1,5 @@
## DB Status Served Output
- 이 디렉터리는 `frontend/apps/db-status` publish 결과물만 둔다.
- backend `/admin/db-status`는 여기의 `index.html`만 서빙한다.
- 수정은 직접 여기서 하지 말고 `./scripts/publish_db_status_app.sh`를 사용한다.

View File

@@ -0,0 +1,801 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DB 상태</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Pretendard:wght@400;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/design-tokens.css?v=20260401-01">
<link rel="stylesheet" href="/design-patterns.css?v=20260401-01">
<style>
:root {
color-scheme: light;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Pretendard", sans-serif;
background:
radial-gradient(circle at top left, rgba(247, 217, 119, 0.28), transparent 30%),
linear-gradient(180deg, var(--ds-bg, #f5f1e8) 0%, #efe6d5 100%);
color: var(--ds-ink, #2f2419);
}
.page {
max-width: 2000px;
margin: 0 auto;
padding: 28px;
display: grid;
gap: 20px;
}
.hero {
display: grid;
gap: 12px;
padding: 28px 30px;
border: 1px solid rgba(134, 98, 47, 0.14);
border-radius: 28px;
background: linear-gradient(135deg, rgba(255, 250, 240, 0.96), rgba(242, 232, 214, 0.92));
box-shadow: 0 28px 68px rgba(88, 61, 23, 0.15);
}
.hero h1 {
margin: 0;
font-size: 30px;
font-weight: 800;
letter-spacing: -0.03em;
}
.hero p {
margin: 0;
color: rgba(76, 58, 35, 0.82);
line-height: 1.6;
}
.overview {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 14px;
}
.kpi {
padding: 18px 20px;
border-radius: 22px;
background: rgba(255, 252, 247, 0.92);
border: 1px solid rgba(140, 110, 59, 0.14);
box-shadow: 0 14px 34px rgba(81, 58, 23, 0.08);
}
.kpi-label {
display: block;
font-size: 12px;
font-weight: 700;
color: rgba(112, 84, 41, 0.72);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.kpi-value {
display: block;
margin-top: 8px;
font-size: 28px;
font-weight: 800;
color: #3d2e1d;
}
.grid {
display: grid;
grid-template-columns: 1.4fr 1fr;
gap: 20px;
}
.panel {
border-radius: 24px;
background: rgba(255, 251, 245, 0.96);
border: 1px solid rgba(142, 110, 54, 0.14);
box-shadow: 0 18px 48px rgba(85, 60, 24, 0.08);
overflow: hidden;
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 18px 22px 14px;
border-bottom: 1px solid rgba(128, 98, 48, 0.12);
}
.panel-head h2 {
margin: 0;
font-size: 18px;
font-weight: 800;
letter-spacing: -0.02em;
}
.panel-head p {
margin: 4px 0 0;
font-size: 13px;
color: rgba(102, 77, 41, 0.72);
}
.panel-body {
padding: 16px 18px 20px;
}
.panel-body.tight {
padding-top: 0;
}
.meta-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 11px;
border-radius: 999px;
background: rgba(251, 236, 196, 0.8);
color: #7a5923;
font-size: 12px;
font-weight: 700;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
th, td {
padding: 12px 10px;
vertical-align: top;
border-bottom: 1px solid rgba(130, 100, 53, 0.1);
text-align: left;
}
th {
font-size: 12px;
font-weight: 800;
color: rgba(104, 79, 40, 0.76);
text-transform: uppercase;
letter-spacing: 0.06em;
white-space: nowrap;
}
tbody tr:hover {
background: rgba(250, 240, 213, 0.34);
}
.domain-tag {
display: inline-flex;
align-items: center;
padding: 4px 9px;
border-radius: 999px;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.04em;
text-transform: uppercase;
background: rgba(90, 122, 94, 0.14);
color: #456b4c;
}
.domain-tag.integration { background: rgba(196, 143, 58, 0.16); color: #8c5f18; }
.domain-tag.history { background: rgba(120, 92, 156, 0.14); color: #6a4b8b; }
.domain-tag.auth { background: rgba(103, 114, 154, 0.14); color: #48567c; }
.domain-tag.other { background: rgba(131, 112, 80, 0.12); color: #6a5637; }
.group-tag {
display: inline-flex;
align-items: center;
padding: 4px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.03em;
background: rgba(240, 231, 214, 0.95);
color: #674d27;
white-space: nowrap;
}
.group-tag.keep { background: rgba(112, 143, 87, 0.18); color: #49623c; }
.group-tag.caution { background: rgba(214, 167, 84, 0.18); color: #8f5d17; }
.group-tag.trace { background: rgba(113, 120, 168, 0.16); color: #56628c; }
.group-tag.cleanup { background: rgba(184, 111, 84, 0.16); color: #884d39; }
.table-title {
font-weight: 800;
color: #2f2419;
}
.table-trigger {
all: unset;
cursor: pointer;
color: #2f2419;
font-weight: 800;
}
.table-trigger:hover {
color: #80591f;
text-decoration: underline;
}
.table-desc {
margin-top: 5px;
color: rgba(98, 75, 42, 0.72);
line-height: 1.5;
}
.view-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.view-pill {
display: inline-flex;
padding: 4px 8px;
border-radius: 999px;
background: rgba(86, 119, 93, 0.12);
color: #456b4c;
font-size: 11px;
font-weight: 700;
}
.notes {
margin: 0;
padding-left: 18px;
display: grid;
gap: 10px;
color: rgba(84, 65, 38, 0.84);
line-height: 1.55;
}
.mapping-list {
display: grid;
gap: 14px;
}
.mapping-card {
padding: 14px 16px;
border-radius: 18px;
background: rgba(255, 249, 239, 0.92);
border: 1px solid rgba(132, 102, 54, 0.12);
}
.mapping-card h3 {
margin: 0 0 8px;
font-size: 15px;
font-weight: 800;
letter-spacing: -0.02em;
}
.mapping-card p {
margin: 8px 0 0;
color: rgba(98, 75, 42, 0.74);
line-height: 1.55;
font-size: 13px;
}
.mapping-table-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.preview-meta {
display: grid;
gap: 10px;
padding: 16px 18px 0;
}
.preview-columns {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.column-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-radius: 999px;
background: rgba(240, 231, 214, 0.9);
color: #634a25;
font-size: 12px;
font-weight: 700;
}
.column-pill em {
font-style: normal;
color: rgba(99, 74, 37, 0.68);
font-weight: 600;
}
.preview-table-wrap {
overflow: auto;
max-height: 520px;
border-top: 1px solid rgba(128, 98, 48, 0.12);
}
.sticky-head th {
position: sticky;
top: 0;
background: rgba(255, 248, 236, 0.98);
z-index: 1;
}
.muted {
color: rgba(110, 86, 50, 0.72);
}
.empty {
padding: 22px;
text-align: center;
color: rgba(102, 77, 41, 0.72);
}
.modal-overlay {
position: fixed;
inset: 0;
display: none;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(44, 31, 16, 0.42);
backdrop-filter: blur(6px);
z-index: 1000;
}
.modal-overlay.open {
display: flex;
}
.modal-card {
width: min(1600px, 100%);
max-height: min(88vh, 980px);
border-radius: 28px;
background: rgba(255, 250, 243, 0.98);
border: 1px solid rgba(142, 110, 54, 0.18);
box-shadow: 0 32px 80px rgba(59, 40, 16, 0.28);
overflow: hidden;
display: grid;
grid-template-rows: auto auto 1fr;
}
.modal-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 22px 24px 16px;
border-bottom: 1px solid rgba(128, 98, 48, 0.12);
}
.modal-head h2 {
margin: 0;
font-size: 22px;
font-weight: 800;
letter-spacing: -0.03em;
}
.modal-close {
border: 0;
background: rgba(240, 229, 206, 0.9);
color: #6d5127;
width: 38px;
height: 38px;
border-radius: 999px;
font-size: 18px;
font-weight: 800;
cursor: pointer;
}
.modal-close:hover {
background: rgba(225, 208, 174, 0.96);
}
.modal-body {
overflow: hidden;
display: grid;
grid-template-rows: auto 1fr;
}
@media (max-width: 1200px) {
.grid { grid-template-columns: 1fr; }
}
@media (max-width: 720px) {
.page { padding: 16px; }
.hero { padding: 22px 20px; }
.kpi-value { font-size: 24px; }
th, td { padding: 10px 8px; }
}
</style>
</head>
<body>
<div class="page">
<section class="hero">
<span class="meta-chip">#2 백엔드 영속 저장 구조 운영</span>
<h1>DB 상태와 저장 구조를 화면에서 바로 확인</h1>
<p>
이 화면은 현재 운영 DB의 핵심 테이블, 적재 상태, 최근 import 흐름을 SQL 없이 확인하기 위한 관리자용 뷰어입니다.
이후 저장 구조 검증과 데이터 정합성 작업은 이 화면을 기준으로 진행합니다.
</p>
<p>
`원본 import 배치`는 업로드한 원본 파일이 몇 행으로 적재됐는지 보여주고, `바이너리 원본 보관`은 엑셀 같은 파일 자체를 DB에 보관하는 상태를 보여줍니다.
</p>
<p>
아래 표는 전체 테이블을 보여주고, 오른쪽 패널은 화면별 데이터 소스와 저장 흐름을 운영 관점으로 묶어서 보여줍니다.
</p>
</section>
<section id="overview" class="overview"></section>
<section class="grid">
<article class="panel">
<div class="panel-head">
<div>
<h2>전체 테이블 현황</h2>
<p>현재 운영 DB의 전체 26개 테이블을 보여주며, 테이블명을 누르면 샘플 row를 바로 확인할 수 있습니다.</p>
</div>
<span id="generated-at" class="meta-chip">로딩 중</span>
</div>
<div class="panel-body">
<table>
<thead>
<tr>
<th>도메인</th>
<th>테이블</th>
<th>Rows</th>
<th>최근 갱신</th>
<th>연결 화면</th>
</tr>
</thead>
<tbody id="table-body">
<tr><td colspan="5" class="empty">DB 상태를 불러오는 중입니다.</td></tr>
</tbody>
</table>
</div>
</article>
<div style="display:grid; gap:20px;">
<article class="panel">
<div class="panel-head">
<div>
<h2>원본 import 배치</h2>
<p>현재 적재된 원본 파일 배치와 row 수입니다.</p>
</div>
</div>
<div class="panel-body">
<table>
<thead>
<tr>
<th>Source</th>
<th>Rows</th>
<th>Imported</th>
</tr>
</thead>
<tbody id="batch-body">
<tr><td colspan="3" class="empty">로딩 중</td></tr>
</tbody>
</table>
</div>
</article>
<article class="panel">
<div class="panel-head">
<div>
<h2>바이너리 원본 보관</h2>
<p>엑셀 같은 바이너리 원본의 DB 보관 상태입니다.</p>
</div>
</div>
<div class="panel-body">
<table>
<thead>
<tr>
<th>Source</th>
<th>파일</th>
<th>크기</th>
</tr>
</thead>
<tbody id="binary-body">
<tr><td colspan="3" class="empty">로딩 중</td></tr>
</tbody>
</table>
</div>
</article>
<article class="panel">
<div class="panel-head">
<div>
<h2>운영 메모</h2>
<p>#2에서 확인해야 할 저장 구조 핵심 포인트입니다.</p>
</div>
</div>
<div class="panel-body">
<ol id="notes" class="notes"></ol>
</div>
</article>
<article class="panel">
<div class="panel-head">
<div>
<h2>운영 분류</h2>
<p>유지/주의/원본·추적/정리 후보 기준과 제품 관점 묶음을 같이 봅니다.</p>
</div>
</div>
<div class="panel-body">
<div id="group-summary"></div>
<div id="product-summary" style="margin-top:18px;"></div>
</div>
</article>
<article class="panel">
<div class="panel-head">
<div>
<h2>화면별 데이터 소스</h2>
<p>각 탭/기능이 실제로 어떤 테이블을 읽고 저장하는지 빠르게 확인합니다.</p>
</div>
</div>
<div id="screen-map" class="panel-body mapping-list"></div>
</article>
</div>
</section>
</div>
<div id="preview-modal" class="modal-overlay" aria-hidden="true">
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="preview-title">
<div class="modal-head">
<div>
<h2 id="preview-title">테이블 내용 미리보기</h2>
<p id="preview-subtitle" class="muted">선택한 테이블의 컬럼과 최대 50개 row를 표시합니다.</p>
</div>
<button id="preview-close" class="modal-close" type="button" aria-label="닫기">×</button>
</div>
<div class="modal-body">
<div id="preview-meta" class="preview-meta"></div>
<div class="panel-body tight">
<div class="preview-table-wrap">
<table>
<thead id="preview-head" class="sticky-head"></thead>
<tbody id="preview-body">
<tr><td class="empty">왼쪽 표에서 테이블을 선택하세요.</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<script>
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function formatNumber(value) {
return new Intl.NumberFormat("ko-KR").format(Number(value || 0));
}
function formatDateTime(value) {
if (!value) return '<span class="muted">-</span>';
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return escapeHtml(value);
return parsed.toLocaleString("ko-KR", { hour12: false });
}
function formatBytes(value) {
const size = Number(value || 0);
if (size <= 0) return "0 B";
const units = ["B", "KB", "MB", "GB"];
let current = size;
let unit = 0;
while (current >= 1024 && unit < units.length - 1) {
current /= 1024;
unit += 1;
}
return `${current.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`;
}
function renderOverview(overview) {
const target = document.getElementById("overview");
target.innerHTML = [
["핵심 테이블", overview.visible_tables],
["전체 테이블", overview.total_tables],
["등록 인원", overview.registered_members],
["재직 인원", overview.active_members],
["고정 오피스 도면", overview.fixed_office_maps],
["현재 active 도면", overview.active_seat_maps],
["Import 배치", overview.import_batches],
["바이너리 원본", overview.binary_sources],
].map(([label, value]) => `
<article class="kpi">
<span class="kpi-label">${escapeHtml(label)}</span>
<span class="kpi-value">${formatNumber(value)}</span>
</article>
`).join("");
}
function renderTables(items) {
const target = document.getElementById("table-body");
if (!items.length) {
target.innerHTML = '<tr><td colspan="5" class="empty">표시할 테이블이 없습니다.</td></tr>';
return;
}
target.innerHTML = items.map((item) => `
<tr>
<td><span class="domain-tag ${escapeHtml(item.domain)}">${escapeHtml(item.domain)}</span></td>
<td>
<div style="margin-bottom:8px;">
<span class="group-tag ${item.group === '유지' ? 'keep' : item.group === '원본·추적' ? 'trace' : item.group === '정리 후보' ? 'cleanup' : 'caution'}">${escapeHtml(item.group || '주의')}</span>
</div>
<button class="table-trigger" type="button" data-schema="${escapeHtml(item.schema)}" data-table="${escapeHtml(item.table_name)}">${escapeHtml(item.label)}</button>
<div class="muted">${escapeHtml(item.table_ref)}</div>
<div class="table-desc">${escapeHtml(item.description)}</div>
</td>
<td>${formatNumber(item.row_count)}</td>
<td>${formatDateTime(item.last_event_at)}</td>
<td>
<div class="view-list">
${(item.related_views || []).map((view) => `<span class="view-pill">${escapeHtml(view)}</span>`).join("")}
</div>
</td>
</tr>
`).join("");
target.querySelectorAll(".table-trigger").forEach((button) => {
button.addEventListener("click", () => {
loadTablePreview(button.dataset.schema, button.dataset.table);
});
});
}
function renderBatches(items) {
const target = document.getElementById("batch-body");
if (!items.length) {
target.innerHTML = '<tr><td colspan="3" class="empty">적재 배치가 없습니다.</td></tr>';
return;
}
target.innerHTML = items.map((item) => `
<tr>
<td>
<div class="table-title">${escapeHtml(item.source_name)}</div>
<div class="muted">${escapeHtml(item.source_key)}</div>
</td>
<td>${formatNumber(item.row_count)}</td>
<td>${formatDateTime(item.imported_at)}</td>
</tr>
`).join("");
}
function renderBinarySources(items) {
const target = document.getElementById("binary-body");
if (!items.length) {
target.innerHTML = '<tr><td colspan="3" class="empty">보관 중인 바이너리 원본이 없습니다.</td></tr>';
return;
}
target.innerHTML = items.map((item) => `
<tr>
<td>
<div class="table-title">${escapeHtml(item.source_name)}</div>
<div class="muted">${escapeHtml(item.source_key)}</div>
</td>
<td>${escapeHtml(item.filename)}</td>
<td>${formatBytes(item.byte_size)}</td>
</tr>
`).join("");
}
function renderNotes(notes) {
const target = document.getElementById("notes");
target.innerHTML = (notes || []).map((note) => `<li>${escapeHtml(note)}</li>`).join("");
}
function renderGroupSummary(summary) {
const target = document.getElementById("group-summary");
const groups = [
["유지", "keep"],
["주의", "caution"],
["원본·추적", "trace"],
["정리 후보", "cleanup"],
];
target.innerHTML = groups.map(([label, klass]) => `
<div style="display:grid; gap:8px; margin-bottom:16px;">
<div><span class="group-tag ${klass}">${escapeHtml(label)}</span></div>
<div class="view-list">
${((summary && summary[label]) || []).map((item) => `<span class="view-pill">${escapeHtml(item)}</span>`).join("") || '<span class="muted">없음</span>'}
</div>
</div>
`).join("");
}
function renderProductSummary(summary) {
const target = document.getElementById("product-summary");
const groups = [
"탭 데이터",
"로그인·권한",
"히스토리",
"로우데이터·적재",
"보정·보조",
];
target.innerHTML = groups.map((label) => `
<div style="display:grid; gap:8px; margin-bottom:16px;">
<div><span class="group-tag keep">${escapeHtml(label)}</span></div>
<div class="view-list">
${((summary && summary[label]) || []).map((item) => `<span class="view-pill">${escapeHtml(item)}</span>`).join("") || '<span class="muted">없음</span>'}
</div>
</div>
`).join("");
}
function renderScreenMap(items) {
const target = document.getElementById("screen-map");
if (!items || !items.length) {
target.innerHTML = '<div class="empty">화면별 데이터 소스 정보가 없습니다.</div>';
return;
}
target.innerHTML = items.map((item) => `
<article class="mapping-card">
<h3>${escapeHtml(item.screen || "")}</h3>
<div class="mapping-table-list">
${(item.tables || []).map((table) => `<span class="view-pill">${escapeHtml(table)}</span>`).join("")}
</div>
<p>${escapeHtml(item.write_flow || "")}</p>
</article>
`).join("");
}
function renderTablePreview(payload) {
const previewModal = document.getElementById("preview-modal");
const previewMeta = document.getElementById("preview-meta");
const previewTitle = document.getElementById("preview-title");
const previewSubtitle = document.getElementById("preview-subtitle");
const previewHead = document.getElementById("preview-head");
const previewBody = document.getElementById("preview-body");
previewTitle.textContent = `${payload.label} · ${payload.table_ref}`;
previewSubtitle.textContent = `${formatNumber(payload.row_count)} rows / 최대 ${formatNumber(payload.limit)}개 표시`;
previewMeta.innerHTML = `
<div>
<div class="table-title">${escapeHtml(payload.label)}</div>
<div class="muted">${escapeHtml(payload.description || "")}</div>
</div>
<div class="preview-columns">
${(payload.columns || []).map((column) => `
<span class="column-pill">${escapeHtml(column.name)} <em>${escapeHtml(column.type)}</em></span>
`).join("")}
</div>
`;
const columns = payload.columns || [];
previewHead.innerHTML = `<tr>${columns.map((column) => `<th>${escapeHtml(column.name)}</th>`).join("")}</tr>`;
if (!payload.rows || !payload.rows.length) {
previewBody.innerHTML = `<tr><td colspan="${Math.max(columns.length, 1)}" class="empty">표시할 row가 없습니다.</td></tr>`;
previewModal.classList.add("open");
previewModal.setAttribute("aria-hidden", "false");
return;
}
previewBody.innerHTML = payload.rows.map((row) => `
<tr>
${columns.map((column) => `<td>${escapeHtml(row[column.name] ?? "")}</td>`).join("")}
</tr>
`).join("");
previewModal.classList.add("open");
previewModal.setAttribute("aria-hidden", "false");
}
async function loadTablePreview(schema, table) {
const previewModal = document.getElementById("preview-modal");
const previewMeta = document.getElementById("preview-meta");
const previewTitle = document.getElementById("preview-title");
const previewSubtitle = document.getElementById("preview-subtitle");
const previewHead = document.getElementById("preview-head");
const previewBody = document.getElementById("preview-body");
previewTitle.textContent = `${table}`;
previewSubtitle.textContent = "테이블 내용을 불러오는 중입니다.";
previewMeta.innerHTML = "";
previewHead.innerHTML = "";
previewBody.innerHTML = `<tr><td class="empty">테이블 내용을 불러오는 중입니다.</td></tr>`;
previewModal.classList.add("open");
previewModal.setAttribute("aria-hidden", "false");
const response = await fetch(`/api/admin/db-status/table?schema=${encodeURIComponent(schema)}&table=${encodeURIComponent(table)}`, { cache: "no-store" });
if (!response.ok) {
throw new Error(`테이블 내용을 불러오지 못했습니다. (${response.status})`);
}
const payload = await response.json();
renderTablePreview(payload);
}
async function bootstrap() {
const response = await fetch("/api/admin/db-status", { cache: "no-store" });
if (!response.ok) {
throw new Error(`DB 상태를 불러오지 못했습니다. (${response.status})`);
}
const payload = await response.json();
document.getElementById("generated-at").textContent = payload.generated_at
? `갱신 ${formatDateTime(payload.generated_at)}`
: "갱신 시각 없음";
renderOverview(payload.overview || {});
renderTables(payload.tables || []);
renderBatches(payload.import_batches || []);
renderBinarySources(payload.binary_sources || []);
renderNotes(payload.notes || []);
renderGroupSummary(payload.group_summary || {});
renderProductSummary(payload.product_summary || {});
renderScreenMap(payload.screen_map || []);
}
document.getElementById("preview-close").addEventListener("click", () => {
const modal = document.getElementById("preview-modal");
modal.classList.remove("open");
modal.setAttribute("aria-hidden", "true");
});
document.getElementById("preview-modal").addEventListener("click", (event) => {
if (event.target.id !== "preview-modal") return;
const modal = document.getElementById("preview-modal");
modal.classList.remove("open");
modal.setAttribute("aria-hidden", "true");
});
bootstrap().catch((error) => {
document.getElementById("table-body").innerHTML = `<tr><td colspan="5" class="empty">${escapeHtml(error.message || "DB 상태를 불러오지 못했습니다.")}</td></tr>`;
document.getElementById("batch-body").innerHTML = '<tr><td colspan="3" class="empty">배치 정보를 불러오지 못했습니다.</td></tr>';
document.getElementById("binary-body").innerHTML = '<tr><td colspan="3" class="empty">바이너리 원본 정보를 불러오지 못했습니다.</td></tr>';
});
</script>
</body>
</html>

View File

@@ -32,6 +32,13 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
}
location /admin/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
proxy_pass http://frontend:80;
proxy_set_header Host $host;

View File

@@ -0,0 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
APP_SRC_DIR="${REPO_ROOT}/frontend/apps/db-status"
SERVED_DIR="${REPO_ROOT}/incoming-files/served/db-status"
FRONTEND_PUBLIC_DIR="${REPO_ROOT}/frontend/public"
mkdir -p "${SERVED_DIR}"
cp "${APP_SRC_DIR}/index.html" "${SERVED_DIR}/index.html"
cp "${APP_SRC_DIR}/index.html" "${FRONTEND_PUBLIC_DIR}/db-status.html"
echo "Published db-status app to ${SERVED_DIR} and ${FRONTEND_PUBLIC_DIR}/db-status.html"