Compare commits
2 Commits
03e90d18a3
...
total
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40ff4f01ac | ||
|
|
d0e055973e |
@@ -1,497 +0,0 @@
|
||||
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,
|
||||
}
|
||||
@@ -281,19 +281,6 @@ 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',
|
||||
@@ -342,6 +329,18 @@ 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 (
|
||||
@@ -535,9 +534,6 @@ 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);
|
||||
|
||||
@@ -547,6 +543,9 @@ 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 (
|
||||
@@ -612,9 +611,6 @@ 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;
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
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
|
||||
@@ -21,19 +21,13 @@ 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 HTMLResponse
|
||||
from fastapi.responses import FileResponse, HTMLResponse, Response
|
||||
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")
|
||||
@@ -50,7 +44,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"
|
||||
DB_STATUS_SERVED_DIR = INCOMING_SERVED_DIR / "db-status"
|
||||
BUSINESS_DASHBOARD_DIR = INCOMING_FILES_DIR / "사업관리대장"
|
||||
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"
|
||||
@@ -72,6 +66,7 @@ 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
|
||||
@@ -93,6 +88,60 @@ 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),
|
||||
@@ -3927,26 +3976,65 @@ def startup() -> None:
|
||||
init_db()
|
||||
with get_conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
sync_default_business_ledger_source(cur, INCOMING_FILES_DIR, INCOMING_SERVED_DIR)
|
||||
sync_default_business_ledger_source(cur)
|
||||
sync_auth_users_from_members(cur)
|
||||
conn.commit()
|
||||
|
||||
|
||||
app.mount("/legacy/static", StaticFiles(directory=LEGACY_STATIC_DIR, check_dir=False), name="legacy-static")
|
||||
|
||||
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.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,
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/auth/login")
|
||||
@@ -4271,6 +4359,18 @@ 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()
|
||||
@@ -4478,3 +4578,58 @@ 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)
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
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)
|
||||
@@ -228,10 +228,354 @@
|
||||
|
||||
- [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
|
||||
|
||||
- `#2` 영속성 운영 검증과 문서 기준 정리
|
||||
- 사업관리대장 원본 담당자와 세부 데이터 규칙 정렬
|
||||
- 자리배치도 `#7`, `#8` 재작업 및 마무리
|
||||
- 권한 제어와 mock login 정리
|
||||
- `#9` as-of date 기반 history 구조 설계 및 점진적 도입
|
||||
- 자리배치도 조직 트리, 나머지 사무실 도면 등 실사용 기능 고도화
|
||||
- 프로젝트별 분석의 남은 소수점/분류 오차 정리
|
||||
- 조직현황의 장기적 앱 구조 승격 검토
|
||||
|
||||
@@ -53,15 +53,12 @@
|
||||
- [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) 기준으로 본다.
|
||||
@@ -109,8 +106,6 @@
|
||||
- [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
|
||||
|
||||
@@ -131,18 +126,20 @@
|
||||
|
||||
## Open Issues Relevant Now
|
||||
|
||||
- `#2` 백엔드 영속 저장 구조 운영 마무리
|
||||
- `#14` 누적된 임시 로직 정리 및 중복 코드 제거
|
||||
- `#16` 사업관리대장 메인 후속 정리 및 기준 분석
|
||||
- `#19` 8081 백엔드 라우터/서빙 deeper 모듈 분리
|
||||
- `#21` organization 레거시 구조 승격 및 장기 고도화
|
||||
- `#16` 사업관리대장 메인 연동 및 기본 원본 DB화
|
||||
- `#17` 8081 분리 worktree 기동 절차와 로컬 디자인 자산 복제 고정
|
||||
- `#18` 8081 파일 책임 맵 정리 및 프런트 서빙 경로 정돈
|
||||
- `#19` 8081 백엔드 라우터/서빙 책임 분리
|
||||
- `#20` 8081 worktree 준비 스크립트·문서·운영 규칙 정리
|
||||
- `#21` reference 의존 제거 및 8081 실제 서비스 코드 독립화
|
||||
|
||||
## Recommended Next Work Order
|
||||
|
||||
1. `#2` 기준으로 DB 상태 화면과 저장 구조 검증 흐름 고도화
|
||||
2. `#21` 이후 기준으로 실제 서비스 파일과 reference 파일 경계를 유지
|
||||
3. 사업관리대장 세부 데이터 정합성 보정은 원본 규칙 분석 후 진행
|
||||
4. 필요 시 `#19` 잔여 정리 항목 재평가
|
||||
1. `#21` 이후 기준으로 실제 서비스 파일과 reference 파일 경계를 유지
|
||||
2. 사업관리대장 세부 데이터 정합성 보정
|
||||
3. 그 다음 화면별 앱 구조 승격 검토
|
||||
4. 필요 시 `#19`, `#20` 잔여 정리 항목 재평가
|
||||
|
||||
## Quick Resume Prompt
|
||||
|
||||
@@ -153,6 +150,4 @@
|
||||
- 먼저 [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`
|
||||
- `#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`를 먼저 확인
|
||||
- 작업 전 `git status`, dev 컨테이너 상태, `/api/health`, `/legacy/organization`, `/integrations/payment`, `/integrations/ledger`, `/integrations/mh`를 먼저 확인
|
||||
|
||||
@@ -47,16 +47,10 @@
|
||||
- 현재 실제 서빙 파일: `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/*`를 직접 서빙한다.
|
||||
@@ -115,5 +109,4 @@
|
||||
- 로그인은 `styles.css`만 본다.
|
||||
- 허브 8081 디자인은 `styles-8081-design.css`만 본다.
|
||||
- `/integrations/payment`, `/integrations/mh`의 실제 서빙 파일 위치가 문서와 코드에서 일치한다.
|
||||
- `/db-status.html`가 현재 DB 저장 구조와 import 상태를 화면에서 바로 보여준다.
|
||||
- 기존 참고 자산을 지우지 않고도 실제 서빙 경로와 참고 경로를 구분할 수 있다.
|
||||
|
||||
@@ -1,205 +0,0 @@
|
||||
# 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 상태` 화면에서 이 분류를 기준으로 계속 설명 유지
|
||||
@@ -1,7 +0,0 @@
|
||||
## 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 저장 구조와 적재 상태를 사람이 읽을 수 있게 보여준다.
|
||||
@@ -1,801 +0,0 @@
|
||||
<!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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -23,8 +23,6 @@ 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");
|
||||
@@ -117,7 +115,6 @@ const viewLabels = {
|
||||
project: "프로젝트별 분석",
|
||||
team: "팀/개인별 분석",
|
||||
organization: "조직 현황",
|
||||
"db-status": "DB 상태",
|
||||
"seatmap-admin": "자리배치도",
|
||||
"seatmap-readonly": "자리배치도",
|
||||
};
|
||||
@@ -1626,7 +1623,6 @@ 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) {
|
||||
@@ -1645,10 +1641,6 @@ 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";
|
||||
@@ -1658,7 +1650,7 @@ function setActiveView(view) {
|
||||
seatMapReadonlyStage.style.display = isSeatMapReadonly ? "flex" : "none";
|
||||
}
|
||||
if (emptyStage) {
|
||||
const showEmpty = !isLedger && !isOrganization && !isProject && !isTeam && !isDbStatus && !isSeatMapAdmin && !isSeatMapReadonly;
|
||||
const showEmpty = !isLedger && !isOrganization && !isProject && !isTeam && !isSeatMapAdmin && !isSeatMapReadonly;
|
||||
emptyStage.hidden = !showEmpty;
|
||||
emptyStage.style.display = showEmpty ? "flex" : "none";
|
||||
}
|
||||
@@ -1685,10 +1677,6 @@ 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();
|
||||
}
|
||||
|
||||
@@ -1,801 +0,0 @@
|
||||
<!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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -79,7 +79,6 @@
|
||||
<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">
|
||||
@@ -120,11 +119,6 @@
|
||||
<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">
|
||||
|
||||
@@ -14,8 +14,7 @@
|
||||
|
||||
- backend `/integrations/payment`, `/integrations/mh`는 위 `served/*`만 읽는다.
|
||||
- backend `/integrations/ledger`와 `/integrations/ledger-assets/*`도 `served/ledger/*`만 읽는다.
|
||||
- 다만 `payment`, `mh`, `ledger`, `db-status`는 이제 앱 소스가 따로 있으므로, 실제 수정은 `frontend/apps/*`에서 하고 publish 스크립트로 `served/`에 반영한다.
|
||||
- 즉 `served/`는 runtime 기준이고, 사람이 직접 먼저 수정하는 source-of-truth는 아니다.
|
||||
- 새 기능을 붙일 때도 실제 서비스 파일은 `served/` 기준으로 수정한다.
|
||||
|
||||
## Reference
|
||||
|
||||
@@ -31,7 +30,6 @@
|
||||
- 디자인 비교용 파일
|
||||
- `reference/ledger/MH 통합 대시보드_260320.html`
|
||||
- `reference/ledger/MH 통합 대시보드_260320.css`
|
||||
- `reference/ledger/사업관리대장-1.xlsx`
|
||||
|
||||
## Temporary Comparison Copies
|
||||
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
# reference/ledger
|
||||
|
||||
이 디렉터리는 `사업관리대장` 원본 참고 자산만 둔다.
|
||||
|
||||
원칙:
|
||||
|
||||
- 직접 서빙하지 않는다.
|
||||
- 비교, 복구, 기준 확인이 필요할 때만 본다.
|
||||
- 실제 수정 원본은 `frontend/apps/ledger/*`다.
|
||||
- 실제 runtime 응답은 `incoming-files/served/ledger/*`다.
|
||||
|
||||
현재 포함:
|
||||
|
||||
- 원본 HTML/CSS
|
||||
- 원본 XLSX
|
||||
- 과거 override 참고 파일
|
||||
|
||||
주의:
|
||||
|
||||
- `reference/ledger` 아래에 다시 `ledger/` 같은 중첩 복사본을 만들지 않는다.
|
||||
- 원본 정리가 필요하면 이 디렉터리에서만 구조를 맞춘다.
|
||||
@@ -1,5 +0,0 @@
|
||||
## DB Status Served Output
|
||||
|
||||
- 이 디렉터리는 `frontend/apps/db-status` publish 결과물만 둔다.
|
||||
- backend `/admin/db-status`는 여기의 `index.html`만 서빙한다.
|
||||
- 수정은 직접 여기서 하지 말고 `./scripts/publish_db_status_app.sh`를 사용한다.
|
||||
@@ -1,801 +0,0 @@
|
||||
<!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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -32,13 +32,6 @@ 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;
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
#!/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"
|
||||
Reference in New Issue
Block a user