8 Commits

Author SHA1 Message Date
hyunho
8125193378 Fix organization member editing and drag sync 2026-04-02 10:38:47 +09:00
hyunho
a4480c3435 feat: add seatmap context panel and smoke checks 2026-04-01 18:01:59 +09:00
hyunho
19c8c6ade1 feat: improve db status table browsing 2026-04-01 17:06:39 +09:00
hyunho
c3afc0c772 refactor: split backend route domains 2026-04-01 16:58:51 +09:00
hyunho
03e90d18a3 refactor: split system and serving routes 2026-04-01 16:49:27 +09:00
hyunho
57d9f630bc refactor: improve db ops visibility and split runtime helpers 2026-04-01 16:41:52 +09:00
hyunho
1e82572e15 feat: add db status viewer and db cleanup baseline 2026-04-01 15:28:11 +09:00
hyunho
e58e584a15 refactor: split 8081 app sources from served assets 2026-04-01 14:30:16 +09:00
61 changed files with 16736 additions and 854 deletions

View File

@@ -8,8 +8,8 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Pretendard:wght@400;600;700;900&display=swap" rel="stylesheet" />
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="/legacy/static/common.css?v=20260331-01" />
<link rel="stylesheet" href="/legacy/static/organization.css?v=20260331-01" />
<link rel="stylesheet" href="/legacy/static/common.css?v=20260402-02" />
<link rel="stylesheet" href="/legacy/static/organization.css?v=20260402-02" />
</head>
<body>
<input type="file" id="upload-excel" class="hidden" accept=".xlsx, .csv" />
@@ -60,6 +60,6 @@
</div>
</div>
<script src="/legacy/static/organization.js?v=20260331-01"></script>
<script src="/legacy/static/organization.js?v=20260402-02"></script>
</body>
</html>

View File

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

172
backend/app/auth_routes.py Normal file
View File

@@ -0,0 +1,172 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import uuid
from typing import Callable
from fastapi import FastAPI, Form, Header, HTTPException, Request
def register_auth_routes(
app: FastAPI,
*,
get_conn,
verify_password: Callable[[str, str], bool],
build_auth_session_payload: Callable[[dict[str, object], uuid.UUID, datetime], dict[str, object]],
extract_bearer_token: Callable[[str | None], str | None],
mock_login_enabled: bool,
auth_session_hours: int,
) -> None:
@app.post("/api/auth/login")
def auth_login(
request: Request,
username: str = Form(...),
password: str = Form(...),
) -> dict[str, object]:
normalized_username = username.strip().lower()
if not normalized_username or not password.strip():
raise HTTPException(status_code=400, detail="사번과 비밀번호를 입력해주세요.")
ip_address = request.client.host if request.client else None
user_agent = request.headers.get("user-agent", "")
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT u.id, u.username, u.password_hash, u.display_name, u.role, u.member_id, u.is_active,
m.rank
FROM auth.users u
LEFT JOIN members m ON m.id = u.member_id
WHERE LOWER(u.username) = %s
""",
(normalized_username,),
)
user = cur.fetchone()
if user is None:
cur.execute(
"""
INSERT INTO auth.login_audit_logs (username, success, failure_reason, ip_address, user_agent)
VALUES (%s, FALSE, %s, %s, %s)
""",
(normalized_username, "unknown_user", ip_address, user_agent),
)
conn.commit()
raise HTTPException(status_code=401, detail="사번 또는 비밀번호가 올바르지 않습니다.")
if not bool(user.get("is_active")):
cur.execute(
"""
INSERT INTO auth.login_audit_logs (username, user_id, success, failure_reason, ip_address, user_agent)
VALUES (%s, %s, FALSE, %s, %s, %s)
""",
(normalized_username, int(user["id"]), "inactive_user", ip_address, user_agent),
)
conn.commit()
raise HTTPException(status_code=403, detail="비활성화된 계정입니다.")
if not verify_password(password, str(user.get("password_hash") or "")):
cur.execute(
"""
INSERT INTO auth.login_audit_logs (username, user_id, success, failure_reason, ip_address, user_agent)
VALUES (%s, %s, FALSE, %s, %s, %s)
""",
(normalized_username, int(user["id"]), "invalid_password", ip_address, user_agent),
)
conn.commit()
raise HTTPException(status_code=401, detail="사번 또는 비밀번호가 올바르지 않습니다.")
expires_at = datetime.now(timezone.utc) + timedelta(hours=auth_session_hours)
session_id = uuid.uuid4()
cur.execute(
"""
INSERT INTO auth.sessions (id, user_id, expires_at, ip_address, user_agent)
VALUES (%s, %s, %s, %s, %s)
""",
(session_id, int(user["id"]), expires_at, ip_address, user_agent),
)
cur.execute(
"UPDATE auth.users SET last_login_at = NOW(), updated_at = NOW() WHERE id = %s",
(int(user["id"]),),
)
cur.execute(
"""
INSERT INTO auth.login_audit_logs (username, user_id, success, failure_reason, ip_address, user_agent)
VALUES (%s, %s, TRUE, NULL, %s, %s)
""",
(normalized_username, int(user["id"]), ip_address, user_agent),
)
conn.commit()
return build_auth_session_payload(user, session_id, expires_at)
@app.post("/api/auth/logout")
def auth_logout(authorization: str | None = Header(default=None)) -> dict[str, bool]:
token = extract_bearer_token(authorization)
if not token:
return {"ok": True}
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"""
UPDATE auth.sessions
SET revoked_at = NOW()
WHERE id = %s
AND revoked_at IS NULL
""",
(token,),
)
conn.commit()
return {"ok": True}
@app.get("/api/auth/me")
def auth_me(authorization: str | None = Header(default=None)) -> dict[str, object]:
token = extract_bearer_token(authorization)
if not token:
raise HTTPException(status_code=401, detail="인증 정보가 없습니다.")
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT s.id AS session_id, s.expires_at, s.revoked_at,
u.id, u.username, u.display_name, u.role, u.member_id, u.is_active,
m.rank
FROM auth.sessions s
JOIN auth.users u ON u.id = s.user_id
LEFT JOIN members m ON m.id = u.member_id
WHERE s.id = %s
""",
(token,),
)
row = cur.fetchone()
if row is None or row.get("revoked_at") is not None:
raise HTTPException(status_code=401, detail="세션이 유효하지 않습니다.")
expires_at = row["expires_at"]
now_utc = datetime.now(timezone.utc)
if expires_at is None or expires_at <= now_utc:
raise HTTPException(status_code=401, detail="세션이 만료되었습니다.")
if not bool(row.get("is_active")):
raise HTTPException(status_code=403, detail="비활성화된 계정입니다.")
return build_auth_session_payload(row, uuid.UUID(str(row["session_id"])), expires_at)
@app.post("/api/mock-login")
def mock_login(username: str = Form(...), password: str = Form(...)) -> dict[str, object]:
if not mock_login_enabled:
raise HTTPException(status_code=403, detail="Mock login is disabled.")
if not username.strip() or not password.strip():
raise HTTPException(status_code=400, detail="Username and password are required.")
return {
"user": {
"username": username.strip(),
"display_name": username.strip(),
"role": "admin",
},
"session_expires_at": datetime.utcnow().isoformat() + "Z",
}

View File

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

View File

@@ -0,0 +1,50 @@
from __future__ import annotations
from fastapi import FastAPI
def register_integration_routes(
app: FastAPI,
*,
import_integration_sources,
fetch_integration_summary,
fetch_project_metrics,
fetch_member_metrics,
fetch_team_metrics,
fetch_project_breakdowns,
fetch_payment_source_rows,
fetch_mh_source_rows,
) -> None:
@app.post("/api/integration/import")
def import_integration_data() -> dict[str, object]:
return import_integration_sources()
@app.get("/api/integration/summary")
def integration_summary() -> dict[str, object]:
return fetch_integration_summary()
@app.get("/api/integration/projects")
def integration_projects(limit: int = 500, start_date: str | None = None, end_date: str | None = None) -> dict[str, list[dict[str, object]]]:
safe_limit = max(1, min(limit, 5000))
return {"items": fetch_project_metrics(safe_limit, start_date=start_date, end_date=end_date)}
@app.get("/api/integration/members")
def integration_members(limit: int = 500, start_date: str | None = None, end_date: str | None = None) -> dict[str, list[dict[str, object]]]:
safe_limit = max(1, min(limit, 5000))
return {"items": fetch_member_metrics(safe_limit, start_date=start_date, end_date=end_date)}
@app.get("/api/integration/teams")
def integration_teams(start_date: str | None = None, end_date: str | None = None) -> dict[str, list[dict[str, object]]]:
return {"items": fetch_team_metrics(start_date=start_date, end_date=end_date)}
@app.get("/api/integration/project-breakdowns")
def integration_project_breakdowns(start_date: str | None = None, end_date: str | None = None) -> dict[str, list[dict[str, object]]]:
return fetch_project_breakdowns(start_date=start_date, end_date=end_date)
@app.get("/api/integration/payment-source")
def integration_payment_source() -> dict[str, object]:
return fetch_payment_source_rows()
@app.get("/api/integration/mh-source")
def integration_mh_source() -> dict[str, object]:
return fetch_mh_source_rows()

View File

@@ -0,0 +1,111 @@
from __future__ import annotations
import hashlib
import json
from pathlib import Path
from urllib.parse import quote
from fastapi import HTTPException
from fastapi.responses import FileResponse, Response
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": quote(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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,147 @@
from __future__ import annotations
from datetime import datetime
from typing import Callable
from fastapi import Body, FastAPI, File, HTTPException, UploadFile
def register_member_routes(
app: FastAPI,
*,
get_conn,
member_payload_cls,
member_bulk_payload_cls,
parse_as_of: Callable[[str | None], datetime | None],
fetch_members: Callable[[], list[dict[str, object]]],
fetch_members_as_of: Callable[[object, datetime], list[dict[str, object]]],
build_member_compare_items: Callable[[list[dict[str, object]], list[dict[str, object]]], list[dict[str, object]]],
serialize_member_payload: Callable[[object, int], tuple[object, ...]],
sync_auth_users_from_members: Callable[[object], None],
create_history_revision: Callable[[object, str, str], int],
fetch_history_revision_created_at: Callable[[object, int], datetime],
sync_member_versions: Callable[[object, list[int], str, int], None],
sync_seat_assignment_versions: Callable[[object, list[int], str, int], None],
replace_members: Callable[[list[object]], list[dict[str, object]]],
parse_import_rows: Callable[[UploadFile, bytes], list[object]],
) -> None:
@app.get("/api/members")
def list_members(as_of: str | None = None) -> dict[str, list[dict[str, object]]]:
parsed_as_of = parse_as_of(as_of)
if parsed_as_of is None:
return {"items": fetch_members()}
with get_conn() as conn:
with conn.cursor() as cur:
return {"items": fetch_members_as_of(cur, parsed_as_of)}
@app.get("/api/history/members/compare")
def compare_members_history(from_date: str, to_date: str) -> dict[str, list[dict[str, object]]]:
parsed_from = parse_as_of(from_date)
parsed_to = parse_as_of(to_date)
if parsed_from is None or parsed_to is None:
raise HTTPException(status_code=400, detail="from_date and to_date are required.")
with get_conn() as conn:
with conn.cursor() as cur:
from_items = fetch_members_as_of(cur, parsed_from)
to_items = fetch_members_as_of(cur, parsed_to)
return {"items": build_member_compare_items(from_items, to_items)}
@app.post("/api/members")
def create_member(payload: dict = Body(...)) -> dict[str, object]:
payload = member_payload_cls.model_validate(payload)
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute("SELECT COALESCE(MAX(sort_order), -1) + 1 AS next_order FROM members")
next_order = int(cur.fetchone()["next_order"])
cur.execute(
"""
INSERT INTO members (
name, employee_id, company, rank, role, department, grp, division, team, cell,
work_status, work_time, phone, email, seat_label, photo_url, sort_order
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id, name, employee_id, company, rank, role, department, grp, division, team, cell,
work_status, work_time, phone, email, seat_label, photo_url,
sort_order, created_at, updated_at
""",
serialize_member_payload(payload, payload.sort_order if payload.sort_order is not None else next_order),
)
member = cur.fetchone()
sync_auth_users_from_members(cur)
revision_no = create_history_revision(cur, "member-create", f"Member created id={int(member['id'])}")
revision_created_at = fetch_history_revision_created_at(cur, revision_no)
sync_member_versions(cur, [int(member["id"])], "member-create", revision_no, revision_created_at)
conn.commit()
return {"item": member}
@app.put("/api/members/bulk-sync")
def bulk_sync_members(payload: dict = Body(...)) -> dict[str, list[dict[str, object]]]:
payload = member_bulk_payload_cls.model_validate(payload)
return {"items": replace_members(payload.items)}
@app.put("/api/members/{member_id}")
def update_member(member_id: int, payload: dict = Body(...)) -> dict[str, object]:
payload = member_payload_cls.model_validate(payload)
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"""
UPDATE members
SET name = %s,
employee_id = %s,
company = %s,
rank = %s,
role = %s,
department = %s,
grp = %s,
division = %s,
team = %s,
cell = %s,
work_status = %s,
work_time = %s,
phone = %s,
email = %s,
seat_label = %s,
photo_url = %s,
sort_order = COALESCE(%s, sort_order),
updated_at = NOW()
WHERE id = %s
RETURNING id, name, employee_id, company, rank, role, department, grp, division, team, cell,
work_status, work_time, phone, email, seat_label, photo_url,
sort_order, created_at, updated_at
""",
(*serialize_member_payload(payload, payload.sort_order or 0)[:-1], payload.sort_order, member_id),
)
member = cur.fetchone()
if member is None:
raise HTTPException(status_code=404, detail="Member not found.")
sync_auth_users_from_members(cur)
revision_no = create_history_revision(cur, "member-update", f"Member updated id={member_id}")
revision_created_at = fetch_history_revision_created_at(cur, revision_no)
sync_member_versions(cur, [member_id], "member-update", revision_no, revision_created_at)
sync_seat_assignment_versions(cur, [member_id], "member-update", revision_no, revision_created_at)
conn.commit()
return {"item": member}
@app.delete("/api/members/{member_id}")
def delete_member(member_id: int) -> dict[str, bool]:
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute("DELETE FROM members WHERE id = %s", (member_id,))
deleted = cur.rowcount > 0
if deleted:
sync_auth_users_from_members(cur)
revision_no = create_history_revision(cur, "member-delete", f"Member deleted id={member_id}")
revision_created_at = fetch_history_revision_created_at(cur, revision_no)
sync_member_versions(cur, [member_id], "member-delete", revision_no, revision_created_at)
sync_seat_assignment_versions(cur, [member_id], "member-delete", revision_no, revision_created_at)
conn.commit()
if not deleted:
raise HTTPException(status_code=404, detail="Member not found.")
return {"ok": True}
@app.post("/api/members/import")
async def import_members(file: UploadFile = File(...)) -> dict[str, list[dict[str, object]]]:
content = await file.read()
items = parse_import_rows(file, content)
return {"items": replace_members(items)}

View File

@@ -0,0 +1,224 @@
from __future__ import annotations
from datetime import datetime
from pathlib import Path
import shutil
import uuid
from typing import Callable
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from fastapi.responses import HTMLResponse
def register_seatmap_routes(
app: FastAPI,
*,
upload_dir: Path,
get_conn,
seat_map_payload_cls,
seat_layout_payload_cls,
fixed_office_source_key: str,
parse_dxf_layout: Callable[[Path], tuple[dict[str, object], list[dict[str, object]]]],
serialize_seat_map_payload: Callable[[object], tuple[object, ...]],
fetch_seat_layout: Callable[[int, object], dict[str, object]],
parse_as_of: Callable[[str | None], object],
ensure_fixed_office_seat_map: Callable[[str, bool], dict[str, object] | None],
build_center_chair_viewer_html: Callable[[dict[str, object]], str],
save_seat_layout: Callable[[int, object], list[dict[str, object]]],
) -> None:
@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()
if suffix not in {".png", ".jpg", ".jpeg", ".webp", ".gif"}:
raise HTTPException(status_code=400, detail="Only image files are allowed.")
stem = member_name.strip().replace(" ", "-") or "member"
filename = f"{datetime.utcnow().strftime('%Y%m%d%H%M%S')}-{stem}-{uuid.uuid4().hex[:8]}{suffix}"
target = upload_dir / filename
with target.open("wb") as out_file:
shutil.copyfileobj(file.file, out_file)
return {"url": f"/uploads/{filename}"}
@app.post("/api/uploads/seat-map-image")
def upload_seat_map_image(file: UploadFile = File(...), seat_map_name: str = Form("")) -> dict[str, str]:
suffix = Path(file.filename or "").suffix.lower()
if suffix not in {".png", ".jpg", ".jpeg", ".webp", ".gif"}:
raise HTTPException(status_code=400, detail="Only image files are allowed.")
stem = seat_map_name.strip().replace(" ", "-") or "seat-map"
filename = f"seat-map-{datetime.utcnow().strftime('%Y%m%d%H%M%S')}-{stem}-{uuid.uuid4().hex[:8]}{suffix}"
target = upload_dir / filename
with target.open("wb") as out_file:
shutil.copyfileobj(file.file, out_file)
return {"url": f"/uploads/{filename}"}
@app.post("/api/seat-maps/dxf")
async def create_dxf_seat_map(file: UploadFile = File(...), name: str = Form(...)) -> dict[str, object]:
suffix = Path(file.filename or "").suffix.lower()
if suffix != ".dxf":
raise HTTPException(status_code=400, detail="DXF 파일만 업로드할 수 있습니다.")
stem = name.strip().replace(" ", "-") or "seat-map"
filename = f"seat-map-{datetime.utcnow().strftime('%Y%m%d%H%M%S')}-{stem}-{uuid.uuid4().hex[:8]}{suffix}"
target = upload_dir / filename
content = await file.read()
with target.open("wb") as out_file:
out_file.write(content)
metadata, slots = parse_dxf_layout(target)
payload = seat_map_payload_cls(
name=name.strip(),
source_type="dxf",
source_url=f"/uploads/{filename}",
image_url="",
preview_svg=metadata["preview_svg"],
view_box_min_x=metadata["view_box_min_x"],
view_box_min_y=metadata["view_box_min_y"],
view_box_width=metadata["view_box_width"],
view_box_height=metadata["view_box_height"],
image_width=None,
image_height=None,
grid_rows=1,
grid_cols=1,
cell_gap=0,
is_active=True,
)
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute("UPDATE seat_maps SET is_active = FALSE, updated_at = NOW() WHERE is_active = TRUE")
cur.execute(
"""
INSERT INTO seat_maps (
name, source_type, source_url, preview_svg,
view_box_min_x, view_box_min_y, view_box_width, view_box_height,
image_url, image_width, image_height, grid_rows, grid_cols, cell_gap, is_active
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id, name, source_type, source_url, preview_svg,
view_box_min_x, view_box_min_y, view_box_width, view_box_height,
image_url, image_width, image_height, grid_rows, grid_cols,
cell_gap, is_active, created_at, updated_at
""",
serialize_seat_map_payload(payload),
)
seat_map = cur.fetchone()
for slot in slots:
cur.execute(
"""
INSERT INTO seat_slots (seat_map_id, slot_key, label, x, y, rotation, layer_name)
VALUES (%s, %s, %s, %s, %s, %s, %s)
""",
(
seat_map["id"],
slot["slot_key"],
slot["label"],
slot["x"],
slot["y"],
slot["rotation"],
slot["layer_name"],
),
)
conn.commit()
return fetch_seat_layout(int(seat_map["id"]))
@app.get("/api/seat-maps/active")
def get_active_seat_map(office_key: str | None = None) -> dict[str, dict[str, object]]:
requested_key = (office_key or "").strip() or fixed_office_source_key
seat_map = ensure_fixed_office_seat_map(requested_key, activate=requested_key == fixed_office_source_key)
if seat_map is None:
raise HTTPException(status_code=404, detail="Active seat map not found.")
return {"item": seat_map}
@app.post("/api/seat-maps")
def create_seat_map(payload: seat_map_payload_cls) -> dict[str, dict[str, object]]:
with get_conn() as conn:
with conn.cursor() as cur:
if payload.is_active:
cur.execute("UPDATE seat_maps SET is_active = FALSE, updated_at = NOW() WHERE is_active = TRUE")
cur.execute(
"""
INSERT INTO seat_maps (
name, source_type, source_url, preview_svg,
view_box_min_x, view_box_min_y, view_box_width, view_box_height,
image_url, image_width, image_height, grid_rows, grid_cols, cell_gap, is_active
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id, name, source_type, source_url, preview_svg,
view_box_min_x, view_box_min_y, view_box_width, view_box_height,
image_url, image_width, image_height, grid_rows, grid_cols,
cell_gap, is_active, created_at, updated_at
""",
serialize_seat_map_payload(payload),
)
seat_map = cur.fetchone()
conn.commit()
return {"item": seat_map}
@app.put("/api/seat-maps/{seat_map_id}")
def update_seat_map(seat_map_id: int, payload: seat_map_payload_cls) -> dict[str, dict[str, object]]:
with get_conn() as conn:
with conn.cursor() as cur:
if payload.source_type != "dxf":
cur.execute(
"""
SELECT COUNT(*) AS count
FROM seat_positions
WHERE seat_map_id = %s
AND (row_index >= %s OR col_index >= %s)
""",
(seat_map_id, payload.grid_rows, payload.grid_cols),
)
out_of_bounds_count = int(cur.fetchone()["count"])
if out_of_bounds_count > 0:
raise HTTPException(status_code=400, detail="현재 배치된 좌석이 새 그리드 범위를 벗어납니다. 먼저 좌석 배치를 정리하세요.")
if payload.is_active:
cur.execute("UPDATE seat_maps SET is_active = FALSE, updated_at = NOW() WHERE is_active = TRUE AND id <> %s", (seat_map_id,))
cur.execute(
"""
UPDATE seat_maps
SET name = %s,
source_type = %s,
source_url = %s,
preview_svg = %s,
view_box_min_x = %s,
view_box_min_y = %s,
view_box_width = %s,
view_box_height = %s,
image_url = %s,
image_width = %s,
image_height = %s,
grid_rows = %s,
grid_cols = %s,
cell_gap = %s,
is_active = %s,
updated_at = NOW()
WHERE id = %s
RETURNING id, name, source_type, source_url, preview_svg,
view_box_min_x, view_box_min_y, view_box_width, view_box_height,
image_url, image_width, image_height, grid_rows, grid_cols,
cell_gap, is_active, created_at, updated_at
""",
(*serialize_seat_map_payload(payload), seat_map_id),
)
seat_map = cur.fetchone()
if seat_map is None:
raise HTTPException(status_code=404, detail="Seat map not found.")
conn.commit()
return {"item": seat_map}
@app.get("/api/seat-maps/{seat_map_id}/layout")
def get_seat_layout(seat_map_id: int, as_of: str | None = None) -> dict[str, object]:
return fetch_seat_layout(seat_map_id, parse_as_of(as_of))
@app.get("/api/seat-maps/{seat_map_id}/viewer")
def get_seat_map_viewer(seat_map_id: int, as_of: str | None = None) -> HTMLResponse:
layout = fetch_seat_layout(seat_map_id, parse_as_of(as_of))
seat_map = layout.get("seat_map") or {}
if seat_map.get("source_type") not in {"dxf", "fixed_html"}:
raise HTTPException(status_code=400, detail="Viewer is only available for supported seat maps.")
return HTMLResponse(build_center_chair_viewer_html(layout))
@app.put("/api/seat-maps/{seat_map_id}/layout")
def update_seat_layout(seat_map_id: int, payload: seat_layout_payload_cls) -> dict[str, list[dict[str, object]]]:
return {"items": save_seat_layout(seat_map_id, payload)}

View File

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

View File

@@ -187,7 +187,7 @@ docker compose -p mh-dashboard-organization-dev --env-file .env -f docker-compos
- 로컬 전용 디자인 참고 자산 복사
- `incoming-files/sample style.css`
- `incoming-files/260320.html`
- `incoming-files/사업관리대장/`
- `incoming-files/reference/ledger/`
- `incoming-files/1.png`
- `incoming-files/seat/center_chair_people_map(2).html`

View File

@@ -53,11 +53,18 @@
- [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`를 참조
- 사업관리대장 상세 팝업 디자인은 [incoming-files/사업관리대장/ledger-override.js](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/incoming-files/사업관리대장/ledger-override.js)에서 `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) 기준으로 본다.
디자인 수정 우선순위:
@@ -98,8 +105,12 @@
- [legacy/static/DashBoard-organization.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/legacy/static/DashBoard-organization.html)
- `/integrations/payment`:
- [incoming-files/served/payment.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/incoming-files/served/payment.html)
- `/integrations/ledger`:
- [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
@@ -109,6 +120,7 @@
- `/api/health` 200
- `/legacy/organization` 200
- `/integrations/payment` 200
- `/integrations/ledger` 200
- `/integrations/mh` 200
- `incoming-files/served` 내 실제 서빙 파일 존재 확인
@@ -119,19 +131,40 @@
## Open Issues Relevant Now
- `#2` 백엔드 영속 저장 구조 운영 마무리
- `#7` 자리배치도 팀별 구역 색상 오버레이
- `#8` 자리배치도 좌석 클릭 시 개인 상위 조직 트리 표시
- `#14` 누적된 임시 로직 정리 및 중복 코드 제거
- `#16` 사업관리대장 메인 연동 및 기본 원본 DB화
- `#17` 8081 분리 worktree 기동 절차와 로컬 디자인 자산 복제 고정
- `#18` 8081 파일 책임 맵 정리 및 프런트 서빙 경로 정돈
- `#19` 8081 백엔드 라우터/서빙 책임 분리
- `#20` 8081 worktree 준비 스크립트·문서·운영 규칙 정리
- `#16` 사업관리대장 메인 후속 정리 및 기준 분석
- `#19` 8081 백엔드 라우터/서빙 deeper 모듈 분리
- `#21` organization 레거시 구조 승격 및 장기 고도화
## Current Seatmap Work Note
- `#8`:
- 자리배치도 우측 패널에 선택 인원 상위 조직 트리 표시 로직을 추가했다.
- 좌석 클릭 / 구성원 카드 클릭 / fixed viewer 선택 이벤트가 부모 화면으로 올라오도록 연결했다.
- 검색 카드 클릭은 상단 패널을 띄우지 않고 좌석 포커스만 하도록 분리했다.
- `#7`:
- 해석 기준은 `개별 좌석 색칠`이 아니라 `팀이 모여 있는 좌석 군집을 하나의 구역처럼 보여주는 오버레이`다.
- grid 배치도와 fixed/DXF viewer 양쪽에 팀 구역 오버레이 코드를 넣었다.
- 현재 증상은 `처음 반짝 보였다가 사라지는 현상`이며, 데이터 부재가 아니라 viewer draw/layer 타이밍 문제로 보인다.
- 다음 작업 시작 시 `#7`을 먼저 재확인한다.
- 자리배치도 카드 텍스트 규칙:
- `이름 - 직급 - 팀(또는 다음 조직 fallback)` 순서
- 검색 목록 카드에는 `chair-00` 같은 좌석 배지를 노출하지 않음
- 미배치 인원 카드는 외곽선만 사용하고 내부 채움색은 사용하지 않음
- 사업관리대장 기본 원본 API 오류:
- 원인은 DB가 아니라 `X-Original-Filename` 한글 헤더 인코딩 오류였음
- [backend/app/ledger_runtime.py](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/backend/app/ledger_runtime.py) 에서 URL-encoded 헤더로 수정
- `./scripts/check_8081_smoke.sh``ledger-default-api` 체크를 넣음
## Recommended Next Work Order
1. `#18` 범위에서 실제 서빙 파일과 비교용 파일 경계를 더 명확히 정리
2. 사업관리대장 탭 기능 추가 전에 수정 대상 파일을 고정
3. 그 다음 `#19`로 backend 라우터/서빙 책임 분리
4. 마지막으로 `#20`에서 스크립트/문서/운영 규칙 정리
1. `#2` 기준으로 DB 상태 화면과 저장 구조 검증 흐름 고도화
2. `#21` 이후 기준으로 실제 서비스 파일과 reference 파일 경계를 유지
3. 사업관리대장 세부 데이터 정합성 보정은 원본 규칙 분석 후 진행
4. 필요 시 `#19` 잔여 정리 항목 재평가
## Quick Resume Prompt
@@ -141,5 +174,8 @@
- `8081` 작업은 `work-8081` + `.dev-worktree-8081`
- 먼저 [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) 먼저 확인
- 현재 1차 구조 정리 기준 이슈는 `#18`
- 작업 전 `git status`, dev 컨테이너 상태, `/api/health`, `/legacy/organization`, `/integrations/payment`, `/integrations/mh`를 먼저 확인
- 현재 구조 독립화 기준 이슈는 `#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`를 먼저 확인
- 구조 정리나 라우트 변경 직후에는 `./scripts/check_8081_smoke.sh` 를 먼저 실행해 `ledger-default-api` 까지 확인

View File

@@ -27,6 +27,7 @@
- `docker compose ps`에서 `backend`, `frontend`, `proxy`, `db`가 정상인지 확인
- `8081`은 기본적으로 `./scripts/start_8081.sh` 또는 `./scripts/prepare_dev_worktree.sh``.dev-worktree-8081` 에서 `docker compose -p mh-dashboard-organization-dev --env-file .env -f docker-compose.8081.yml up -d --build` 로 기동
- `8081` 기동 후 `docker inspect mh-dashboard-organization-dev-backend-1`에서 마운트 경로가 `.dev-worktree-8081/...`인지 확인
- 구조 정리, 라우트 분리, 기본 원본 API 변경 후에는 먼저 `./scripts/check_8081_smoke.sh` 를 실행한다
### 2. 데이터 동기화 범위 결정
@@ -52,6 +53,7 @@
- 메인 허브가 정상 렌더링된다.
- 상단 탭 이동이 정상 동작한다.
- 로그인 상태가 비정상적으로 풀리지 않는다.
- `DB 상태` 탭이 정상 렌더링된다.
### B. 조직현황
@@ -100,6 +102,12 @@
- `GPD`, `TDC` 선택 시 각 소속 범위만 버튼 기준으로 보인다.
- 검색은 버튼 상태와 무관하게 전체 데이터를 검색한다.
### G. 사업관리대장 기본 원본
- `/api/integration/business-ledger-default``200` 이어야 한다.
- `사업관리대장` 탭 진입 시 기본 원본이 비어 있지 않다.
- 기본 원본 응답은 바이너리 XLSX 시그니처(`PK`)를 반환해야 한다.
## 작업 유형별 필수 추가 확인
### 조직도 / 관리자모드 수정 시

View File

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

View File

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

View File

@@ -36,14 +36,31 @@
- URL: `/integrations/payment`
- 현재 실제 서빙 파일: `incoming-files/served/payment.html`
- 앱 소스 기준: `frontend/apps/payment/index.html`
- publish 규칙: `scripts/publish_payment_app.sh`
- URL: `/integrations/ledger`
- 현재 실제 서빙 파일: `incoming-files/served/ledger/index.html`
- 현재 실제 runtime asset 경로: `incoming-files/served/ledger/*`
- 앱 소스 기준: `frontend/apps/ledger/*`
- publish 규칙: `frontend/apps/ledger/index.html` placeholder를 `scripts/publish_ledger_app.sh`가 runtime asset 경로로 치환
- URL: `/integrations/mh`
- 현재 실제 서빙 파일: `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/*`를 직접 서빙한다.
- `사업관리대장` 수정 원본은 `#21` 다음 단계부터 `frontend/apps/ledger/*`를 먼저 보고, `scripts/publish_ledger_app.sh`로 runtime served 파일에 반영한다.
- 기존 루트 `incoming-files/payment.html`, `incoming-files/mh.html`는 안전한 비교/복구를 위해 당분간 남겨둔다.
## Seat Map
@@ -80,7 +97,8 @@
- `sample style.css`
- `opayment.html`
- `omh.html`
- `사업관리대장/*`
- `reference/ledger/MH 통합 대시보드_260320.html`
- `reference/ledger/MH 통합 대시보드_260320.css`
- 원본 xlsx/csv
## Out Of Scope For Phase 1
@@ -97,4 +115,5 @@
- 로그인은 `styles.css`만 본다.
- 허브 8081 디자인은 `styles-8081-design.css`만 본다.
- `/integrations/payment`, `/integrations/mh`의 실제 서빙 파일 위치가 문서와 코드에서 일치한다.
- `/db-status.html`가 현재 DB 저장 구조와 import 상태를 화면에서 바로 보여준다.
- 기존 참고 자산을 지우지 않고도 실제 서빙 경로와 참고 경로를 구분할 수 있다.

View File

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

View File

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

View File

@@ -0,0 +1,918 @@
<!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-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
flex-wrap: wrap;
}
.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;
}
.toolbar-input {
width: min(360px, 100%);
padding: 11px 14px;
border-radius: 14px;
border: 1px solid rgba(132, 102, 54, 0.16);
background: rgba(255, 251, 244, 0.96);
color: #2f2419;
font: inherit;
}
.toolbar-input:focus {
outline: none;
border-color: rgba(129, 88, 31, 0.34);
box-shadow: 0 0 0 4px rgba(208, 176, 116, 0.16);
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.action-button {
border: 1px solid rgba(128, 98, 48, 0.14);
background: rgba(250, 240, 213, 0.62);
color: #6c4a1a;
border-radius: 999px;
padding: 9px 14px;
font: inherit;
font-size: 12px;
font-weight: 800;
cursor: pointer;
}
.action-button:hover {
background: rgba(244, 228, 186, 0.8);
}
.action-button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.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">
<div class="panel-toolbar">
<input id="table-search" class="toolbar-input" type="search" placeholder="테이블명, 설명, 화면명으로 검색" />
<div class="toolbar-actions">
<span id="table-count" class="meta-chip">0 / 0</span>
</div>
</div>
<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>
<div class="toolbar-actions">
<button id="preview-download" class="action-button" type="button" disabled>CSV 다운로드</button>
<button id="preview-close" class="modal-close" type="button" aria-label="닫기">×</button>
</div>
</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>
let allTables = [];
let currentPreview = null;
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function formatNumber(value) {
return new Intl.NumberFormat("ko-KR").format(Number(value || 0));
}
function formatDateTime(value) {
if (!value) return '<span class="muted">-</span>';
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return escapeHtml(value);
return parsed.toLocaleString("ko-KR", { hour12: false });
}
function formatBytes(value) {
const size = Number(value || 0);
if (size <= 0) return "0 B";
const units = ["B", "KB", "MB", "GB"];
let current = size;
let unit = 0;
while (current >= 1024 && unit < units.length - 1) {
current /= 1024;
unit += 1;
}
return `${current.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`;
}
function renderOverview(overview) {
const target = document.getElementById("overview");
target.innerHTML = [
["핵심 테이블", overview.visible_tables],
["전체 테이블", overview.total_tables],
["등록 인원", overview.registered_members],
["재직 인원", overview.active_members],
["고정 오피스 도면", overview.fixed_office_maps],
["현재 active 도면", overview.active_seat_maps],
["Import 배치", overview.import_batches],
["바이너리 원본", overview.binary_sources],
].map(([label, value]) => `
<article class="kpi">
<span class="kpi-label">${escapeHtml(label)}</span>
<span class="kpi-value">${formatNumber(value)}</span>
</article>
`).join("");
}
function renderTables(items) {
const target = document.getElementById("table-body");
const countTarget = document.getElementById("table-count");
if (countTarget) {
countTarget.textContent = `${formatNumber(items.length)} / ${formatNumber(allTables.length)}`;
}
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) {
currentPreview = payload;
const previewModal = document.getElementById("preview-modal");
const downloadButton = document.getElementById("preview-download");
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)}개 표시`;
if (downloadButton) {
downloadButton.disabled = !(payload.rows && payload.rows.length);
}
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();
allTables = payload.tables || [];
document.getElementById("generated-at").textContent = payload.generated_at
? `갱신 ${formatDateTime(payload.generated_at)}`
: "갱신 시각 없음";
renderOverview(payload.overview || {});
renderTables(allTables);
renderBatches(payload.import_batches || []);
renderBinarySources(payload.binary_sources || []);
renderNotes(payload.notes || []);
renderGroupSummary(payload.group_summary || {});
renderProductSummary(payload.product_summary || {});
renderScreenMap(payload.screen_map || []);
}
function toCsvValue(value) {
const text = String(value ?? "");
if (!/[",\n]/.test(text)) return text;
return `"${text.replaceAll('"', '""')}"`;
}
function downloadPreviewCsv() {
if (!currentPreview || !currentPreview.columns || !currentPreview.rows || !currentPreview.rows.length) return;
const headers = currentPreview.columns.map((column) => column.name);
const lines = [
headers.map(toCsvValue).join(","),
...currentPreview.rows.map((row) => headers.map((header) => toCsvValue(row[header] ?? "")).join(",")),
];
const blob = new Blob(["\ufeff" + lines.join("\n")], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${currentPreview.table_name || "table-preview"}.csv`;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}
function applyTableSearch(query) {
const normalized = String(query || "").trim().toLowerCase();
if (!normalized) {
renderTables(allTables);
return;
}
const filtered = allTables.filter((item) => {
const haystack = [
item.label,
item.table_ref,
item.description,
...(item.related_views || []),
item.domain,
item.group,
].join(" ").toLowerCase();
return haystack.includes(normalized);
});
renderTables(filtered);
}
document.getElementById("preview-close").addEventListener("click", () => {
const modal = document.getElementById("preview-modal");
modal.classList.remove("open");
modal.setAttribute("aria-hidden", "true");
});
document.getElementById("preview-download").addEventListener("click", downloadPreviewCsv);
document.getElementById("table-search").addEventListener("input", (event) => {
applyTableSearch(event.target.value);
});
document.getElementById("preview-modal").addEventListener("click", (event) => {
if (event.target.id !== "preview-modal") return;
const modal = document.getElementById("preview-modal");
modal.classList.remove("open");
modal.setAttribute("aria-hidden", "true");
});
bootstrap().catch((error) => {
document.getElementById("table-body").innerHTML = `<tr><td colspan="5" class="empty">${escapeHtml(error.message || "DB 상태를 불러오지 못했습니다.")}</td></tr>`;
document.getElementById("batch-body").innerHTML = '<tr><td colspan="3" class="empty">배치 정보를 불러오지 못했습니다.</td></tr>';
document.getElementById("binary-body").innerHTML = '<tr><td colspan="3" class="empty">바이너리 원본 정보를 불러오지 못했습니다.</td></tr>';
});
</script>
</body>
</html>

View File

@@ -0,0 +1,26 @@
# Ledger App Source
`사업관리대장` 화면의 앱 구조 source-of-truth 디렉터리다.
현재 원칙:
- 실제 runtime 응답은 여전히 `incoming-files/served/ledger/`를 사용한다.
- 하지만 HTML/CSS/JS 수정 원본은 이 디렉터리에서 먼저 관리한다.
- 변경 후에는 `scripts/publish_ledger_app.sh``served/ledger/`에 반영한다.
구성:
- `index.html`: ledger 엔트리 HTML 원본 템플릿
- `assets/MH 통합 대시보드_260320.css`: ledger base stylesheet
- `assets/ledger-override.css`: 8081 ledger 스타일 확장
- `assets/ledger-override.js`: 8081 ledger UI/상호작용 확장
주의:
- `index.html`은 runtime 경로를 직접 하드코딩하지 않는다.
- `__LEDGER_HEAD_ASSETS__`, `__LEDGER_BODY_SCRIPTS__` placeholder는 publish 시 실제 `/integrations/ledger-assets/*` 경로로 치환된다.
범위:
- 이 디렉터리는 `#21` 이후 `사업관리대장`을 화면별 앱 구조로 승격하기 위한 첫 단계다.
- 아직 프레임워크 앱은 아니고, 독립 관리되는 정식 화면 소스 디렉터리다.

View File

@@ -0,0 +1,954 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>사업관리대장 Dashboard</title>
<style>
*{box-sizing:border-box}body{margin:0;background:#f8fafc;color:#0f172a;font-family:'Pretendard','Noto Sans KR','Malgun Gothic',sans-serif}
.wrap{max-width:1600px;margin:0 auto;padding:20px}
.top{display:grid;grid-template-columns:1fr minmax(260px,520px);gap:12px;align-items:end}
.title{font-size:34px;font-weight:900;letter-spacing:-.03em;margin:0}
.sub{font-size:12px;color:#64748b;font-weight:800;letter-spacing:.08em;text-transform:uppercase}
.controls{display:flex;gap:8px;justify-content:flex-end;flex-wrap:wrap}
.btn{border:1px solid #2563eb;background:#2563eb;color:#fff;border-radius:12px;padding:10px 14px;font-size:13px;font-weight:800;cursor:pointer}
.search{flex:1;min-width:250px;border:1px solid #e2e8f0;border-radius:12px;padding:10px 12px;font-size:13px;font-weight:700}
.status{margin:10px 0 14px;font-size:12px;font-weight:700;color:#64748b}
.cards{display:grid;grid-template-columns:repeat(5,minmax(150px,1fr));gap:10px;margin-bottom:12px}
.card{background:#fff;border:1px solid #e2e8f0;border-radius:14px;padding:10px 12px}
.card .k{font-size:11px;font-weight:800;color:#64748b}
.card .v{font-size:19px;font-weight:900;white-space:nowrap}
.panel{background:#fff;border:1px solid #e2e8f0;border-radius:20px;overflow:hidden}
.table-wrap{overflow:auto}
table{width:100%;min-width:1250px;border-collapse:collapse}
thead th{background:#0f172a;color:#ffffffd1;font-size:11px;text-transform:uppercase;letter-spacing:.12em;padding:12px 10px;text-align:left;white-space:nowrap;vertical-align:middle}
.th-head{position:relative;display:flex;align-items:center}
.th-head.end{justify-content:flex-end}
.th-trigger{display:inline-flex;align-items:center;gap:6px;border:0;background:none;padding:0;color:#ffffffd1;font:inherit;font-weight:900;letter-spacing:inherit;text-transform:inherit;cursor:pointer}
.th-trigger:hover,.th-trigger.active,.th-trigger.open{color:#fff}
.th-title{display:inline-block}
.th-meta{font-size:10px;color:#93c5fd;font-weight:800;letter-spacing:0;text-transform:none}
.th-mark{display:inline-flex;align-items:center;justify-content:center;min-width:8px;color:#60a5fa;font-size:12px;line-height:1}
.th-caret{font-size:10px;color:#93c5fd;transition:transform .15s ease}
.th-trigger.open .th-caret{transform:rotate(180deg)}
.th-menu{position:absolute;top:calc(100% + 8px);left:0;display:none;min-width:180px;max-width:320px;max-height:280px;overflow:auto;padding:6px;background:#fff;border:1px solid #cbd5e1;border-radius:12px;box-shadow:0 16px 40px #0f172a26;z-index:15}
.th-head.end .th-menu{left:auto;right:0}
.th-menu.open{display:block}
.th-option{display:block;width:100%;border:0;background:none;border-radius:8px;padding:9px 10px;text-align:left;font-size:12px;font-weight:700;color:#0f172a;cursor:pointer;white-space:normal;word-break:break-word}
.th-option:hover{background:#eff6ff}
.th-option.active{background:#dbeafe;color:#1d4ed8}
tbody td{padding:12px;border-bottom:1px solid #f1f5f9;font-size:13px;white-space:nowrap;vertical-align:middle}
tbody tr:hover{background:#eff6ff}
tbody tr.settled{background:#f8fafc;color:#94a3b8}
tbody tr.settled:hover{background:#f1f5f9}
tbody tr.settled .name,tbody tr.settled strong{color:#64748b}
tbody tr.settled .badge{border-color:#cbd5e1;background:#f8fafc;color:#64748b}
.num{text-align:right;font-variant-numeric:tabular-nums}
.name{font-weight:800;max-width:460px;overflow:hidden;text-overflow:ellipsis}
.subline{font-size:11px;color:#94a3b8;font-weight:700;margin-top:3px}
.badge{display:inline-flex;padding:3px 9px;border-radius:999px;border:1px solid #bfdbfe;background:#eff6ff;color:#1d4ed8;font-size:11px;font-weight:900}
.badge.ok{border-color:#bbf7d0;background:#f0fdf4;color:#047857}
.empty{display:none;padding:32px;text-align:center;color:#94a3b8;font-weight:800}
.hidden{display:none}
.modal{position:fixed;inset:0;background:#020617bf;backdrop-filter:blur(4px);display:none;align-items:center;justify-content:center;padding:16px;z-index:30}
.modal.show{display:flex}
.modal-card{width:min(1200px,100%);max-height:90vh;overflow:auto;background:#fff;border-radius:24px;border:1px solid #e2e8f0}
.m-top{padding:20px;border-bottom:1px solid #f1f5f9;background:#f8fafc;display:flex;justify-content:space-between;gap:10px}
.x{width:42px;height:42px;border:1px solid #e2e8f0;border-radius:12px;background:#fff;font-size:22px;font-weight:900;color:#64748b;cursor:pointer}
.m-body{padding:18px;display:grid;grid-template-columns:1.5fr 1fr;gap:12px}
.sec{border:1px solid #e2e8f0;border-radius:16px;padding:12px}
.sec.dark{background:#0f172a;color:#fff;border-color:#0f172a}
.grid3{display:grid;grid-template-columns:repeat(3,minmax(100px,1fr));gap:8px}
.grid4{display:grid;grid-template-columns:repeat(4,minmax(100px,1fr));gap:8px}
.kv{border:1px solid #e2e8f0;border-radius:12px;padding:9px}
.kvk{font-size:10px;color:#94a3b8;font-weight:900;text-transform:uppercase}
.kvv{font-size:13px;font-weight:800;margin-top:3px;word-break:break-word}
.line{display:flex;justify-content:space-between;gap:10px;padding:5px 0;border-bottom:1px dashed #e2e8f0;font-size:13px;font-weight:700}
.line:last-child{border-bottom:0}
.money{font-size:28px;font-weight:900}
.progress{height:11px;background:#94a3b833;border-radius:999px;overflow:hidden;margin-top:7px}
.bar{height:100%;background:#3b82f6;width:0%}
.pay-list{display:flex;flex-direction:column;gap:8px;margin-top:10px}
.pay-item{border:1px solid #e2e8f0;border-radius:12px;padding:10px 12px;background:#f8fafc}
.pay-head{display:flex;justify-content:space-between;gap:10px;align-items:flex-start}
.pay-name{font-size:13px;font-weight:900;word-break:break-word}
.pay-meta{margin-top:6px;display:grid;grid-template-columns:repeat(2,minmax(120px,1fr));gap:6px 10px;font-size:12px;color:#475569;font-weight:700}
.pay-empty{margin-top:10px;border:1px dashed #cbd5e1;border-radius:12px;padding:12px;color:#94a3b8;font-size:12px;font-weight:800;text-align:center}
.pay-note{margin-top:8px;border-top:1px dashed #fecaca;padding-top:8px;font-size:12px;color:#b91c1c;font-weight:800;white-space:pre-wrap}
.metric-btn{display:inline-flex;flex-direction:column;align-items:flex-end;gap:2px;border:0;background:none;padding:0;color:inherit;font:inherit;cursor:pointer}
.metric-btn strong{color:#0f172a;text-decoration:underline;text-decoration-color:#bfdbfe;text-underline-offset:3px}
tbody tr.settled .metric-btn strong{color:#64748b}
.metric-btn:hover strong{color:#1d4ed8;text-decoration-color:#1d4ed8}
.detail-row td{padding:0;border-bottom:1px solid #e2e8f0;background:#f8fafc}
.detail-row:hover{background:#f8fafc}
.detail-cell{padding:0}
.inline-panel{padding:16px 18px}
.inline-grid{display:grid;grid-template-columns:1.35fr 1fr;gap:12px}
.inline-stack{display:flex;flex-direction:column;gap:10px}
.inline-card{background:#fff;border:1px solid #e2e8f0;border-radius:16px;padding:12px}
.inline-hero{background:#0f172a;color:#fff;border-color:#0f172a}
.inline-hero-note{font-size:12px;color:#94a3b8;margin-top:6px}
.inline-hero-split{display:grid;grid-template-columns:1fr 1fr;gap:14px;align-items:end}
.inline-hero-col{min-width:0}
.inline-hero-col.right{padding-left:14px;border-left:1px solid #334155}
.out-list{display:flex;flex-direction:column;gap:8px;margin-top:10px}
.out-item{border:1px solid #e2e8f0;border-radius:12px;padding:10px 12px;background:#f8fafc}
.out-head{display:flex;justify-content:space-between;gap:10px;align-items:flex-start}
.out-vendor{font-size:13px;font-weight:900}
.out-name{margin-top:6px;font-size:13px;font-weight:800;word-break:break-word}
.out-meta{margin-top:8px;display:grid;grid-template-columns:repeat(2,minmax(140px,1fr));gap:6px 10px;font-size:12px;color:#475569;font-weight:700}
.out-payments{display:flex;flex-direction:column;gap:6px;margin-top:8px;padding-top:8px;border-top:1px dashed #cbd5e1}
.out-payment{background:#fff;border:1px solid #e2e8f0;border-radius:10px;padding:8px}
.out-payment-head{display:flex;justify-content:space-between;gap:10px;align-items:flex-start;font-size:12px;font-weight:800}
.out-payment-meta{margin-top:6px;display:grid;grid-template-columns:repeat(3,minmax(120px,1fr));gap:4px 8px;font-size:12px;color:#475569;font-weight:700}
.out-note{margin-top:8px;border-top:1px dashed #fecaca;padding-top:8px;font-size:12px;color:#b91c1c;font-weight:800;white-space:pre-wrap}
.project-head{display:grid;grid-template-columns:1.2fr .8fr;gap:12px;margin-bottom:12px}
.project-meta-grid{display:grid;grid-template-columns:repeat(4,minmax(110px,1fr));gap:8px}
.project-sections{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.section-card{background:#fff;border:1px solid #e2e8f0;border-radius:16px;padding:14px}
.section-head{display:flex;justify-content:space-between;gap:12px;align-items:flex-start;margin-bottom:10px}
.section-title{font-size:16px;font-weight:900}
.section-sub{margin-top:4px;font-size:12px;color:#64748b;font-weight:800}
.section-chip{display:inline-flex;align-items:center;gap:6px;border:1px solid #bfdbfe;background:#eff6ff;color:#1d4ed8;border-radius:999px;padding:5px 10px;font-size:11px;font-weight:900;white-space:nowrap}
.section-chip.out{border-color:#fecdd3;background:#fff1f2;color:#be123c}
.summary-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:8px}
.summary-card{background:#f8fafc;border:1px solid #e2e8f0;border-radius:14px;padding:12px;min-width:0}
.summary-label{font-size:11px;color:#64748b;font-weight:900;text-transform:uppercase}
.summary-value{margin-top:6px;font-size:clamp(12px,0.95vw,22px);font-weight:900;line-height:1.15;white-space:nowrap;max-width:100%;letter-spacing:-.03em}
.summary-note{margin-top:4px;font-size:12px;color:#94a3b8;font-weight:800}
.ledger-stack{display:flex;flex-direction:column;gap:14px}
.ledger-block{background:#fff;border:1px solid #e2e8f0;border-radius:18px;overflow:hidden}
.ledger-block.outsource{border-color:#fecdd3;background:#fff}
.ledger-block.collect{border-color:#c7d2fe;background:#fff}
.ledger-head{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:12px 14px}
.ledger-head-left{display:flex;align-items:center;gap:10px;min-width:0}
.ledger-icon{width:20px;height:20px;border-radius:999px;display:inline-flex;align-items:center;justify-content:center;font-size:12px;font-weight:900;color:#fff;flex:0 0 auto}
.ledger-block.outsource .ledger-icon{background:#f43f5e}
.ledger-block.collect .ledger-icon{background:#6366f1}
.ledger-name{font-size:13px;font-weight:900}
.ledger-sub{margin-top:2px;font-size:11px;color:#64748b;font-weight:800}
.ledger-pill{display:inline-flex;align-items:center;padding:6px 10px;border-radius:999px;font-size:11px;font-weight:900;white-space:nowrap}
.ledger-block.outsource .ledger-pill{border:1px solid #fecdd3;background:#fff1f2;color:#e11d48}
.ledger-block.collect .ledger-pill{border:1px solid #c7d2fe;background:#eef2ff;color:#4f46e5}
.ledger-table-wrap{padding:0 12px 12px}
.ledger-table{width:100%;min-width:0;border-collapse:collapse}
.ledger-table thead th{background:transparent;color:#94a3b8;font-size:11px;font-weight:900;letter-spacing:0;text-transform:none;padding:8px 10px;border-bottom:1px solid #e2e8f0}
.ledger-table tbody td{padding:10px;border-bottom:1px solid #eef2f7;font-size:12px;color:#334155;white-space:normal;background:#fff}
.ledger-table tbody tr:last-child td{border-bottom:0}
.ledger-main{font-weight:800;color:#0f172a}
.ledger-muted{display:block;margin-top:3px;font-size:11px;color:#94a3b8;font-weight:700}
.ledger-amount{font-weight:900;text-align:right;color:#0f172a}
.ledger-note{font-size:11px;color:#64748b;font-weight:700}
.ledger-empty{padding:14px 12px;color:#94a3b8;font-size:12px;font-weight:800;text-align:center}
.ledger-block.outsource .ledger-head{background:#fff1f2;border-bottom:1px solid #fecdd3}
.ledger-block.collect .ledger-head{background:#eef2ff;border-bottom:1px solid #c7d2fe}
.ledger-block.outsource .ledger-table thead th{background:#fff7f8}
.ledger-block.collect .ledger-table thead th{background:#f5f7ff}
@media(max-width:1280px){.top{grid-template-columns:1fr}.controls{justify-content:flex-start}.cards{grid-template-columns:repeat(2,minmax(140px,1fr))}.m-body{grid-template-columns:1fr}.inline-grid{grid-template-columns:1fr}.grid4{grid-template-columns:repeat(2,minmax(100px,1fr))}.inline-hero-split{grid-template-columns:1fr}.inline-hero-col.right{padding-left:0;border-left:0;border-top:1px solid #334155;padding-top:12px}.project-head{grid-template-columns:1fr}.project-meta-grid{grid-template-columns:repeat(2,minmax(110px,1fr))}.project-sections{grid-template-columns:1fr}.summary-grid{grid-template-columns:repeat(2,minmax(120px,1fr))}.ledger-head{align-items:flex-start;flex-direction:column}.ledger-pill{align-self:flex-start}}
</style>
__LEDGER_HEAD_ASSETS__</head>
<body class="mh-business-theme">
<input id="file" type="file" accept=".csv,.xlsx,.xls" class="hidden" />
<div class="wrap">
<div class="top">
<div><div class="sub">Live Management</div><h1 class="title">사업관리대장 <span style="font-weight:300;color:#94a3b8">| Dashboard</span></h1></div>
<div class="controls"><button id="btnUpload" class="btn" type="button">파일 업로드</button><input id="search" class="search" placeholder="전체 검색" /></div>
</div>
<div id="status" class="status">CSV/XLSX 파일을 업로드하면 데이터가 표시됩니다.</div>
<div id="cards" class="cards"></div>
<div class="panel">
<div class="table-wrap">
<table>
<thead>
<tr>
<th>
<div class="th-head">
<button type="button" class="th-trigger" data-filter="code" data-label="구분 / 코드">
<span class="th-title">구분 / 코드</span><span class="th-mark"></span><span class="th-caret"></span>
</button>
<div id="filterCodeMenu" class="th-menu" data-filter="code"></div>
</div>
</th>
<th>
<div class="th-head">
<button type="button" class="th-trigger" data-filter="name" data-label="사업명">
<span class="th-title">사업명</span><span class="th-mark"></span><span class="th-caret"></span>
</button>
<div id="filterNameMenu" class="th-menu" data-filter="name"></div>
</div>
</th>
<th>
<div class="th-head">
<button type="button" class="th-trigger" data-filter="corp" data-label="계약법인">
<span class="th-title">계약법인</span><span class="th-mark"></span><span class="th-caret"></span>
</button>
<div id="filterCorpMenu" class="th-menu" data-filter="corp"></div>
</div>
</th>
<th>
<div class="th-head">
<button type="button" class="th-trigger" data-filter="status" data-label="진행상태">
<span class="th-title">진행상태</span><span class="th-mark"></span><span class="th-caret"></span>
</button>
<div id="filterStatusMenu" class="th-menu" data-filter="status"></div>
</div>
</th>
<th>
<div class="th-head">
<button type="button" class="th-trigger" data-filter="outsource" data-label="외주비">
<span class="th-title">외주비</span><span class="th-meta">(VAT 별도)</span><span class="th-mark"></span><span class="th-caret"></span>
</button>
<div id="filterOutsourceMenu" class="th-menu" data-filter="outsource"></div>
</div>
</th>
<th class="num">
<div class="th-head end">
<button type="button" class="th-trigger" data-filter="amount" data-label="계약금">
<span class="th-title">계약금</span><span class="th-meta">(VAT 별도)</span><span class="th-mark"></span><span class="th-caret"></span>
</button>
<div id="filterAmountMenu" class="th-menu" data-filter="amount"></div>
</div>
</th>
<th class="num">
<div class="th-head end">
<button type="button" class="th-trigger" data-filter="collected" data-label="수금액">
<span class="th-title">수금액</span><span class="th-meta">(VAT 별도)</span><span class="th-mark"></span><span class="th-caret"></span>
</button>
<div id="filterCollectedMenu" class="th-menu" data-filter="collected"></div>
</div>
</th>
<th class="num">
<div class="th-head end">
<button type="button" class="th-trigger" data-filter="rate" data-label="수금률">
<span class="th-title">수금률</span><span class="th-mark"></span><span class="th-caret"></span>
</button>
<div id="filterRateMenu" class="th-menu" data-filter="rate"></div>
</div>
</th>
</tr>
</thead>
<tbody id="tbody"></tbody>
</table>
</div>
<div id="empty" class="empty">표시할 데이터가 없습니다.</div>
</div>
</div>
<div id="collectModal" class="modal">
<div class="modal-card">
<div class="m-top"><div><div id="mCat" class="badge">미분류</div><div id="mTitle" style="font-size:28px;font-weight:900;margin-top:6px"></div><div id="mSub" style="font-size:13px;color:#64748b;font-weight:700;margin-top:4px"></div></div><button id="btnCollectClose" class="x" type="button">×</button></div>
<div class="m-body">
<div style="display:flex;flex-direction:column;gap:10px">
<div class="sec"><div class="grid3"><div class="kv"><div class="kvk">발주처</div><div id="mClient" class="kvv"></div></div><div class="kv"><div class="kvk">발주방법</div><div id="mOrder" class="kvv"></div></div><div class="kv"><div class="kvk">분담율</div><div id="mSplit" class="kvv"></div></div></div></div>
<div class="sec"><div class="line"><span>착수일</span><strong id="mStartDate"></strong></div><div class="line"><span>준공일</span><strong id="mEndDate"></strong></div><div class="line"><span>대금구분</span><strong id="mPayType"></strong></div><div id="mPayItems" class="pay-list"></div></div>
<div class="sec dark"><div style="display:flex;justify-content:space-between;gap:10px;align-items:flex-end"><div><div style="font-size:11px;color:#94a3b8;font-weight:900">총 계약 합계(VAT 포함)</div><div id="mContractTotal" class="money"></div><div id="mContractSupply" style="font-size:12px;color:#94a3b8"></div></div><div style="text-align:right"><div style="font-size:11px;color:#60a5fa;font-weight:900">수금금액</div><div id="mCollected" class="money" style="color:#60a5fa"></div><div id="mCollectDate" style="font-size:12px;color:#94a3b8"></div></div></div><div style="margin-top:10px;display:flex;justify-content:space-between"><span style="font-size:12px;color:#94a3b8;font-weight:900">수금 진행률</span><strong id="mRate" style="font-size:28px"></strong></div><div class="progress"><div id="mRateBar" class="bar"></div></div><div style="display:flex;justify-content:space-between;margin-top:7px"><span style="color:#fda4af;font-size:12px;font-weight:900">미수 금액</span><strong id="mReceivable" style="color:#fb7185"></strong></div></div>
</div>
<div style="display:flex;flex-direction:column;gap:10px">
<div class="sec"><div style="font-size:11px;color:#64748b;font-weight:900;letter-spacing:.1em;text-transform:uppercase">계약 / 청구 담당자</div><div style="margin-top:8px"><div id="mCmName" style="font-size:20px;font-weight:900"></div><div id="mCmOrg" style="font-size:13px;color:#0f172a;font-weight:800;margin-top:4px"></div><div id="mCmPhone" style="font-size:13px;font-weight:700;margin-top:8px"></div><div id="mCmEmail" style="font-size:13px;font-weight:700;margin-top:4px"></div></div></div>
<div class="sec"><div style="font-size:11px;color:#64748b;font-weight:900;letter-spacing:.1em;text-transform:uppercase">부서 담당자</div><div style="margin-top:8px"><div id="mDmName" style="font-size:20px;font-weight:900"></div><div id="mDmOrg" style="font-size:13px;color:#334155;font-weight:800;margin-top:4px"></div><div id="mDmPhone" style="font-size:13px;font-weight:700;margin-top:8px"></div><div id="mDmEmail" style="font-size:13px;font-weight:700;margin-top:4px"></div></div></div>
</div>
</div>
</div>
</div>
<div id="outsourceModal" class="modal">
<div class="modal-card">
<div class="m-top"><div><div class="badge">외주비 상세</div><div id="oTitle" style="font-size:28px;font-weight:900;margin-top:6px"></div><div id="oSub" style="font-size:13px;color:#64748b;font-weight:700;margin-top:4px"></div></div><button id="btnOutsourceClose" class="x" type="button">×</button></div>
<div class="m-body">
<div style="display:flex;flex-direction:column;gap:10px">
<div class="sec">
<div class="grid3">
<div class="kv"><div class="kvk">계약법인</div><div id="oCorp" class="kvv"></div></div>
<div class="kv"><div class="kvk">발주처</div><div id="oClient" class="kvv"></div></div>
<div class="kv"><div class="kvk">외주처 요약</div><div id="oVendors" class="kvv"></div></div>
</div>
</div>
<div class="sec">
<div class="line"><span>외주 총액</span><strong id="oTotal"></strong></div>
<div class="line"><span>외주 건수</span><strong id="oCount"></strong></div>
<div class="line"><span>계약기간</span><strong id="oPeriod"></strong></div>
<div id="oItems" class="out-list"></div>
</div>
</div>
<div style="display:flex;flex-direction:column;gap:10px">
<div class="sec dark">
<div style="font-size:11px;color:#94a3b8;font-weight:900">총 외주비(공급가액 기준)</div>
<div id="oTotalHero" class="money"></div>
<div id="oTotalHint" style="font-size:12px;color:#94a3b8;margin-top:6px"></div>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js"></script>
<script>
const FILTER_KEYS=["code","name","corp","status","outsource","amount","collected","rate"];
const S={all:[],rows:[],viewRows:[],file:"",filters:{},totals:null,expanded:{key:""}};
const E={file:document.getElementById("file"),btnUpload:document.getElementById("btnUpload"),search:document.getElementById("search"),status:document.getElementById("status"),cards:document.getElementById("cards"),tbody:document.getElementById("tbody"),empty:document.getElementById("empty"),collectModal:document.getElementById("collectModal"),btnCollectClose:document.getElementById("btnCollectClose"),outsourceModal:document.getElementById("outsourceModal"),btnOutsourceClose:document.getElementById("btnOutsourceClose"),filterButtons:Object.fromEntries(Array.from(document.querySelectorAll(".th-trigger")).map(el=>[el.dataset.filter,el])),filterMenus:Object.fromEntries(Array.from(document.querySelectorAll(".th-menu")).map(el=>[el.dataset.filter,el]))};
const G=id=>document.getElementById(id);
const esc=v=>String(v||"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");
const escAttr=v=>esc(v).replace(/"/g,"&quot;");
const n=v=>String(v||"").replace(/[\s\r\n]+/g,"").toLowerCase();
const num=v=>{v=String(v||"").trim();if(!v||v.startsWith("="))return 0;return parseFloat(v.replace(/[^0-9.\-]/g,""))||0;};
const won=v=>Math.round(v||0).toLocaleString("ko-KR")+" 원";
const d=v=>{v=String(v||"").trim();return !v||v==="~"?"-":v;};
const rate=(raw,col,sales)=>{const x=parseFloat(String(raw||"").replace(/[^0-9.\-]/g,""));if(Number.isFinite(x))return Math.max(0,Math.min(100,x));return sales>0?Math.max(0,Math.min(100,col/sales*100)):0;};
const score=t=>{t=String(t||"");let s=0,m=t.replace(/\s+/g,"");if(m.includes("사업관리대장"))s+=8;if(m.includes("총괄사업코드"))s+=8;if(m.includes("사업명(계약명)"))s+=7;s+=(t.match(/[가-힣]/g)||[]).length*0.01;s-=(t.match(/<2F>/g)||[]).length*0.5;return s;};
const rowKey=r=>[r.code||"",r.name||"",r.corp||"",r.client||""].join("|");
function parseCsv(txt){const out=[];let row=[],f="",q=false;for(let i=0;i<txt.length;i++){const c=txt[i];if(c==='"'){if(q&&txt[i+1]==='"'){f+='"';i++;}else q=!q;continue;}if(c===","&&!q){row.push(f);f="";continue;}if((c==="\n"||c==="\r")&&!q){if(c==="\r"&&txt[i+1]==="\n")i++;row.push(f);out.push(row);row=[];f="";continue;}f+=c;}row.push(f);out.push(row);if(out.length&&out[0].length)out[0][0]=String(out[0][0]||"").replace(/^\uFEFF/,"");return out;}
function hs(rows){
for(let i=0;i<rows.length;i++){
const a=(rows[i]||[]).map(n);
const hasName=a.some(v=>v.includes("사업명(계약명)")||v==="사업명"||v.includes("사업명"));
const hasCode=a.some(v=>v.includes("총괄사업코드")||v.includes("사업코드"));
const hasClient=a.some(v=>v.includes("발주처(매출처)")||v.includes("발주처"));
if(hasName&&(hasCode||hasClient)) return i;
}
return -1;
}
function ch(a,b){a=a||[];b=b||[];const m=Math.max(a.length,b.length),o=[];let carry="";for(let i=0;i<m;i++){const t=String(a[i]||"").replace(/\s+/g," ").trim(),s=String(b[i]||"").replace(/\s+/g," ").trim();if(t)carry=t;const top=t||carry;o.push(top&&s?(top+" "+s).trim():(top||s||""));}return o;}
function hi(headers,cands){const C=(cands||[]).map(n).filter(Boolean);for(const c of C){for(let i=0;i<headers.length;i++)if(n(headers[i])===c)return i;}return -1;}
function parseLedgerRows(R){
if(R.length&&R[0].length)R[0][0]=String(R[0][0]||"").replace(/^\uFEFF/,"");
const h=hs(R);if(h<0)throw new Error("헤더를 찾지 못했습니다.");
const H=ch(R[h],R[h+1]||[]),I={cat:hi(H,["사업구분","사업 구분"]),corp:hi(H,["계약법인","계약 법인"]),code:hi(H,["총괄사업코드","총괄 사업코드","사업코드"]),name:hi(H,["사업명 (계약명)","사업명(계약명)","사업명"]),pay:hi(H,["대금구분","대금 구분"]),yn:hi(H,["계약여부"]),order:hi(H,["발주방법"]),pm:hi(H,["pm"]),status:hi(H,["진행상태"]),client:hi(H,["발주처 (매출처)","발주처(매출처)","발주처"]),split:hi(H,["분담율"]),cDate:hi(H,["계약기간 계약일","계약일","발행일"]),sDate:hi(H,["계약기간 착수일","착수일"]),eDate:hi(H,["계약기간 준공일","준공일"]),cSup:hi(H,["계약금 공급가액","매출금액 공급가액","공급가액"]),cVat:hi(H,["계약금 부가세","매출금액 부가세","부가세"]),cTot:hi(H,["계약금 합계","매출금액 합계","합계","계약금","매출금액"]),colDate:hi(H,["매출금액 수금일","수금일"]),sSup:hi(H,["매출금액 공급가액","공급가액"]),sVat:hi(H,["매출금액 부가세","부가세"]),sTot:hi(H,["매출금액 합계","합계","매출금액"]),col:hi(H,["매출금액 수금금액","수금금액","수금액"]),recv:hi(H,["매출금액 미수금액","미수금액"]),r:hi(H,["매출금액 수금율","수금율"]),note:hi(H,["비고"]),cmCo:hi(H,["계약/청구담당자 회사"]),cmNm:hi(H,["계약/청구담당자 이름"]),cmDp:hi(H,["계약/청구담당자 부서"]),cmPh:hi(H,["계약/청구담당자 연락처"]),cmEm:hi(H,["계약/청구담당자 이메일"]),dmCo:hi(H,["부서담당자 회사"]),dmNm:hi(H,["부서담당자 이름"]),dmDp:hi(H,["부서담당자 부서"]),dmPh:hi(H,["부서담당자 연락처"]),dmEm:hi(H,["부서담당자 이메일"])};
const out=[];for(const row of R.slice(h+2)){const x={cat:I.cat>=0?String(row[I.cat]||"").trim():"",corp:I.corp>=0?String(row[I.corp]||"").trim():"",code:I.code>=0?String(row[I.code]||"").trim():"",name:I.name>=0?String(row[I.name]||"").trim():"",pay:I.pay>=0?String(row[I.pay]||"").trim():"",yn:I.yn>=0?String(row[I.yn]||"").trim():"",order:I.order>=0?String(row[I.order]||"").trim():"",pm:I.pm>=0?String(row[I.pm]||"").trim():"",status:I.status>=0?String(row[I.status]||"").trim():"",client:I.client>=0?String(row[I.client]||"").trim():"",split:I.split>=0?String(row[I.split]||"").trim():"",cDate:I.cDate>=0?String(row[I.cDate]||"").trim():"",sDate:I.sDate>=0?String(row[I.sDate]||"").trim():"",eDate:I.eDate>=0?String(row[I.eDate]||"").trim():"",cSup:I.cSup>=0?num(row[I.cSup]):0,cVat:I.cVat>=0?num(row[I.cVat]):0,cTot:I.cTot>=0?num(row[I.cTot]):0,colDate:I.colDate>=0?String(row[I.colDate]||"").trim():"",sSup:I.sSup>=0?num(row[I.sSup]):0,sVat:I.sVat>=0?num(row[I.sVat]):0,sTot:I.sTot>=0?num(row[I.sTot]):0,col:I.col>=0?num(row[I.col]):0,recv:I.recv>=0?num(row[I.recv]):0,rateRaw:I.r>=0?String(row[I.r]||"").trim():"",note:I.note>=0?String(row[I.note]||"").trim():"",cmCo:I.cmCo>=0?String(row[I.cmCo]||"").trim():"",cmNm:I.cmNm>=0?String(row[I.cmNm]||"").trim():"",cmDp:I.cmDp>=0?String(row[I.cmDp]||"").trim():"",cmPh:I.cmPh>=0?String(row[I.cmPh]||"").trim():"",cmEm:I.cmEm>=0?String(row[I.cmEm]||"").trim():"",dmCo:I.dmCo>=0?String(row[I.dmCo]||"").trim():"",dmNm:I.dmNm>=0?String(row[I.dmNm]||"").trim():"",dmDp:I.dmDp>=0?String(row[I.dmDp]||"").trim():"",dmPh:I.dmPh>=0?String(row[I.dmPh]||"").trim():"",dmEm:I.dmEm>=0?String(row[I.dmEm]||"").trim():""};
if(!x.name&&!x.code)continue;if(!x.code&&!x.corp&&!x.client&&!x.pm)continue;if(!x.cTot)x.cTot=x.cSup+x.cVat;if(!x.sTot)x.sTot=x.sSup+x.sVat;if(!x.recv)x.recv=Math.max(0,x.sTot-x.col);x.rate=rate(x.rateRaw,x.col,x.sTot);out.push(x);}
return out;
}
const hk=v=>String(v||"").normalize("NFKC").toLowerCase().replace(/[^0-9a-z가-힣]+/g,"");
function findHeaderIndex(headers,cands){
const normalized=(headers||[]).map(hk);
const candidates=(cands||[]).map(hk).filter(Boolean);
for(const c of candidates){
for(let i=0;i<normalized.length;i++){
if(!normalized[i]) continue;
if(normalized[i]===c||normalized[i].includes(c)||c.includes(normalized[i])) return i;
}
}
return -1;
}
function textAt(row,idx){return idx>=0?String(row[idx]??"").replace(/\u00a0/g," ").replace(/\s+/g," ").trim():"";}
function moneyAt(row,idx){return idx>=0?num(row[idx]):0;}
function lastText(values){for(let i=values.length-1;i>=0;i--){const v=d(values[i]);if(v!=="-")return v;}return "-";}
function paymentSummary(payments){
const labels=[...new Set((payments||[]).map(p=>String(p.pay||"").trim()).filter(Boolean))];
if(!labels.length) return "-";
if(labels.length<=2) return labels.join(", ");
return `${labels.slice(0,2).join(", ")}${labels.length-2}`;
}
function paymentRecord(x,fallbackPay){
const supply=x.sSup||0,vat=x.sVat||0,total=x.sTot||supply+vat,collected=x.col||0;
return {pay:String(x.pay||x.name||fallbackPay||"미입력").trim(),status:x.status||"",issueDate:x.issueDate||x.cDate||"",collectDate:x.colDate||"",supply,vat,total,collected,receivable:x.recv||Math.max(0,total-collected),rate:rate(x.rateRaw,collected,total),note:String(x.note||"").trim()};
}
function finalizeProject(project){
const payments=(project.payments||[]).filter(p=>p.pay||p.issueDate||p.collectDate||p.total||p.collected||p.receivable);
if(!payments.length&&(project.issueDate||project.colDate||project.sSup||project.sVat||project.sTot||project.col||project.recv)) payments.push(paymentRecord(project,project.pay||"일괄"));
project.payments=payments;
project.pay=paymentSummary(payments);
project.periodText=(d(project.sDate)==="-"&&d(project.eDate)==="-")?"-":`${d(project.sDate)} ~ ${d(project.eDate)}`;
project.issueDateSummary=lastText(payments.map(p=>p.issueDate));
project.collectDateSummary=lastText(payments.map(p=>p.collectDate));
return project;
}
function normalizeProjectKey(v){return hk(v);}
function normalizeProjectBase(v){
return hk(String(v||"").replace(/\([^)]*\)/g," ").replace(/\[[^\]]*\]/g," "));
}
function summarizeOutsourceVendors(vendors){
const list=(vendors||[]).filter(Boolean);
if(!list.length) return "";
if(list.length<=2) return list.join(", ");
return `${list.slice(0,2).join(", ")} \uC678 ${list.length-2}\uACF3`;
}
function calcVatExcluded(total){return total>0?Math.round(total/1.1):0;}
function outsourceTotalLabel(item){
const ex=Math.round(item&&item.contractEx||0);
const total=Math.round(item&&item.contractIn||0);
if(ex>0) return won(ex);
if(total>0) return won(calcVatExcluded(total));
return "-";
}
function cleanVendorName(value,sheetName){
const raw=String(value||sheetName||"").trim();
return raw.replace(/^\(\uC8FC\)\s*/,"").replace(/^\uC8FC\uC2DD\uD68C\uC0AC\s*/,"").replace(/^\uC678\uC8FC/,"").trim()||String(sheetName||"\uC678\uC8FC").replace(/^\uC678\uC8FC/,"").trim()||"\uC678\uC8FC";
}
function getOutsourceLayout(rows){
const header=rows[3]||[];
const hasVatContract=String(header[9]??"").includes("VAT\uD3EC\uD568");
if(hasVatContract){
return {hasVatContract:true,contractEx:8,contractIn:9,invoiceDate:10,paymentDate:11,paymentAmount:12,remainingAmount:13,progress:14,label:15,note:16};
}
return {hasVatContract:false,contractEx:8,contractIn:-1,invoiceDate:9,paymentDate:10,paymentAmount:11,remainingAmount:12,progress:13,label:-1,note:14};
}
function shouldStopOutsourceRows(row){
const first=String(row[0]??"").trim();
const project=String(row[2]??"").trim();
const detail=String(row[3]??"").trim();
const joined=[row[0],row[2],row[3],row[13],row[14],row[15],row[16]].map(v=>String(v??"").trim()).join(" ");
return first==="\uB0A0\uC9DC"||first.startsWith("*\uC790\uB8CC\uCD9C\uCC98")||project==="\uC801\uC694"||detail==="\uC801\uC694"||project.includes("\uC790\uB8CC\uCD9C\uCC98")||joined.includes("\uC6D0\uACC4\uC57D\uAE08")||joined.includes("\uC218\uAE08/\uC9C0\uAE09\uCC98");
}
function getOutsourceEntry(map,key,name){
const current=map.get(key);
if(current) return current;
const next={name,key,baseKey:normalizeProjectBase(name),vendors:new Set(),items:[],contract:0,contractIn:0,paid:0,paidIn:0,remaining:0,remainingIn:0};
map.set(key,next);
return next;
}
function createOutsourceItem(entry,vendor,projectName,detail,row,layout){
const contractEx=num(row[layout.contractEx]);
const contractIn=layout.contractIn>=0?num(row[layout.contractIn]):0;
const next={
vendor,
projectName,
detail:String(detail||"-").trim()||"-",
contractDate:String(row[4]??"").trim(),
startDate:String(row[5]??"").trim(),
endDate:String(row[7]??"").trim(),
contractEx,
contractIn,
invoiceDate:String(row[layout.invoiceDate]??"").trim(),
progress:String(row[layout.progress]??"").trim(),
note:"",
payments:[]
};
entry.items.push(next);
return next;
}
function buildOutsourcePayment(item,row,layout){
const invoiceDate=String(row[layout.invoiceDate]??"").trim();
const paymentDate=String(row[layout.paymentDate]??"").trim();
const paymentCell=String(row[layout.paymentAmount]??"").trim();
const remainingCell=String(row[layout.remainingAmount]??"").trim();
const paymentRaw=num(row[layout.paymentAmount]);
const remainingRaw=num(row[layout.remainingAmount]);
const label=layout.label>=0?String(row[layout.label]??"").trim():"";
const note=layout.note>=0?String(row[layout.note]??"").trim():String(row[14]??"").trim();
if(!(invoiceDate||paymentDate||paymentRaw||remainingRaw||label||note)) return null;
if(note&&!label&&!paymentDate&&!paymentRaw&&!remainingRaw&&!invoiceDate){
item.note=note;
}
return {
label,
note,
invoiceDate,
paymentDate,
paymentKnown:paymentCell!=="",
remainingKnown:remainingCell!=="",
paymentEx:paymentRaw?(layout.hasVatContract?calcVatExcluded(paymentRaw):paymentRaw):0,
paymentIn:layout.hasVatContract?paymentRaw:0,
remainingEx:remainingRaw?(layout.hasVatContract?calcVatExcluded(remainingRaw):remainingRaw):0,
remainingIn:layout.hasVatContract?remainingRaw:0
};
}
function finalizeOutsourceItem(item){
const payments=Array.isArray(item.payments)?item.payments.filter(Boolean):[];
const paidEx=Math.round(payments.reduce((sum,p)=>sum+(p.paymentEx||0),0));
const paidIn=Math.round(payments.reduce((sum,p)=>sum+(p.paymentIn||0),0));
let remainingEx=0;
let remainingIn=0;
for(let i=payments.length-1;i>=0;i--){
const payment=payments[i];
if(payment.remainingKnown){
remainingEx=Math.round(payment.remainingEx||0);
remainingIn=Math.round(payment.remainingIn||0);
break;
}
}
if(!remainingEx&&item.contractEx>0) remainingEx=Math.max(0,Math.round(item.contractEx-paidEx));
if(!remainingIn&&item.contractIn>0) remainingIn=Math.max(0,Math.round(item.contractIn-paidIn));
return {...item,payments,paidEx,paidIn,remainingEx,remainingIn};
}
function parseOutsourceRows(rows,sheetName,map){
if(!rows||rows.length<6) return;
const vendor=cleanVendorName((rows[1]||[])[0],sheetName);
const layout=getOutsourceLayout(rows);
let currentKey="",currentName="",currentItem=null;
for(const row of rows.slice(5)){
if(shouldStopOutsourceRows(row)) break;
const projectName=String(row[2]??"").trim();
const projectKey=normalizeProjectKey(projectName);
const detail=String(row[3]??"").trim();
const validProject=projectKey&&projectKey!=="ref";
if(validProject){
currentKey=projectKey;
currentName=projectName;
const entry=getOutsourceEntry(map,currentKey,currentName);
entry.vendors.add(vendor);
currentItem=createOutsourceItem(entry,vendor,currentName,detail,row,layout);
const firstPayment=buildOutsourcePayment(currentItem,row,layout);
if(firstPayment) currentItem.payments.push(firstPayment);
continue;
}
if(!currentKey) continue;
const entry=getOutsourceEntry(map,currentKey,currentName);
entry.vendors.add(vendor);
const contractEx=num(row[layout.contractEx]);
const contractIn=layout.contractIn>=0?num(row[layout.contractIn]):0;
const hasFinancialRow=!!(contractEx||contractIn||num(row[layout.paymentAmount])||num(row[layout.remainingAmount]));
const hasMetaRow=!!(String(row[layout.invoiceDate]??"").trim()||String(row[layout.paymentDate]??"").trim()||String(row[layout.progress]??"").trim()||detail);
if(detail&&hasMetaRow){
currentItem=createOutsourceItem(entry,vendor,currentName,detail,row,layout);
const payment=buildOutsourcePayment(currentItem,row,layout);
if(payment) currentItem.payments.push(payment);
continue;
}
if(!currentItem){
if(!(hasFinancialRow||hasMetaRow)) continue;
currentItem=createOutsourceItem(entry,vendor,currentName,detail||"\uC678\uC8FC \uACC4\uC57D",row,layout);
}else{
if(contractEx>0) currentItem.contractEx+=contractEx;
if(contractIn>0) currentItem.contractIn+=contractIn;
if(!currentItem.progress) currentItem.progress=String(row[layout.progress]??"").trim();
}
const payment=buildOutsourcePayment(currentItem,row,layout);
if(payment) currentItem.payments.push(payment);
}
}
function parseOutsourceSheets(workbook){
const map=new Map();
const names=(workbook&&workbook.SheetNames)||[];
for(const sheetName of names){
if(!String(sheetName||"").startsWith("\uC678\uC8FC")) continue;
const sheet=workbook.Sheets[sheetName];
if(!sheet) continue;
const rows=XLSX.utils.sheet_to_json(sheet,{header:1,raw:false,defval:""});
parseOutsourceRows(rows,sheetName,map);
}
for(const entry of map.values()){
entry.items=entry.items.map(finalizeOutsourceItem).filter(item=>item.contractEx||item.contractIn||item.paidEx||item.paidIn||item.remainingEx||item.remainingIn||item.detail||item.payments.length);
entry.contract=Math.round(entry.items.reduce((sum,item)=>sum+(item.contractEx||0),0));
entry.contractIn=Math.round(entry.items.reduce((sum,item)=>sum+(item.contractIn||0),0));
entry.paid=Math.round(entry.items.reduce((sum,item)=>sum+(item.paidEx||0),0));
entry.paidIn=Math.round(entry.items.reduce((sum,item)=>sum+(item.paidIn||0),0));
entry.remaining=Math.round(entry.items.reduce((sum,item)=>sum+(item.remainingEx||0),0));
entry.remainingIn=Math.round(entry.items.reduce((sum,item)=>sum+(item.remainingIn||0),0));
}
return map;
}
function resolveOutsourceEntry(record,outsourceMap){
const fullKey=normalizeProjectKey(record.name||"");
const baseKey=normalizeProjectBase(record.name||"");
if(fullKey&&outsourceMap.has(fullKey)) return outsourceMap.get(fullKey);
if(baseKey&&outsourceMap.has(baseKey)) return outsourceMap.get(baseKey);
let best=null,bestScore=0;
for(const entry of outsourceMap.values()){
const entryFull=String(entry&&entry.key||"");
const entryBase=String(entry&&entry.baseKey||normalizeProjectBase(entry&&entry.name||""));
for(const candidate of [entryFull,entryBase]){
if(!candidate) continue;
const matched=(fullKey&&fullKey.includes(candidate))||(candidate&&fullKey&&candidate.includes(fullKey))||(baseKey&&baseKey.includes(candidate))||(candidate&&baseKey&&candidate.includes(baseKey));
if(matched&&candidate.length>bestScore){
best=entry;
bestScore=candidate.length;
}
}
}
return best;
}
function attachOutsourceCosts(records,outsourceMap){
return (records||[]).map(record=>{
const entry=resolveOutsourceEntry(record,outsourceMap);
const outsourceCost=entry?Math.round(entry.contract||0):0;
const outsourcePaid=entry?Math.round(entry.paid||0):0;
const outsourceRemaining=entry?Math.round(entry.remaining||0):0;
const outsourceCostIn=entry?Math.round(entry.contractIn||0):0;
const outsourcePaidIn=entry?Math.round(entry.paidIn||0):0;
const outsourceRemainingIn=entry?Math.round(entry.remainingIn||0):0;
const outsourceVendors=entry?Array.from(entry.vendors):[];
const outsourceItems=entry&&Array.isArray(entry.items)?entry.items.slice():[];
return {
...record,
outsourceCost,
outsourcePaid,
outsourceRemaining,
outsourceCostIn,
outsourcePaidIn,
outsourceRemainingIn,
outsourceVendors,
outsourceVendorText:summarizeOutsourceVendors(outsourceVendors),
outsourceItems
};
});
}
function parseLedgerRecords(R){
if(R.length&&R[0].length)R[0][0]=String(R[0][0]||"").replace(/^\uFEFF/,"");
const h=hs(R);if(h<0)throw new Error("헤더를 찾지 못했습니다.");
ch(R[h],R[h+1]||[]);
const I={cat:1,corp:4,code:5,name:6,pay:7,yn:8,order:9,pm:10,status:11,client:12,split:13,cDate:14,sDate:15,eDate:17,cSup:18,cVat:19,cTot:20,issueDate:21,colDate:22,sSup:23,sVat:24,sTot:25,col:26,recv:27,r:28,note:29,cmCo:30,cmNm:31,cmDp:32,cmPh:33,cmEm:34,dmCo:35,dmNm:36,dmDp:37,dmPh:38,dmEm:39};
const out=[];let current=null;
for(const row of R.slice(h+2)){
const x={
cat:textAt(row,I.cat),corp:textAt(row,I.corp),code:textAt(row,I.code),name:textAt(row,I.name),pay:textAt(row,I.pay),
yn:textAt(row,I.yn),order:textAt(row,I.order),pm:textAt(row,I.pm),status:textAt(row,I.status),client:textAt(row,I.client),
split:textAt(row,I.split),cDate:textAt(row,I.cDate),sDate:textAt(row,I.sDate),eDate:textAt(row,I.eDate),
cSup:moneyAt(row,I.cSup),cVat:moneyAt(row,I.cVat),cTot:moneyAt(row,I.cTot),issueDate:textAt(row,I.issueDate),colDate:textAt(row,I.colDate),
sSup:moneyAt(row,I.sSup),sVat:moneyAt(row,I.sVat),sTot:moneyAt(row,I.sTot),col:moneyAt(row,I.col),recv:moneyAt(row,I.recv),rateRaw:textAt(row,I.r),
note:textAt(row,I.note),cmCo:textAt(row,I.cmCo),cmNm:textAt(row,I.cmNm),cmDp:textAt(row,I.cmDp),cmPh:textAt(row,I.cmPh),cmEm:textAt(row,I.cmEm),
dmCo:textAt(row,I.dmCo),dmNm:textAt(row,I.dmNm),dmDp:textAt(row,I.dmDp),dmPh:textAt(row,I.dmPh),dmEm:textAt(row,I.dmEm)
};
if(!x.cTot) x.cTot=x.cSup+x.cVat;
if(!x.sTot) x.sTot=x.sSup+x.sVat;
if(!x.recv) x.recv=Math.max(0,x.sTot-x.col);
x.rate=rate(x.rateRaw,x.col,x.sTot);
const isProject=!!(x.code||(x.name&&(x.cat||x.corp||x.client||x.yn||x.order||x.pm)));
const isPayment=!isProject&&!!(x.pay||x.name||x.issueDate||x.colDate||x.sSup||x.sVat||x.sTot||x.col||x.recv);
if(isProject){
if(!x.name&&!x.code) continue;
if(current) out.push(finalizeProject(current));
current={...x,payments:[]};
continue;
}
if(isPayment&&current) current.payments.push(paymentRecord(x,x.pay));
}
if(current) out.push(finalizeProject(current));
return out;
}
function extractLedgerTotals(rows){
const indexes={contract:20,collected:26,receivable:27,rate:28};
let summaryRow=null;
for(let i=(rows||[]).length-1;i>=0;i--){
const row=rows[i]||[];
const hasSummaryLabel=row.some(cell=>String(cell??"").replace(/\s+/g,"").includes("합계"));
if(hasSummaryLabel){summaryRow=row;break;}
}
if(!summaryRow) return null;
const contract=num(summaryRow[indexes.contract]);
const collected=num(summaryRow[indexes.collected]);
const receivable=num(summaryRow[indexes.receivable]);
const rateRaw=String(summaryRow[indexes.rate]??"").trim();
if(!(contract||collected||receivable||rateRaw)) return null;
const totalBase=collected+receivable;
return {contract,collected,receivable,rate:rate(rateRaw,collected,totalBase)};
}
function parseLedger(txt){
const rows=parseCsv(txt);
return {records:parseLedgerRecords(rows),totals:extractLedgerTotals(rows)};
}
function parseLedgerExcel(buf){
if(typeof XLSX==="undefined")throw new Error("XLSX 라이브러리를 불러오지 못했습니다.");
const wb=XLSX.read(buf,{type:"array",cellDates:false});
const outsourceMap=parseOutsourceSheets(wb);
const names=wb.SheetNames||[];
const preferredNames=names.filter(name=>String(name||"").includes("공유사업관리대장"));
const candidateNames=preferredNames.length?preferredNames:[...names];
let bestRecords=null;
let bestSheet="";
let bestScore=-1;
let bestTotals=null;
for(const name of candidateNames){
try{
const sheet=wb.Sheets[name];
const rows=XLSX.utils.sheet_to_json(sheet,{header:1,raw:false,defval:""});
const normalized=(rows||[]).map(r=>Array.isArray(r)?r.map(v=>String(v??"")):[]);
const records=attachOutsourceCosts(parseLedgerRecords(normalized),outsourceMap);
if(!records.length) continue;
const totals=extractLedgerTotals(normalized);
const bonus=String(name||"").includes("공유사업관리대장")?1000000:/사업관리대장/i.test(String(name||""))?10000:0;
const score=records.length+bonus;
if(score>bestScore){
bestScore=score;
bestRecords=records;
bestSheet=name;
bestTotals=totals;
}
}catch(_){
// try next sheet
}
}
if(!bestRecords) throw new Error("엑셀에서 사업관리대장 헤더를 찾지 못했습니다.");
return { records: bestRecords, sheetName: bestSheet, totals: bestTotals };
}
function decode(buf){const u=new TextDecoder("utf-8").decode(buf);let e="";try{e=new TextDecoder("euc-kr").decode(buf);}catch(_){e=u;}return score(e)>score(u)?e:u;}
function sumRows(rows){return rows.reduce((a,r)=>(a.c+=r.cTot||0,a.s+=r.sTot||0,a.col+=r.col||0,a.recv+=r.recv||0,a),{c:0,s:0,col:0,recv:0});}
function isSettledRow(r){
const noSales=(r.sTot||0)<=0&&(r.col||0)<=0&&(r.recv||0)<=0;
const statusDone=String(r.status||"").includes("완료");
const coopDone=String(r.yn||"").includes("업무협조")&&statusDone&&noSales;
return coopDone||(statusDone&&Math.round(r.recv||0)<=0&&(r.rate||0)>=100);
}
function hasActiveDashboardFilters(){
return !!String(E.search.value||"").trim()||FILTER_KEYS.some(key=>!!S.filters[key]);
}
function codeFilterLabel(r){return r.cat||"-";}
function periodFilterLabel(r){return `${d(r.sDate)} ~ ${d(r.eDate)}`;}
function outsourceFilterLabel(r){return r.outsourceCost?won(r.outsourceCost):"-";}
function amountFilterLabel(r){return won(r.cSup);}
function collectedFilterLabel(r){return won(r.col);}
function rateFilterLabel(r){return r.rate.toFixed(2)+"%";}
function uniqueFilterValues(rows,mapFn){
const seen=new Set(),out=[];
for(const row of rows){
const value=String(mapFn(row)||"").trim();
if(!value||seen.has(value)) continue;
seen.add(value);
out.push(value);
}
return out;
}
function filterDefinitions(){
return [
{key:"code",map:codeFilterLabel},
{key:"name",map:r=>r.name||"-"},
{key:"corp",map:r=>r.corp||"-"},
{key:"status",map:r=>r.status||"-"},
{key:"outsource",map:outsourceFilterLabel},
{key:"amount",map:amountFilterLabel},
{key:"collected",map:collectedFilterLabel},
{key:"rate",map:rateFilterLabel}
];
}
function closeFilterMenus(){
Object.values(E.filterMenus).forEach(menu=>menu.classList.remove("open"));
Object.values(E.filterButtons).forEach(btn=>btn.classList.remove("open"));
}
function updateFilterButtons(){
FILTER_KEYS.forEach(key=>{
const btn=E.filterButtons[key];
if(!btn) return;
const active=!!S.filters[key];
btn.classList.toggle("active",active);
btn.title=active?`${btn.dataset.label}: ${S.filters[key]}`:btn.dataset.label||"";
const mark=btn.querySelector(".th-mark");
if(mark) mark.textContent=active?"•":"";
});
}
function renderFilterMenu(key,values){
const menu=E.filterMenus[key];
if(!menu) return;
const current=String(S.filters[key]||"");
menu.innerHTML=`<button type="button" class="th-option${!current?" active":""}" data-filter-value="">전체</button>`+values.map(v=>`<button type="button" class="th-option${current===v?" active":""}" data-filter-value="${escAttr(v)}">${esc(v)}</button>`).join("");
}
function syncColumnFilters(rows){
filterDefinitions().forEach(def=>{
const values=uniqueFilterValues(rows,def.map);
if(S.filters[def.key]&&!values.includes(S.filters[def.key])) delete S.filters[def.key];
renderFilterMenu(def.key,values);
});
updateFilterButtons();
}
function toggleFilterMenu(key){
const menu=E.filterMenus[key],btn=E.filterButtons[key];
if(!menu||!btn) return;
const willOpen=!menu.classList.contains("open");
closeFilterMenus();
if(willOpen){
menu.classList.add("open");
btn.classList.add("open");
}
}
function setFilterValue(key,value){
if(value) S.filters[key]=value;
else delete S.filters[key];
syncColumnFilters(S.all);
closeFilterMenus();
filter();
}
function matchesColumnFilters(r){
if(S.filters.code&&codeFilterLabel(r)!==S.filters.code) return false;
if(S.filters.name&&(r.name||"-")!==S.filters.name) return false;
if(S.filters.corp&&(r.corp||"-")!==S.filters.corp) return false;
if(S.filters.status&&(r.status||"-")!==S.filters.status) return false;
if(S.filters.outsource&&outsourceFilterLabel(r)!==S.filters.outsource) return false;
if(S.filters.amount&&amountFilterLabel(r)!==S.filters.amount) return false;
if(S.filters.collected&&collectedFilterLabel(r)!==S.filters.collected) return false;
if(S.filters.rate&&rateFilterLabel(r)!==S.filters.rate) return false;
return true;
}
function setText(id,v){const el=G(id);if(el)el.textContent=v||"-";}
function renderPaymentsHtml(payments){
if(!payments||!payments.length) return '<div class="pay-empty">대금 차수 정보가 없습니다.</div>';
return payments.map(p=>`<div class="pay-item"><div class="pay-head"><div class="pay-name">${esc(p.pay||"미입력")}</div><div style="font-size:11px;color:#64748b;font-weight:800;white-space:nowrap">${esc(p.status||"-")}</div></div><div class="pay-meta"><span>발행일 ${esc(d(p.issueDate))}</span><span>수금일 ${esc(d(p.collectDate))}</span><span>공급가액 ${esc(won(p.supply))}</span><span>수금금액 ${esc(won(p.collected))}</span></div>${p.note?`<div class="pay-note">비고: ${esc(p.note)}</div>`:""}</div>`).join("");
}
function renderOutsourcePayments(payments){
const list=(payments||[]).filter(payment=>payment&&(payment.label||payment.note||payment.invoiceDate||payment.paymentDate||payment.paymentEx||payment.remainingEx||payment.paymentIn||payment.remainingIn));
if(!list.length) return "";
return `<div class="out-payments">${list.map((payment,index)=>`<div class="out-payment"><div class="out-payment-head"><span>${esc(payment.label||`\uC9C0\uAE09 ${index+1}`)}</span><span>${esc(payment.paymentDate?d(payment.paymentDate):"-")}</span></div><div class="out-payment-meta"><span>\uACC4\uC0B0\uC11C\uC77C\uC790 ${esc(payment.invoiceDate?d(payment.invoiceDate):"-")}</span><span>\uC9C0\uAE09\uAE08\uC561 ${esc(payment.paymentEx?won(payment.paymentEx):"-")}</span><span>\uC794\uC5EC\uAE08\uC561 ${esc(payment.remainingEx||payment.remainingEx===0?won(payment.remainingEx):"-")}</span></div>${payment.note?`<div class="out-note">\uBE44\uACE0: ${esc(payment.note)}</div>`:""}</div>`).join("")}</div>`;
}
function countOutsourceStages(r){
return (r.outsourceItems||[]).reduce((sum,item)=>{
const stages=(item.payments||[]).filter(payment=>payment&&(payment.label||payment.note||payment.invoiceDate||payment.paymentDate||payment.paymentEx||payment.remainingEx||payment.paymentIn||payment.remainingIn));
return sum+(stages.length||1);
},0);
}
function summarizeOutsourceCounts(r){
const vendors=(r.outsourceVendors||[]).length;
const contracts=(r.outsourceItems||[]).length;
const stages=countOutsourceStages(r);
const parts=[];
if(vendors) parts.push(`외주처 ${vendors.toLocaleString("ko-KR")}`);
if(contracts) parts.push(`계약 ${contracts.toLocaleString("ko-KR")}`);
if(stages) parts.push(`지급단계 ${stages.toLocaleString("ko-KR")}`);
return parts.join(" · ")||"외주 내역 없음";
}
function renderOutsourceHtml(items){
if(!items||!items.length) return '<div class="pay-empty">외주 상세 정보가 없습니다.</div>';
return items.map(item=>{
const stageCount=(item.payments||[]).filter(payment=>payment&&(payment.label||payment.note||payment.invoiceDate||payment.paymentDate||payment.paymentEx||payment.remainingEx||payment.paymentIn||payment.remainingIn)).length;
const stageText=stageCount?`지급단계 ${stageCount.toLocaleString("ko-KR")}`:"지급내역 없음";
const periodText=(d(item.startDate)==="-"&&d(item.endDate)==="-")?"-":`${d(item.startDate)} ~ ${d(item.endDate)}`;
return `<div class="out-item"><div class="out-head"><div><div class="out-vendor">${esc(item.vendor||"외주")}</div><div class="out-name">${esc(item.detail||"-")}</div></div><div style="font-size:11px;color:#64748b;font-weight:800;white-space:nowrap">${esc(item.progress||stageText)}</div></div><div class="out-meta"><span>계약기간 ${esc(periodText)}</span><span>계약금액 ${esc(item.contractEx?won(item.contractEx):"-")}</span><span>지급금액 ${esc(item.paidEx||item.paidEx===0?won(item.paidEx):"-")}</span><span>잔여금액 ${esc(item.remainingEx||item.remainingEx===0?won(item.remainingEx):"-")}</span><span>계산서일자 ${esc(item.invoiceDate?d(item.invoiceDate):"-")}</span><span>${esc(stageText)}</span></div>${item.note?`<div class="out-note">비고: ${esc(item.note)}</div>`:""}${renderOutsourcePayments(item.payments||[])}</div>`;
}).join("");
}
function renderContactCompact(label,name,company,dept,phone,email){
return `<div class="summary-card"><div class="summary-label">${esc(label)}</div><div style="margin-top:6px;font-size:16px;font-weight:900">${esc(name||"-")}</div><div class="summary-note">${esc([company||"-",dept||"-"].join(" · "))}</div><div class="summary-note">${esc(`전화 ${phone||"-"} / 메일 ${email||"-"}`)}</div></div>`;
}
function renderOutsourceBoard(r){
const items=r.outsourceItems||[];
if(!items.length){
return `<div class="ledger-block outsource"><div class="ledger-head"><div class="ledger-head-left"><div class="ledger-icon">O</div><div><div class="ledger-name">외주 계약 / 지급 현황</div><div class="ledger-sub">등록된 외주 데이터 없음</div></div></div><div class="ledger-pill">총 계약 0원</div></div><div class="ledger-empty">외주 상세 정보가 없습니다.</div></div>`;
}
return `<div class="ledger-block outsource"><div class="ledger-head"><div class="ledger-head-left"><div class="ledger-icon">O</div><div><div class="ledger-name">외주 계약 / 지급 현황</div><div class="ledger-sub">VAT 별도</div></div></div><div class="ledger-pill">총 계약 ${esc(r.outsourceCost?won(r.outsourceCost):"-")}</div></div><div class="ledger-table-wrap"><table class="ledger-table"><thead><tr><th>외주처 / 계약명</th><th>계약기간</th><th style="text-align:right">계약금액</th><th style="text-align:right">지급금액</th><th style="text-align:right">잔여금액</th><th>진행현황</th><th>비고</th></tr></thead><tbody>${items.map(item=>{const periodText=(d(item.startDate)==="-"&&d(item.endDate)==="-")?"-":`${d(item.startDate)} ~ ${d(item.endDate)}`;const noteLines=(item.payments||[]).map(payment=>{const label=String(payment.label||"").trim();const note=String(payment.note||"").trim();if(!label&&!note) return "";if(label&&note) return `${label}: ${note}`;return label||note;}).filter(Boolean);if(item.note) noteLines.unshift(item.note);return `<tr><td><span class="ledger-main">${esc(item.vendor||"외주")}</span><span class="ledger-muted">${esc(item.detail||"-")}</span></td><td><span class="ledger-main">${esc(periodText)}</span></td><td class="ledger-amount">${esc(item.contractEx?won(item.contractEx):"-")}</td><td class="ledger-amount">${esc(item.paidEx||item.paidEx===0?won(item.paidEx):"-")}</td><td class="ledger-amount">${esc(item.remainingEx||item.remainingEx===0?won(item.remainingEx):"-")}</td><td><span class="ledger-note">${esc(item.progress||"-")}</span></td><td><span class="ledger-note">${esc(noteLines.join(" / ")||"-")}</span></td></tr>`;}).join("")}</tbody></table></div></div>`;
}
function renderCollectionBoard(r){
const payments=r.payments&&r.payments.length?r.payments:[{pay:r.pay||"-",issueDate:r.issueDate||"",collectDate:r.collectDateSummary||r.colDate||"",supply:r.sSup||0,collected:r.col||0,receivable:r.recv||Math.max(0,(r.sTot||0)-(r.col||0)),rate:r.rate||0,note:r.note||"",status:r.status||"-"}];
return `<div class="ledger-block collect"><div class="ledger-head"><div class="ledger-head-left"><div class="ledger-icon">C</div><div><div class="ledger-name">수금 및 기성 현황</div><div class="ledger-sub">VAT 별도</div></div></div><div class="ledger-pill">총 수금 ${esc(won(r.col))}</div></div><div class="ledger-table-wrap"><table class="ledger-table"><thead><tr><th>발행 / 수금일</th><th>구분</th><th style="text-align:right">공급가액</th><th style="text-align:right">수금금액</th><th style="text-align:right">미수금액</th><th style="text-align:right">수금율</th><th>비고</th></tr></thead><tbody>${payments.map(payment=>{const dateParts=[payment.issueDate?`발행 ${d(payment.issueDate)}`:"",payment.collectDate?`수금 ${d(payment.collectDate)}`:""].filter(Boolean);const noteParts=[];if(payment.status) noteParts.push(payment.status);if(payment.note) noteParts.push(payment.note);return `<tr><td><span class="ledger-main">${esc(dateParts[0]||"-")}</span><span class="ledger-muted">${esc(dateParts[1]||"수금일 없음")}</span></td><td><span class="ledger-main">${esc(payment.pay||"미입력")}</span></td><td class="ledger-amount">${esc(won(payment.supply||0))}</td><td class="ledger-amount">${esc(won(payment.collected||0))}</td><td class="ledger-amount">${esc(won(payment.receivable||0))}</td><td class="ledger-amount">${esc(((payment.rate||0).toFixed?payment.rate.toFixed(2):Number(payment.rate||0).toFixed(2))+"%")}</td><td><span class="ledger-note">${esc(noteParts.join(" / ")||"-")}</span></td></tr>`;}).join("")}</tbody></table></div></div>`;
}
function renderProjectInline(r){
const payments=r.payments||[];
const latestCollect=d(r.collectDateSummary||r.colDate);
const collectCountText=payments.length?`차수 ${payments.length.toLocaleString("ko-KR")}`:"수금 내역 없음";
const outsourceCountText=summarizeOutsourceCounts(r);
const hasOutsource=(r.outsourceItems||[]).length>0||(r.outsourceCost||0)>0||(r.outsourcePaid||0)>0||(r.outsourceRemaining||0)>0;
const summaryCards=[
`<div class="summary-card"><div class="summary-label">계약금</div><div class="summary-value">${esc(won(r.cSup))}</div><div class="summary-note">VAT 별도</div></div>`,
`<div class="summary-card"><div class="summary-label">수금액</div><div class="summary-value">${esc(won(r.col))}</div><div class="summary-note">${esc(latestCollect==="-"?"수금일 없음":`최종 수금일 ${latestCollect}`)}</div></div>`,
`<div class="summary-card"><div class="summary-label">수금율</div><div class="summary-value">${esc(r.rate.toFixed(2)+"%")}</div><div class="summary-note">${esc(collectCountText)}</div></div>`
].filter(Boolean).join("");
const bottomNotes=[
`<div class="summary-note">미수금액 ${esc(won(r.recv))}</div>`
].join("");
const boards=[
hasOutsource?renderOutsourceBoard(r):"",
renderCollectionBoard(r)
].filter(Boolean).join("");
return `<div class="inline-panel"><div class="project-head"><div class="inline-card"><div class="project-meta-grid"><div class="kv"><div class="kvk">계약법인</div><div class="kvv">${esc(r.corp||"-")}</div></div><div class="kv"><div class="kvk">발주처</div><div class="kvv">${esc(r.client||"-")}</div></div><div class="kv"><div class="kvk">발주방법</div><div class="kvv">${esc(r.order||"-")}</div></div><div class="kv"><div class="kvk">PM</div><div class="kvv">${esc(r.pm||"-")}</div></div></div><div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:10px">${renderContactCompact("계약 / 청구 담당자",r.cmNm,r.cmCo,r.cmDp,r.cmPh,r.cmEm)}${renderContactCompact("부서 담당자",r.dmNm,r.dmCo,r.dmDp,r.dmPh,r.dmEm)}</div></div><div class="inline-card"><div class="summary-grid">${summaryCards}</div><div style="margin-top:10px" class="progress"><div class="bar" style="width:${Math.max(0,Math.min(100,r.rate||0))}%"></div></div><div style="display:flex;justify-content:space-between;gap:10px;margin-top:10px">${bottomNotes}</div></div></div><div class="ledger-stack">${boards}</div></div>`;
}
function closeAllModals(){
E.collectModal.classList.remove("show");
E.outsourceModal.classList.remove("show");
}
function toggleInlineDetail(r){
const key=rowKey(r);
S.expanded.key=S.expanded.key===key?"":key;
render();
}
function openCollectionModal(r){
setText("mCat",r.cat||"미분류");G("mCat").classList.toggle("ok",(r.status||"").includes("완료"));setText("mTitle",r.name||"-");setText("mSub","Project Code: "+(r.code||"-")+" · 계약법인: "+(r.corp||"-"));
setText("mClient",r.client||"-");setText("mOrder",r.order||"-");setText("mSplit",r.split||"-");setText("mStartDate",d(r.sDate));setText("mEndDate",d(r.eDate));setText("mPayType",r.pay||"-");G("mPayItems").innerHTML=renderPaymentsHtml(r.payments||[]);
setText("mContractTotal",won(r.cTot));setText("mContractSupply","공급가액: "+won(r.cSup));setText("mCollected",won(r.col));setText("mCollectDate",(r.payments&&r.payments.length>1?"최근 수금일: ":"수금일: ")+d(r.collectDateSummary||r.colDate));setText("mRate",r.rate.toFixed(2)+"%");setText("mReceivable",won(r.recv));G("mRateBar").style.width=Math.max(0,Math.min(100,r.rate||0))+"%";
setText("mCmName",r.cmNm||"-");setText("mCmOrg",(r.cmCo||"-")+" · "+(r.cmDp||"-"));setText("mCmPhone","전화: "+(r.cmPh||"-"));setText("mCmEmail","메일: "+(r.cmEm||"-"));
setText("mDmName",r.dmNm||"-");setText("mDmOrg",(r.dmCo||"-")+" · "+(r.dmDp||"-"));setText("mDmPhone","전화: "+(r.dmPh||"-"));setText("mDmEmail","메일: "+(r.dmEm||"-"));
closeAllModals();
E.collectModal.classList.add("show");
}
function openOutsourceModal(r){
setText("oTitle",r.name||"-");
setText("oSub","Project Code: "+(r.code||"-")+" · PM: "+(r.pm||"-"));
setText("oCorp",r.corp||"-");
setText("oClient",r.client||"-");
setText("oVendors",r.outsourceVendorText||"-");
setText("oTotal",r.outsourceCost?won(r.outsourceCost):"-");
setText("oCount",(r.outsourceItems||[]).length?`${(r.outsourceItems||[]).length.toLocaleString("ko-KR")}`:"0건");
setText("oPeriod",r.periodText||"-");
setText("oTotalHero",r.outsourceCost?won(r.outsourceCost):"-");
setText("oTotalHint",(r.outsourceItems||[]).length?"시트별 외주 상세 내역 합산":"외주 상세 정보가 없습니다.");
G("oItems").innerHTML=renderOutsourceHtml(r.outsourceItems||[]);
closeAllModals();
E.outsourceModal.classList.add("show");
}
function outsourceSummaryText(r){
const contracts=(r.outsourceItems||[]).length;
const stages=countOutsourceStages(r);
const parts=[];
if(contracts) parts.push(`계약 ${contracts.toLocaleString("ko-KR")}`);
if(stages) parts.push(`지급단계 ${stages.toLocaleString("ko-KR")}`);
if(parts.length) return parts.join(" · ");
return "-";
}
function render(){
const rows=S.rows,t=sumRows(rows),viewRows=rows.slice().sort((a,b)=>{const as=isSettledRow(a),bs=isSettledRow(b);if(as!==bs)return as?1:-1;return (b.recv||0)-(a.recv||0);});
const useSheetTotals=!!(S.totals&&!hasActiveDashboardFilters());
const totalContract=useSheetTotals?S.totals.contract:t.c;
const totalCollected=useSheetTotals?S.totals.collected:t.col;
const totalReceivable=useSheetTotals?S.totals.receivable:t.recv;
const totalRate=useSheetTotals?S.totals.rate:rate("",totalCollected,totalCollected+totalReceivable);
S.viewRows=viewRows;
E.cards.innerHTML=[["총 프로젝트수",rows.length.toLocaleString("ko-KR")+" 건"],["총 계약금",won(totalContract)],["총 수금금액",won(totalCollected)],["총 미수금액",won(totalReceivable)],["총 수금율",totalRate.toFixed(2)+"%"]].map(c=>`<div class="card"><div class="k">${esc(c[0])}</div><div class="v">${esc(c[1])}</div></div>`).join("");
E.tbody.innerHTML=viewRows.map((r,i)=>{
const key=rowKey(r);
const detailOpen=S.expanded.key===key;
const detailHtml=detailOpen?renderProjectInline(r):"";
return `<tr data-i="${i}" class="${isSettledRow(r)?"settled":""}"><td><div class="badge">${esc(r.cat||"-")}</div><div class="subline">ID: ${esc(r.code||"-")}</div></td><td><div class="name">${esc(r.name||"-")}</div><div class="subline">${esc(r.periodText||"-")}</div></td><td><div>${esc(r.corp||"-")}</div></td><td><div class="badge ${(r.status||"").includes("완료")?"ok":""}">${esc(r.status||"-")}</div><div class="subline">${esc(r.yn||"-")}</div></td><td class="num"><strong>${esc(r.outsourceCost?won(r.outsourceCost):"-")}</strong></td><td class="num"><strong>${esc(won(r.cSup))}</strong></td><td class="num"><strong>${esc(won(r.col))}</strong></td><td class="num"><strong style="color:${isSettledRow(r)?"#94a3b8":"#2563eb"}">${esc(r.rate.toFixed(2)+"%")}</strong></td></tr>${detailHtml?`<tr class="detail-row"><td class="detail-cell" colspan="8">${detailHtml}</td></tr>`:""}`;
}).join("");
E.empty.style.display=rows.length?"none":"block";
const settledCount=S.all.filter(isSettledRow).length;
E.status.textContent=S.all.length?`로드 완료: ${S.all.length.toLocaleString("ko-KR")}${S.file?` · 파일: ${S.file}`:""}${settledCount?` · 완납 ${settledCount.toLocaleString("ko-KR")}건 하단 정렬`:""}`:"CSV/XLSX 파일을 업로드하면 데이터가 표시됩니다.";
}
function filter(){const q=String(E.search.value||"").trim().toLowerCase();const searched=!q?S.all.slice():S.all.filter(r=>[r.code,r.name,r.client,r.pm,r.status,r.cat,r.corp,r.pay,(r.payments||[]).map(p=>p.pay).join(" "),r.periodText,r.outsourceVendorText,(r.outsourceItems||[]).map(item=>[item.vendor,item.detail,item.progress,item.note,(item.payments||[]).map(payment=>[payment.label,payment.note,payment.invoiceDate,payment.paymentDate].join(" ")).join(" ")].join(" ")).join(" "),outsourceFilterLabel(r),amountFilterLabel(r),collectedFilterLabel(r)].join(" ").toLowerCase().includes(q));S.rows=searched.filter(matchesColumnFilters);render();}
function applyParsedLedgerResult(fileName,parsed,sheetName){
S.all=parsed.records;
S.totals=parsed.totals||null;
S.file=(fileName||"")+(sheetName?` [${sheetName}]`:"");
syncColumnFilters(S.all);
filter();
}
async function loadLedgerFile(buffer,fileName){
const isExcel=/\.(xlsx|xls)$/i.test(String(fileName||""));
if(isExcel){
const parsed=parseLedgerExcel(buffer);
applyParsedLedgerResult(fileName,parsed,parsed.sheetName||"");
return;
}
const parsed=parseLedger(decode(buffer));
applyParsedLedgerResult(fileName,parsed,"");
}
E.btnUpload.addEventListener("click",()=>E.file.click());
E.file.addEventListener("change",async e=>{
const f=e.target.files&&e.target.files[0];
try{
if(f){
const buf=await f.arrayBuffer();
await loadLedgerFile(buf,f.name||"");
}
}catch(err){
S.all=[];S.rows=[];S.totals=null;syncColumnFilters([]);closeAllModals();render();E.status.textContent="업로드 실패: "+(err&&err.message?err.message:String(err));
}
e.target.value="";
});
E.search.addEventListener("input",filter);
Object.values(E.filterButtons).forEach(btn=>btn.addEventListener("click",e=>{e.stopPropagation();toggleFilterMenu(btn.dataset.filter);}));
Object.values(E.filterMenus).forEach(menu=>menu.addEventListener("click",e=>{
e.stopPropagation();
const option=e.target&&e.target.closest?e.target.closest("button[data-filter-value]"):null;
if(!option) return;
setFilterValue(menu.dataset.filter,option.getAttribute("data-filter-value")||"");
}));
E.tbody.addEventListener("click",e=>{
const rowEl=e.target&&e.target.closest?e.target.closest("tr[data-i]"):null;
if(!rowEl) return;
const r=S.viewRows[parseInt(rowEl.getAttribute("data-i"),10)];
if(!r) return;
toggleInlineDetail(r);
});
E.btnCollectClose.addEventListener("click",closeAllModals);
E.btnOutsourceClose.addEventListener("click",closeAllModals);
E.collectModal.addEventListener("click",e=>{if(e.target===E.collectModal)closeAllModals();});
E.outsourceModal.addEventListener("click",e=>{if(e.target===E.outsourceModal)closeAllModals();});
document.addEventListener("click",e=>{if(!(e.target&&e.target.closest&&e.target.closest(".th-head")))closeFilterMenus();});
document.addEventListener("keydown",e=>{if(e.key==="Escape"){closeFilterMenus();closeAllModals();}});
window.addEventListener("message",async e=>{
const data=e.data||{};
if(data.source==="total-control"&&data.type==="embedded-host") E.btnUpload.style.display="none";
if(data.source!=="total-upload"||data.type!=="business") return;
try{
const buffer=data.buffer instanceof ArrayBuffer?data.buffer:(data.buffer&&data.buffer.buffer instanceof ArrayBuffer?data.buffer.buffer:null);
if(!buffer) throw new Error("업로드 데이터가 비어 있습니다.");
await loadLedgerFile(buffer,data.fileName||"사업관리대장.xlsx");
}catch(err){
S.all=[];S.rows=[];S.totals=null;syncColumnFilters([]);closeAllModals();render();E.status.textContent="업로드 실패: "+(err&&err.message?err.message:String(err));
}
});
syncColumnFilters([]);
render();
</script>
__LEDGER_BODY_SCRIPTS__</body>
</html>

View File

@@ -0,0 +1,18 @@
# Payment App Source
`프로젝트별 분석` 화면의 앱 구조 source-of-truth 디렉터리다.
원칙:
- 실제 runtime 응답은 여전히 `incoming-files/served/payment.html`을 사용한다.
- 수정 원본은 이 디렉터리의 `index.html`만 본다.
- 반영은 `scripts/publish_payment_app.sh`로 수행한다.
구성:
- `index.html`: 프로젝트별 분석 standalone app 원본
주의:
- runtime을 수정할 때 `incoming-files/served/payment.html`부터 고치지 않는다.
- 먼저 `frontend/apps/payment/index.html`을 수정한 뒤 publish 한다.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,18 @@
# Team App Source
`팀/개인별 분석` 화면의 앱 구조 source-of-truth 디렉터리다.
원칙:
- 실제 runtime 응답은 `incoming-files/served/mh.html`을 사용한다.
- 수정 원본은 이 디렉터리의 `index.html`만 본다.
- 반영은 `scripts/publish_team_app.sh`로 수행한다.
구성:
- `index.html`: 팀/개인별 분석 standalone app 원본
주의:
- runtime을 수정할 때 `incoming-files/served/mh.html`부터 고치지 않는다.
- 먼저 `frontend/apps/team/index.html`을 수정한 뒤 publish 한다.

File diff suppressed because it is too large Load Diff

View File

@@ -23,6 +23,8 @@ const projectFrame = document.getElementById("project-frame");
const projectStage = document.getElementById("project-stage");
const teamFrame = document.getElementById("team-frame");
const teamStage = document.getElementById("team-stage");
const dbStatusFrame = document.getElementById("db-status-frame");
const dbStatusStage = document.getElementById("db-status-stage");
const seatMapAdminStage = document.getElementById("seatmap-admin-stage");
const seatMapReadonlyStage = document.getElementById("seatmap-readonly-stage");
const emptyStage = document.getElementById("empty-stage");
@@ -48,6 +50,7 @@ const seatMapDom = {
formGap: null,
formImage: document.getElementById("seatmap-admin-form-image"),
search: document.getElementById("seatmap-admin-search"),
context: document.getElementById("seatmap-admin-context"),
unassigned: document.getElementById("seatmap-admin-unassigned"),
officeTabs: document.getElementById("seatmap-admin-office-tabs"),
sidebarTitle: document.getElementById("seatmap-admin-sidebar-title"),
@@ -73,6 +76,7 @@ const seatMapDom = {
formGap: null,
formImage: null,
search: document.getElementById("seatmap-readonly-search"),
context: document.getElementById("seatmap-readonly-context"),
unassigned: document.getElementById("seatmap-readonly-unassigned"),
officeTabs: document.getElementById("seatmap-readonly-office-tabs"),
sidebarTitle: document.getElementById("seatmap-readonly-sidebar-title"),
@@ -98,6 +102,7 @@ let seatMapFormCols = seatMapDom.admin.formCols;
let seatMapFormGap = seatMapDom.admin.formGap;
let seatMapFormImage = seatMapDom.admin.formImage;
let seatMapSearch = seatMapDom.admin.search;
let seatMapContext = seatMapDom.admin.context;
let seatMapUnassigned = seatMapDom.admin.unassigned;
let seatMapOfficeTabs = seatMapDom.admin.officeTabs;
let seatMapSidebarTitle = seatMapDom.admin.sidebarTitle;
@@ -115,6 +120,7 @@ const viewLabels = {
project: "프로젝트별 분석",
team: "팀/개인별 분석",
organization: "조직 현황",
"db-status": "DB 상태",
"seatmap-admin": "자리배치도",
"seatmap-readonly": "자리배치도",
};
@@ -132,6 +138,7 @@ const seatMapState = {
search: "",
status: "",
statusTone: "info",
selectedMemberId: null,
draggingMemberId: null,
zoom: 1,
panning: false,
@@ -488,6 +495,7 @@ function syncSeatMapDomRefs() {
seatMapFormGap = dom.formGap;
seatMapFormImage = dom.formImage;
seatMapSearch = dom.search;
seatMapContext = dom.context;
seatMapUnassigned = dom.unassigned;
seatMapOfficeTabs = dom.officeTabs;
seatMapSidebarTitle = dom.sidebarTitle;
@@ -834,6 +842,98 @@ function getMemberMap() {
return new Map(seatMapState.members.map((member) => [Number(member.id), member]));
}
function buildSeatMapTeamTone(teamName) {
const team = String(teamName || "").trim();
if (!team) {
return {
accent: "rgba(120, 132, 119, 0.86)",
soft: "rgba(120, 132, 119, 0.18)",
label: "미분류",
};
}
const palette = [
{ accent: "rgba(13, 148, 136, 0.9)", soft: "rgba(13, 148, 136, 0.18)" },
{ accent: "rgba(202, 138, 4, 0.9)", soft: "rgba(202, 138, 4, 0.18)" },
{ accent: "rgba(5, 150, 105, 0.9)", soft: "rgba(5, 150, 105, 0.18)" },
{ accent: "rgba(180, 83, 9, 0.9)", soft: "rgba(180, 83, 9, 0.18)" },
{ accent: "rgba(20, 83, 45, 0.9)", soft: "rgba(20, 83, 45, 0.16)" },
{ accent: "rgba(11, 110, 79, 0.9)", soft: "rgba(11, 110, 79, 0.18)" },
];
let hash = 0;
for (const char of team) {
hash = ((hash * 31) + char.charCodeAt(0)) % 2147483647;
}
const picked = palette[Math.abs(hash) % palette.length];
return { ...picked, label: team };
}
function getSeatMapTeamStyle(member) {
const tone = buildSeatMapTeamTone(getSeatMapOrgUnitLabel(member));
return `--seatmap-team-accent:${tone.accent}; --seatmap-team-soft:${tone.soft};`;
}
function renderSeatMapTeamChip(member) {
const label = getSeatMapOrgUnitLabel(member);
const tone = buildSeatMapTeamTone(label);
return `<span class="seatmap-team-chip" style="${escapeHtml(getSeatMapTeamStyle({ team: label }))}">${escapeHtml(label)}</span>`;
}
function getSeatMapOrgPath(member) {
const values = [
member?.department,
member?.grp,
member?.division,
member?.team,
member?.cell,
]
.map((value) => String(value || "").trim())
.filter(Boolean);
return values.filter((value, index) => values.indexOf(value) === index);
}
function getSeatMapOrgUnitLabel(member) {
return String(
member?.team
|| member?.division
|| member?.grp
|| member?.department
|| member?.cell
|| "미분류"
).trim() || "미분류";
}
function getSeatMapOverlayTeamLabel(member) {
return String(member?.team || "").trim();
}
function renderSeatMapMemberContext(memberId) {
if (!seatMapContext) return;
const member = memberId ? getMemberMap().get(Number(memberId)) : null;
seatMapState.selectedMemberId = member ? Number(member.id) : null;
if (!member) {
seatMapContext.classList.add("hidden");
seatMapContext.innerHTML = "";
return;
}
const tone = buildSeatMapTeamTone(member.team);
const orgPath = getSeatMapOrgPath(member);
seatMapContext.classList.remove("hidden");
seatMapContext.innerHTML = `
<div class="seatmap-context-head">
<div class="seatmap-context-title">
<strong>${escapeHtml(member.name || "-")}</strong>
<span>${escapeHtml(member.rank || member.role || "구성원")}</span>
</div>
<span class="seatmap-context-badge" style="${escapeHtml(getSeatMapTeamStyle(member))}">${escapeHtml(tone.label)}</span>
</div>
<div class="seatmap-context-tree">
${orgPath.length
? orgPath.map((item) => `<span class="seatmap-context-node">${escapeHtml(item)}</span>`).join("")
: '<div class="seatmap-context-empty">표시할 상위 조직 정보가 없습니다.</div>'}
</div>
`;
}
function getPlacementForMember(memberId) {
return getPlacementSource().find((item) => Number(item.member_id) === Number(memberId)) || null;
}
@@ -868,9 +968,13 @@ function getSidebarMembers() {
return members.filter(memberMatchesSeatMapSearch);
}
function focusSeatMapMember(memberId) {
function focusSeatMapMember(memberId, options = {}) {
const showContext = options.showContext !== false;
const placement = getPlacementForMember(memberId);
const member = getMemberMap().get(Number(memberId));
if (showContext) {
renderSeatMapMemberContext(memberId);
}
if (!placement) {
setSeatMapStatus("해당 인원은 아직 배치되지 않았습니다.", "info");
return;
@@ -940,6 +1044,46 @@ function getSlotPlacementMap() {
return slotMap;
}
function buildSeatMapGridTeamOverlays(rows, cols, placementMap, memberMap) {
const groups = new Map();
placementMap.forEach((placement) => {
const member = memberMap.get(Number(placement.member_id));
if (!member) return;
const label = getSeatMapOverlayTeamLabel(member);
if (!label) return;
const rowIndex = Number(placement.row_index);
const colIndex = Number(placement.col_index);
if (!Number.isFinite(rowIndex) || !Number.isFinite(colIndex)) return;
if (!groups.has(label)) {
groups.set(label, {
label,
toneMember: { team: label },
minRow: rowIndex,
maxRow: rowIndex,
minCol: colIndex,
maxCol: colIndex,
count: 1,
});
return;
}
const group = groups.get(label);
group.minRow = Math.min(group.minRow, rowIndex);
group.maxRow = Math.max(group.maxRow, rowIndex);
group.minCol = Math.min(group.minCol, colIndex);
group.maxCol = Math.max(group.maxCol, colIndex);
group.count += 1;
});
return Array.from(groups.values())
.filter((group) => group.count >= 2 && group.maxRow < rows && group.maxCol < cols)
.map((group) => `
<div
class="seatmap-team-overlay"
data-team-label="${escapeHtml(group.label)}"
style="${escapeHtml(getSeatMapTeamStyle(group.toneMember))}; grid-column:${group.minCol + 1} / ${group.maxCol + 2}; grid-row:${group.minRow + 1} / ${group.maxRow + 2};"
></div>
`);
}
function upsertDraftPlacement(memberId, rowIndex, colIndex) {
const cellMap = getCellPlacementMap();
const existing = cellMap.get(`${rowIndex}:${colIndex}`);
@@ -1000,11 +1144,12 @@ function renderMemberCard(member, draggable) {
? `<span class="seatmap-member-avatar"><img src="${photoUrl}" alt="${escapeHtml(member.name)}"></span>`
: `<span class="seatmap-member-avatar seatmap-member-avatar-fallback">${escapeHtml(getInitials(member.name))}</span>`;
return `
<div class="seatmap-member-card${draggable ? " draggable" : ""}" draggable="${draggable}" data-member-id="${Number(member.id)}">
<div class="seatmap-member-card team-colored${draggable ? " draggable" : ""}" style="${escapeHtml(getSeatMapTeamStyle(member))}" draggable="${draggable}" data-member-id="${Number(member.id)}">
${avatar}
<span class="seatmap-member-text">
<strong>${escapeHtml(member.name || "-")}</strong>
<em>${escapeHtml(member.department || member.team || member.rank || "-")}</em>
<em>${escapeHtml(member.rank || "-")}</em>
<span class="seatmap-team-chip" style="${escapeHtml(getSeatMapTeamStyle({ team: getSeatMapOrgUnitLabel(member) }))}">${escapeHtml(getSeatMapOrgUnitLabel(member))}</span>
</span>
</div>
`;
@@ -1012,11 +1157,12 @@ function renderMemberCard(member, draggable) {
function renderUnassignedMemberCard(member, draggable) {
return `
<div class="seatmap-member-card seatmap-member-card-compact${draggable ? " draggable" : ""}" draggable="${draggable}" data-member-id="${Number(member.id)}">
<div class="seatmap-member-card seatmap-member-card-compact${draggable ? " draggable" : ""}" style="${escapeHtml(getSeatMapTeamStyle(member))}" draggable="${draggable}" data-member-id="${Number(member.id)}">
<span class="seatmap-member-text seatmap-member-text-inline">
<strong>${escapeHtml(member.name || "-")}</strong>
<em>${escapeHtml(member.rank || "-")}</em>
</span>
${renderSeatMapTeamChip(member)}
</div>
`;
}
@@ -1024,14 +1170,13 @@ function renderUnassignedMemberCard(member, draggable) {
function renderSeatMapSearchCard(member) {
const placement = getPlacementForMember(Number(member.id));
if (!placement) return "";
const badge = `<span class="seatmap-member-badge occupied">${escapeHtml(placement.seat_label || "배치완료")}</span>`;
return `
<button class="seatmap-member-search-card" type="button" data-member-id="${Number(member.id)}">
<span class="seatmap-member-text seatmap-member-text-inline">
<strong>${escapeHtml(member.name || "-")}</strong>
<em>${escapeHtml(member.rank || member.department || "-")}</em>
<em>${escapeHtml(member.rank || "-")}</em>
</span>
${badge}
${renderSeatMapTeamChip(member)}
</button>
`;
}
@@ -1051,14 +1196,16 @@ function renderSeatMapBoard() {
const gap = Number(seatMapState.seatMap.cell_gap || 0);
const editable = seatMapState.editMode && isAdmin();
const cells = [];
const overlays = buildSeatMapGridTeamOverlays(rows, cols, placementMap, memberMap);
for (let rowIndex = 0; rowIndex < rows; rowIndex += 1) {
for (let colIndex = 0; colIndex < cols; colIndex += 1) {
const key = `${rowIndex}:${colIndex}`;
const placement = placementMap.get(key);
const member = placement ? memberMap.get(Number(placement.member_id)) : null;
const teamStyle = member ? ` style="${escapeHtml(getSeatMapTeamStyle(member))}"` : "";
cells.push(`
<div class="seatmap-cell${placement ? " occupied" : ""}${editable ? " editable" : ""}" data-row="${rowIndex}" data-col="${colIndex}">
<div class="seatmap-cell${placement ? " occupied" : ""}${member ? " team-colored" : ""}${editable ? " editable" : ""}" data-row="${rowIndex}" data-col="${colIndex}"${teamStyle}>
<span class="seatmap-cell-label">${escapeHtml(computeSeatLabel(rowIndex, colIndex))}</span>
${member ? renderMemberCard(member, editable) : ""}
</div>
@@ -1069,7 +1216,7 @@ function renderSeatMapBoard() {
seatMapBoard.innerHTML = `
<div class="seatmap-canvas" style="--seatmap-rows:${rows}; --seatmap-cols:${cols}; --seatmap-gap:${gap}px;">
<img class="seatmap-image" src="${escapeHtml(seatMapState.seatMap.image_url)}" alt="${escapeHtml(seatMapState.seatMap.name)}">
<div class="seatmap-grid">${cells.join("")}</div>
<div class="seatmap-grid">${overlays.join("")}${cells.join("")}</div>
</div>
`;
}
@@ -1172,6 +1319,7 @@ function renderSeatMapActions() {
function updateSeatMapDraftUi() {
renderSeatMapActions();
renderUnassignedMembers();
renderSeatMapMemberContext(seatMapState.selectedMemberId);
syncSeatMapViewerFrame();
}
@@ -1391,6 +1539,9 @@ function handleEmbeddedNavigationMessage(event) {
updateSeatMapDraftUi();
}
}
if (data.type === "seatmap-member-selected") {
renderSeatMapMemberContext(Number(data.memberId || 0) || null);
}
}
async function fetchJson(url, options) {
@@ -1434,6 +1585,7 @@ async function loadSeatMapData(force = false) {
seatMapState.placements = clonePlacements(layoutPayload.placements || []);
seatMapState.zoom = 1;
seatMapState.hoveredSlotId = null;
seatMapState.selectedMemberId = null;
seatMapState.editMode = canEditSeatMap();
resetSeatMapDraft();
seatMapState.loaded = true;
@@ -1447,6 +1599,7 @@ async function loadSeatMapData(force = false) {
seatMapState.placements = [];
seatMapState.zoom = 1;
seatMapState.hoveredSlotId = null;
seatMapState.selectedMemberId = null;
seatMapState.editMode = canEditSeatMap();
resetSeatMapDraft();
seatMapState.loaded = true;
@@ -1623,6 +1776,7 @@ function setActiveView(view) {
const isLedger = currentView === "ledger";
const isProject = currentView === "project";
const isTeam = currentView === "team";
const isDbStatus = currentView === "db-status";
const isSeatMapAdmin = currentView === "seatmap-admin";
const isSeatMapReadonly = currentView === "seatmap-readonly";
if (ledgerStage) {
@@ -1641,6 +1795,10 @@ function setActiveView(view) {
teamStage.hidden = !isTeam;
teamStage.style.display = isTeam ? "flex" : "none";
}
if (dbStatusStage) {
dbStatusStage.hidden = !isDbStatus;
dbStatusStage.style.display = isDbStatus ? "flex" : "none";
}
if (seatMapAdminStage) {
seatMapAdminStage.hidden = !isSeatMapAdmin;
seatMapAdminStage.style.display = isSeatMapAdmin ? "flex" : "none";
@@ -1650,7 +1808,7 @@ function setActiveView(view) {
seatMapReadonlyStage.style.display = isSeatMapReadonly ? "flex" : "none";
}
if (emptyStage) {
const showEmpty = !isLedger && !isOrganization && !isProject && !isTeam && !isSeatMapAdmin && !isSeatMapReadonly;
const showEmpty = !isLedger && !isOrganization && !isProject && !isTeam && !isDbStatus && !isSeatMapAdmin && !isSeatMapReadonly;
emptyStage.hidden = !showEmpty;
emptyStage.style.display = showEmpty ? "flex" : "none";
}
@@ -1677,8 +1835,12 @@ 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();
loadSeatMapData(previousView !== currentView);
}
notifyEmbeddedTabActivated();
}
@@ -1874,14 +2036,15 @@ Object.values(seatMapDom).forEach((dom) => {
});
dom.unassigned?.addEventListener("click", (event) => {
const button = event.target.closest("[data-member-id]");
if (!button) return;
if (button.classList.contains("seatmap-member-search-card")) {
focusSeatMapMember(Number(button.dataset.memberId));
const target = event.target.closest("[data-member-id]");
if (!target) return;
if (target.classList.contains("seatmap-member-search-card")) {
focusSeatMapMember(Number(target.dataset.memberId), { showContext: false });
return;
}
renderSeatMapMemberContext(Number(target.dataset.memberId));
if (canEditSeatMap()) return;
focusSeatMapMember(Number(button.dataset.memberId));
focusSeatMapMember(Number(target.dataset.memberId));
});
dom.unassigned?.addEventListener("dragover", (event) => {
if (!seatMapState.editMode) return;
@@ -1910,6 +2073,17 @@ Object.values(seatMapDom).forEach((dom) => {
if (!fitButton) return;
fitDxfSeatMapBoard();
});
dom.board?.addEventListener("click", (event) => {
const memberCard = event.target.closest(".seatmap-member-card[data-member-id]");
if (memberCard) {
renderSeatMapMemberContext(Number(memberCard.dataset.memberId));
return;
}
const cell = event.target.closest(".seatmap-cell[data-row][data-col]");
if (!cell) return;
const placement = getCellPlacementMap().get(`${Number(cell.dataset.row)}:${Number(cell.dataset.col)}`);
renderSeatMapMemberContext(placement ? Number(placement.member_id) : null);
});
dom.board?.addEventListener("dragover", (event) => {
if (!seatMapState.editMode) return;
const target = isSlotBasedSeatMap()

View File

@@ -0,0 +1,918 @@
<!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-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
flex-wrap: wrap;
}
.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;
}
.toolbar-input {
width: min(360px, 100%);
padding: 11px 14px;
border-radius: 14px;
border: 1px solid rgba(132, 102, 54, 0.16);
background: rgba(255, 251, 244, 0.96);
color: #2f2419;
font: inherit;
}
.toolbar-input:focus {
outline: none;
border-color: rgba(129, 88, 31, 0.34);
box-shadow: 0 0 0 4px rgba(208, 176, 116, 0.16);
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.action-button {
border: 1px solid rgba(128, 98, 48, 0.14);
background: rgba(250, 240, 213, 0.62);
color: #6c4a1a;
border-radius: 999px;
padding: 9px 14px;
font: inherit;
font-size: 12px;
font-weight: 800;
cursor: pointer;
}
.action-button:hover {
background: rgba(244, 228, 186, 0.8);
}
.action-button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.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">
<div class="panel-toolbar">
<input id="table-search" class="toolbar-input" type="search" placeholder="테이블명, 설명, 화면명으로 검색" />
<div class="toolbar-actions">
<span id="table-count" class="meta-chip">0 / 0</span>
</div>
</div>
<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>
<div class="toolbar-actions">
<button id="preview-download" class="action-button" type="button" disabled>CSV 다운로드</button>
<button id="preview-close" class="modal-close" type="button" aria-label="닫기">×</button>
</div>
</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>
let allTables = [];
let currentPreview = null;
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function formatNumber(value) {
return new Intl.NumberFormat("ko-KR").format(Number(value || 0));
}
function formatDateTime(value) {
if (!value) return '<span class="muted">-</span>';
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return escapeHtml(value);
return parsed.toLocaleString("ko-KR", { hour12: false });
}
function formatBytes(value) {
const size = Number(value || 0);
if (size <= 0) return "0 B";
const units = ["B", "KB", "MB", "GB"];
let current = size;
let unit = 0;
while (current >= 1024 && unit < units.length - 1) {
current /= 1024;
unit += 1;
}
return `${current.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`;
}
function renderOverview(overview) {
const target = document.getElementById("overview");
target.innerHTML = [
["핵심 테이블", overview.visible_tables],
["전체 테이블", overview.total_tables],
["등록 인원", overview.registered_members],
["재직 인원", overview.active_members],
["고정 오피스 도면", overview.fixed_office_maps],
["현재 active 도면", overview.active_seat_maps],
["Import 배치", overview.import_batches],
["바이너리 원본", overview.binary_sources],
].map(([label, value]) => `
<article class="kpi">
<span class="kpi-label">${escapeHtml(label)}</span>
<span class="kpi-value">${formatNumber(value)}</span>
</article>
`).join("");
}
function renderTables(items) {
const target = document.getElementById("table-body");
const countTarget = document.getElementById("table-count");
if (countTarget) {
countTarget.textContent = `${formatNumber(items.length)} / ${formatNumber(allTables.length)}`;
}
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) {
currentPreview = payload;
const previewModal = document.getElementById("preview-modal");
const downloadButton = document.getElementById("preview-download");
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)}개 표시`;
if (downloadButton) {
downloadButton.disabled = !(payload.rows && payload.rows.length);
}
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();
allTables = payload.tables || [];
document.getElementById("generated-at").textContent = payload.generated_at
? `갱신 ${formatDateTime(payload.generated_at)}`
: "갱신 시각 없음";
renderOverview(payload.overview || {});
renderTables(allTables);
renderBatches(payload.import_batches || []);
renderBinarySources(payload.binary_sources || []);
renderNotes(payload.notes || []);
renderGroupSummary(payload.group_summary || {});
renderProductSummary(payload.product_summary || {});
renderScreenMap(payload.screen_map || []);
}
function toCsvValue(value) {
const text = String(value ?? "");
if (!/[",\n]/.test(text)) return text;
return `"${text.replaceAll('"', '""')}"`;
}
function downloadPreviewCsv() {
if (!currentPreview || !currentPreview.columns || !currentPreview.rows || !currentPreview.rows.length) return;
const headers = currentPreview.columns.map((column) => column.name);
const lines = [
headers.map(toCsvValue).join(","),
...currentPreview.rows.map((row) => headers.map((header) => toCsvValue(row[header] ?? "")).join(",")),
];
const blob = new Blob(["\ufeff" + lines.join("\n")], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${currentPreview.table_name || "table-preview"}.csv`;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}
function applyTableSearch(query) {
const normalized = String(query || "").trim().toLowerCase();
if (!normalized) {
renderTables(allTables);
return;
}
const filtered = allTables.filter((item) => {
const haystack = [
item.label,
item.table_ref,
item.description,
...(item.related_views || []),
item.domain,
item.group,
].join(" ").toLowerCase();
return haystack.includes(normalized);
});
renderTables(filtered);
}
document.getElementById("preview-close").addEventListener("click", () => {
const modal = document.getElementById("preview-modal");
modal.classList.remove("open");
modal.setAttribute("aria-hidden", "true");
});
document.getElementById("preview-download").addEventListener("click", downloadPreviewCsv);
document.getElementById("table-search").addEventListener("input", (event) => {
applyTableSearch(event.target.value);
});
document.getElementById("preview-modal").addEventListener("click", (event) => {
if (event.target.id !== "preview-modal") return;
const modal = document.getElementById("preview-modal");
modal.classList.remove("open");
modal.setAttribute("aria-hidden", "true");
});
bootstrap().catch((error) => {
document.getElementById("table-body").innerHTML = `<tr><td colspan="5" class="empty">${escapeHtml(error.message || "DB 상태를 불러오지 못했습니다.")}</td></tr>`;
document.getElementById("batch-body").innerHTML = '<tr><td colspan="3" class="empty">배치 정보를 불러오지 못했습니다.</td></tr>';
document.getElementById("binary-body").innerHTML = '<tr><td colspan="3" class="empty">바이너리 원본 정보를 불러오지 못했습니다.</td></tr>';
});
</script>
</body>
</html>

View File

@@ -16,7 +16,7 @@
<link rel="stylesheet" href="/design-patterns.css?v=20260401-01">
<link rel="stylesheet" href="/legacy/static/common.css">
<!-- Keep login and common hub defaults aligned with 8080. -->
<link rel="stylesheet" href="/styles.css?v=20260330-01">
<link rel="stylesheet" href="/styles.css?v=20260402-01">
<!-- 8081-only hub overrides must not restyle the login screen. -->
<link rel="stylesheet" href="/styles-8081-design.css?v=20260401-01">
</head>
@@ -79,6 +79,7 @@
<button class="nav-pill" type="button" data-view="project">프로젝트별 분석</button>
<button class="nav-pill" type="button" data-view="team">팀/개인별 분석</button>
<button class="nav-pill active" type="button" data-view="organization">조직 현황</button>
<button class="nav-pill" type="button" data-view="db-status">DB 상태</button>
</div>
<div class="header-actions">
@@ -104,7 +105,7 @@
<section id="organization-stage" class="main-stage">
<div class="stage-frame">
<!-- Legacy organization keeps its own CSS/JS responsibility under /legacy/static. -->
<iframe id="organization-frame" src="/legacy/organization?v=20260330-02" data-src="/legacy/organization?v=20260330-02" title="조직도 메인 화면"></iframe>
<iframe id="organization-frame" src="/legacy/organization?v=20260402-02" data-src="/legacy/organization?v=20260402-02" title="조직도 메인 화면"></iframe>
</div>
</section>
<section id="project-stage" class="main-stage" hidden>
@@ -119,6 +120,11 @@
<iframe id="team-frame" src="/integrations/mh" data-src="/integrations/mh" title="팀/개인별 분석 화면"></iframe>
</div>
</section>
<section id="db-status-stage" class="main-stage" hidden>
<div class="stage-frame">
<iframe id="db-status-frame" src="/db-status.html?v=20260401-02" data-src="/db-status.html?v=20260401-02" title="DB 상태 화면"></iframe>
</div>
</section>
<section id="seatmap-admin-stage" class="main-stage" hidden>
<div class="seatmap-layout">
<div class="seatmap-topbar">
@@ -175,6 +181,7 @@
<span class="hidden">구성원 검색</span>
<input id="seatmap-admin-search" type="search" placeholder="이름 또는 부서 검색">
</label>
<div id="seatmap-admin-context" class="seatmap-context-panel hidden"></div>
<div id="seatmap-admin-unassigned" class="seatmap-member-list"></div>
</section>
</aside>
@@ -214,6 +221,7 @@
<span class="hidden">구성원 검색</span>
<input id="seatmap-readonly-search" type="search" placeholder="이름 또는 부서 검색">
</label>
<div id="seatmap-readonly-context" class="seatmap-context-panel hidden"></div>
<div id="seatmap-readonly-unassigned" class="seatmap-member-list"></div>
</section>
</aside>

View File

@@ -344,6 +344,12 @@ body {
padding-right: 8px;
}
.header-date-field select option {
background: var(--color-surface);
color: var(--color-text);
font-weight: 700;
}
.header-date-sep {
color: var(--color-text-muted);
font-size: 12px;
@@ -913,6 +919,39 @@ body {
padding: var(--seatmap-gap);
}
.seatmap-team-overlay {
pointer-events: none;
align-self: stretch;
justify-self: stretch;
border-radius: 20px;
border: 2px solid color-mix(in srgb, var(--seatmap-team-accent, rgba(13, 148, 136, 0.3)) 62%, white);
background: linear-gradient(
180deg,
color-mix(in srgb, var(--seatmap-team-soft, rgba(13, 148, 136, 0.12)) 100%, transparent),
color-mix(in srgb, var(--seatmap-team-soft, rgba(13, 148, 136, 0.08)) 90%, transparent)
);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.22), 0 8px 18px rgba(16, 37, 29, 0.08);
margin: 4px;
z-index: 0;
}
.seatmap-team-overlay::before {
content: attr(data-team-label);
position: absolute;
top: 10px;
left: 12px;
display: inline-flex;
align-items: center;
min-height: 26px;
padding: 0 11px;
border-radius: 999px;
background: color-mix(in srgb, var(--seatmap-team-soft, rgba(13, 148, 136, 0.14)) 84%, white);
color: color-mix(in srgb, var(--seatmap-team-accent, #0f766e) 86%, #10251d);
font-size: 11px;
font-weight: 900;
letter-spacing: 0.02em;
}
.seatmap-cell {
position: relative;
min-width: 0;
@@ -920,6 +959,7 @@ body {
border: 1px dashed rgba(15, 23, 42, 0.14);
background: rgba(255, 255, 255, 0.12);
transition: border-color 0.18s ease, background 0.18s ease;
z-index: 1;
}
.seatmap-cell.editable:hover {
@@ -931,6 +971,13 @@ body {
background: rgba(255, 255, 255, 0.18);
}
.seatmap-cell.occupied.team-colored {
border-color: color-mix(in srgb, var(--seatmap-team-accent, rgba(15, 118, 110, 0.72)) 62%, white);
background:
linear-gradient(180deg, color-mix(in srgb, var(--seatmap-team-soft, rgba(15, 118, 110, 0.14)) 86%, transparent), rgba(255, 255, 255, 0.14)),
rgba(255, 255, 255, 0.18);
}
.seatmap-cell-label {
position: absolute;
top: 4px;
@@ -959,6 +1006,12 @@ body {
overflow: hidden;
}
.seatmap-member-card.team-colored {
border-color: color-mix(in srgb, var(--seatmap-team-accent, rgba(245, 158, 11, 0.95)) 42%, rgba(255,255,255,0.4));
background:
linear-gradient(180deg, color-mix(in srgb, var(--seatmap-team-soft, rgba(245, 158, 11, 0.18)) 68%, rgba(15, 23, 42, 0.84)) 0%, rgba(15, 23, 42, 0.84) 100%);
}
.seatmap-member-card.draggable {
cursor: grab;
}
@@ -1008,11 +1061,26 @@ body {
}
.seatmap-member-text em {
color: rgba(226, 232, 240, 0.84);
color: rgba(255, 247, 237, 0.96);
font-size: 10px;
font-weight: 800;
font-style: normal;
}
.seatmap-team-chip {
display: inline-flex;
align-items: center;
max-width: 100%;
padding: 2px 6px;
border-radius: 999px;
background: color-mix(in srgb, var(--seatmap-team-soft, rgba(245, 158, 11, 0.18)) 88%, white);
color: color-mix(in srgb, var(--seatmap-team-accent, #b45309) 85%, #10251d);
font-size: 10px;
font-weight: 900;
line-height: 1;
letter-spacing: 0.01em;
}
.seatmap-sidebar {
height: 100%;
min-height: 0;
@@ -1195,6 +1263,11 @@ body {
text-align: left;
}
.seatmap-member-search-card.team-colored {
border-color: color-mix(in srgb, var(--seatmap-team-accent, rgba(245, 158, 11, 0.24)) 34%, rgba(226, 232, 240, 0.9));
background: linear-gradient(180deg, color-mix(in srgb, var(--seatmap-team-soft, rgba(245, 158, 11, 0.1)) 78%, white), #fff);
}
.seatmap-member-badge {
flex: 0 0 auto;
display: inline-flex;
@@ -1219,8 +1292,8 @@ body {
min-height: 42px;
padding: 10px 12px;
border-radius: 12px;
background: #3f4658;
border: 1px solid rgba(148, 163, 184, 0.14);
background: transparent;
border: 1px solid rgba(194, 170, 134, 0.34);
box-shadow: none;
}
@@ -1232,12 +1305,14 @@ body {
.seatmap-member-text-inline strong {
font-size: 13px;
color: #10251d;
}
.seatmap-member-text-inline em {
display: inline;
color: rgba(226, 232, 240, 0.8);
color: #6f5b3e;
font-size: 11px;
font-weight: 800;
}
.seatmap-slot .seatmap-member-card {
@@ -1266,6 +1341,81 @@ body {
display: none;
}
.seatmap-context-panel {
display: grid;
gap: 10px;
padding: 14px;
border: 1px solid rgba(194, 170, 134, 0.28);
border-radius: 18px;
background: linear-gradient(180deg, rgba(255, 252, 247, 0.96), rgba(246, 239, 227, 0.92));
box-shadow: 0 14px 28px rgba(16, 37, 29, 0.08);
}
.seatmap-context-panel.hidden {
display: none;
}
.seatmap-context-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.seatmap-context-title {
display: grid;
gap: 4px;
}
.seatmap-context-title strong {
color: #10251d;
font-size: 14px;
font-weight: 900;
}
.seatmap-context-title span {
color: #6b7280;
font-size: 11px;
font-weight: 700;
}
.seatmap-context-badge {
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 0 10px;
border-radius: 999px;
background: color-mix(in srgb, var(--seatmap-team-soft, rgba(245, 158, 11, 0.14)) 88%, white);
color: color-mix(in srgb, var(--seatmap-team-accent, #b45309) 84%, #10251d);
font-size: 11px;
font-weight: 900;
}
.seatmap-context-tree {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.seatmap-context-node {
display: inline-flex;
align-items: center;
min-height: 30px;
padding: 0 11px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.84);
border: 1px solid rgba(194, 170, 134, 0.28);
color: #30423a;
font-size: 11px;
font-weight: 800;
}
.seatmap-context-empty {
color: #7b7b6c;
font-size: 12px;
line-height: 1.5;
}
.seatmap-dxf-stage {
cursor: grab;
}

View File

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

View File

@@ -1,9 +1,21 @@
# Reference Assets
이 디렉터리는 앞으로 `8081`에서 직접 서빙하지 않는 참고 원본/복구 비교 자산을 모으기 위한 공간이다.
이 디렉터리는 `8081`에서 직접 서빙하지 않는 참고 원본/복구 비교 자산을 모으 공간이다.
1차 정리에서는 위험한 대량 이동을 피하기 위해 기존 참고 파일을 즉시 옮기지 않는다.
대신 실제 서빙 파일은 `incoming-files/served/`로 고정하고, 다음 차수에서 참고 자산을 단계적으로 재배치한다.
`#21` 2차부터 실제 reference 재배치를 시작했다.
현재 포함:
- `ledger/`
- 사업관리대장 원본 wrapper/html/css/xlsx
- 이전 override 복사본
- 중첩 백업 디렉터리
규칙:
- runtime은 이 디렉터리를 직접 서빙하지 않는다.
- 실제 서비스 수정은 `incoming-files/served/` 기준으로 먼저 반영한다.
- reference는 비교, 복구, 출처 확인이 필요할 때만 본다.
예상 대상:

View File

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

View File

@@ -0,0 +1,328 @@
html,
body {
margin: 0;
padding: 0;
}
body.mh-business-theme {
overflow-x: hidden;
background:
radial-gradient(circle at top left, rgba(214, 138, 58, 0.16), transparent 24%),
radial-gradient(circle at top right, rgba(47, 153, 115, 0.10), transparent 20%),
linear-gradient(180deg, #f6efe6 0%, #f1eadf 100%);
}
body.mh-business-theme .wrap {
width: min(100%, 2000px);
max-width: 2000px;
margin: 0 auto;
padding: 18px 18px 26px;
box-sizing: border-box;
}
body.mh-business-theme .top,
body.mh-business-theme .status {
display: none !important;
}
body.mh-business-theme .cards {
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
gap: 14px;
margin: 0 0 16px;
}
body.mh-business-theme .business-shell {
width: 100%;
box-sizing: border-box;
margin-top: 2px;
padding: 18px;
border-radius: 32px;
background:
radial-gradient(circle at 16% 14%, rgba(255,255,255,0.05), transparent 18%),
radial-gradient(circle at 88% 8%, rgba(255,255,255,0.04), transparent 16%),
linear-gradient(145deg, #0b352b 0%, #174e41 52%, #245f50 100%);
box-shadow: 0 26px 54px rgba(15, 58, 47, 0.16);
border: 1px solid rgba(255,255,255,0.08);
}
body.mh-business-theme .cards-toolbar {
grid-column: 1 / -1;
display: flex;
flex-direction: column;
gap: 14px;
padding: 10px 0 2px;
}
body.mh-business-theme .cards-toolbar-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
body.mh-business-theme .cards-toolbar-search {
margin-left: auto;
display: flex;
align-items: center;
min-width: min(360px, 100%);
flex: 1 1 320px;
max-width: 520px;
}
body.mh-business-theme .cards-toolbar-search .search {
width: 100%;
min-width: 0;
border-radius: 999px;
border: 1px solid rgba(255,255,255,0.12);
background: rgba(255,255,255,0.10);
color: #f4efe6;
padding: 14px 18px;
font-size: 14px;
font-weight: 800;
box-shadow: inset 0 1px 0 rgba(255,255,255,0.04);
}
body.mh-business-theme .cards-toolbar-search .search::placeholder {
color: rgba(244, 239, 230, 0.74);
}
body.mh-business-theme #btnUpload {
display: none !important;
}
body.mh-business-theme .cards-toolbar-metrics {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
}
body.mh-business-theme .summary-year-chip {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 60px;
padding: 10px 16px;
border-radius: 999px;
border: 1px solid rgba(255,255,255,0.14);
background: rgba(255,255,255,0.08);
color: #f4efe6;
font-size: 12px;
font-weight: 900;
cursor: pointer;
}
body.mh-business-theme .summary-year-chip.active {
background: linear-gradient(180deg, #fff8ee 0%, #f2dec0 100%);
color: #0a2a22;
border-color: rgba(242, 196, 132, 0.58);
box-shadow: 0 12px 28px rgba(10, 42, 34, 0.18);
}
body.mh-business-theme .summary-filter-chip {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
width: 100%;
min-height: 98px;
padding: 18px 22px;
border-radius: 999px;
border: 1px solid rgba(255,255,255,0.14);
background: linear-gradient(180deg, rgba(255,255,255,0.10) 0%, rgba(255,255,255,0.07) 100%);
color: #f4efe6;
box-shadow: inset 0 1px 0 rgba(255,255,255,0.04), 0 16px 30px rgba(7, 28, 22, 0.14);
cursor: pointer;
text-align: center;
}
body.mh-business-theme .summary-filter-chip.active {
background: linear-gradient(180deg, #fff8ee 0%, #f2dec0 100%);
color: #0a2a22;
border-color: rgba(242, 196, 132, 0.58);
}
body.mh-business-theme .summary-filter-chip .label {
color: rgba(244, 239, 230, 0.78);
font-size: 13px;
font-weight: 900;
}
body.mh-business-theme .summary-filter-chip.active .label {
color: rgba(10, 42, 34, 0.78);
}
body.mh-business-theme .summary-filter-chip .count {
color: #fff7e6;
font-size: 32px;
line-height: 1;
font-weight: 900;
}
body.mh-business-theme .summary-filter-chip.active .count {
color: #b86b1f;
}
body.mh-business-theme .summary-filter-chip .meta {
color: #f2c484;
font-size: 11px;
font-weight: 800;
text-align: center;
}
body.mh-business-theme .summary-filter-chip.active .meta {
color: #7c5a20;
}
body.mh-business-theme .card {
grid-column: span 2;
min-height: 110px;
border-radius: 24px;
border: 1px solid rgba(217, 197, 168, 0.55);
background: linear-gradient(180deg, rgba(255,250,243,0.96) 0%, rgba(248,242,232,0.96) 100%);
padding: 18px 20px;
box-shadow: 0 18px 32px rgba(15, 58, 47, 0.08);
}
body.mh-business-theme .card.management {
grid-column: span 2;
}
body.mh-business-theme .card .k {
color: #5b6d63;
font-size: 12px;
font-weight: 900;
}
body.mh-business-theme .card .v {
margin-top: 8px;
color: #17392f;
font-size: 30px;
font-weight: 900;
}
body.mh-business-theme .card .n {
margin-top: 8px;
color: #7b6953;
font-size: 11px;
font-weight: 700;
}
body.mh-business-theme .panel {
border-radius: 28px;
border: 1px solid rgba(217, 197, 168, 0.55);
box-shadow: 0 18px 32px rgba(15, 58, 47, 0.08);
}
body.mh-business-theme .table-wrap {
width: 100%;
max-width: 100%;
border-radius: 28px;
overflow-x: hidden !important;
}
body.mh-business-theme .table-vat-note {
display: none !important;
}
body.mh-business-theme table {
width: 100% !important;
min-width: 0 !important;
table-layout: fixed;
background: rgba(255, 250, 243, 0.96);
}
body.mh-business-theme thead th {
background: #0f352b;
color: #fff5e6;
border-right: 1px solid rgba(242, 196, 132, 0.2);
}
body.mh-business-theme tbody td {
background: rgba(255, 250, 243, 0.96);
}
body.mh-business-theme .group-row td {
padding: 12px 14px 10px;
background: linear-gradient(180deg, rgba(255, 248, 238, 0.98) 0%, rgba(242, 222, 192, 0.78) 100%);
border-top: 1px solid rgba(214, 138, 58, 0.26);
border-bottom: 1px solid rgba(217, 197, 168, 0.54);
}
body.mh-business-theme .group-chip {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
border-radius: 999px;
background: rgba(255, 250, 243, 0.98);
border: 1px solid rgba(214, 138, 58, 0.3);
color: #17392f;
font-size: 12px;
font-weight: 900;
box-shadow: 0 8px 18px rgba(15, 58, 47, 0.08);
cursor: pointer;
}
body.mh-business-theme .group-chip .group-toggle {
margin-left: 4px;
width: 22px;
height: 22px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
background: rgba(242, 196, 132, 0.18);
color: #b66e22;
font-size: 14px;
line-height: 1;
}
body.mh-business-theme .project-link {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0;
border: 0;
background: none;
color: #17392f;
font: inherit;
font-weight: 900;
text-align: left;
cursor: pointer;
}
body.mh-business-theme .project-link:hover {
color: #0f6a55;
}
@media (max-width: 1280px) {
body.mh-business-theme .cards-toolbar-metrics {
grid-template-columns: 1fr;
}
body.mh-business-theme .card {
grid-column: span 4;
}
}
@media (max-width: 880px) {
body.mh-business-theme .wrap {
padding: 12px 12px 20px;
}
body.mh-business-theme .cards {
grid-template-columns: 1fr;
}
body.mh-business-theme .card {
grid-column: auto;
}
body.mh-business-theme .cards-toolbar-search {
margin-left: 0;
max-width: none;
flex-basis: 100%;
}
}

View File

@@ -0,0 +1,498 @@
(function () {
window.__mhLedgerEnhancementLoaded = false;
if (typeof S === "undefined" || typeof E === "undefined" || typeof render !== "function") return;
window.__mhLedgerEnhancementLoaded = true;
if (!S.dashboard) S.dashboard = { year: "", section: "active" };
if (!S.collapsedGroups) S.collapsedGroups = {};
function bgToday() {
var now = new Date();
return new Date(now.getFullYear(), now.getMonth(), now.getDate());
}
function bgParseDate(value) {
var text = String(value || "").trim();
if (!text) return null;
var match = text.match(/(20\d{2})\D?(\d{1,2})\D?(\d{1,2})/);
if (match) {
var parsed = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
return isNaN(parsed.getTime()) ? null : parsed;
}
var fallback = new Date(text);
if (isNaN(fallback.getTime())) return null;
return new Date(fallback.getFullYear(), fallback.getMonth(), fallback.getDate());
}
function bgYearFromText(value) {
var match = String(value || "").trim().match(/(20\d{2})/);
return match ? match[1] : "";
}
function bgStartYear(row) {
return bgYearFromText(row && row.sDate);
}
function bgEndYear(row) {
return bgYearFromText(row && row.eDate);
}
function bgDisplayYear(row) {
var start = bgStartYear(row);
if (start) return start;
var contractMatch = String((row && row.cDate) || "").trim().match(/(20\d{2})/);
if (contractMatch) return contractMatch[1];
var nameMatch = String((row && row.name) || "").trim().match(/^(20\d{2})/);
if (nameMatch) return nameMatch[1];
return bgEndYear(row) || "미지정";
}
function bgCompletionYear(row) {
return bgEndYear(row) || bgDisplayYear(row);
}
function bgDateOrYearStart(row) {
var yearText = bgDisplayYear(row);
return bgParseDate(row && row.sDate) || bgParseDate(row && row.cDate) || (/^20\d{2}$/.test(yearText) ? new Date(Number(yearText), 0, 1) : null);
}
function bgDateOrYearEnd(row) {
var completionYear = bgCompletionYear(row);
return bgParseDate(row && row.eDate) || (/^20\d{2}$/.test(completionYear) ? new Date(Number(completionYear), 11, 31) : null);
}
function bgYearCutoff(year) {
var targetYear = Number(year || 0);
if (!targetYear) return null;
var today = bgToday();
if (targetYear < today.getFullYear()) return new Date(targetYear, 11, 31);
if (targetYear === today.getFullYear()) return today;
return null;
}
function bgYearStartDate(year) {
var targetYear = Number(year || 0);
return targetYear ? new Date(targetYear, 0, 1) : null;
}
function bgActiveInYear(row, year) {
var cutoff = bgYearCutoff(year);
var yearStart = bgYearStartDate(year);
var startDate = bgDateOrYearStart(row);
var endDate = bgDateOrYearEnd(row);
if (!(cutoff && yearStart && startDate)) return false;
if (startDate > cutoff) return false;
if (endDate && endDate < yearStart) return false;
return !(endDate && endDate <= cutoff);
}
function bgStartedInYear(row, year) {
var cutoff = bgYearCutoff(year);
var startDate = bgDateOrYearStart(row);
if (!(cutoff && startDate)) return false;
return startDate.getFullYear() === Number(year || 0) && startDate <= cutoff;
}
function bgCompletedInYear(row, year) {
var cutoff = bgYearCutoff(year);
var endDate = bgDateOrYearEnd(row);
if (!(cutoff && endDate)) return false;
return endDate.getFullYear() === Number(year || 0) && endDate <= cutoff;
}
function bgYearRange(row) {
var years = [];
var startYear = Number(bgDisplayYear(row) || 0);
var endYear = Number(bgCompletionYear(row) || 0);
if (startYear && endYear && endYear >= startYear) {
for (var year = startYear; year <= endYear; year += 1) years.push(String(year));
} else if (startYear) {
years.push(String(startYear));
}
return years;
}
function bgYears(rows) {
var currentYear = new Date().getFullYear();
var years = Array.from(new Set((Array.isArray(rows) ? rows : []).flatMap(bgYearRange).filter(function (year) {
return /^20\d{2}$/.test(year);
}))).sort(function (a, b) {
return Number(b) - Number(a);
});
years = years.filter(function (year) {
var numericYear = Number(year);
return numericYear >= 2018 && numericYear <= currentYear;
});
return years.length ? years : [String(currentYear)];
}
function bgEnsureYear(rows) {
var years = bgYears(rows);
if (!years.includes(S.dashboard.year)) S.dashboard.year = years[0];
return years;
}
function bgTotals(targetRows) {
return (Array.isArray(targetRows) ? targetRows : []).reduce(function (acc, row) {
acc.c += Number((row && row.cSup) || 0);
acc.col += Number((row && row.col) || 0);
acc.recv += Number((row && row.recv) || 0);
return acc;
}, { c: 0, col: 0, recv: 0 });
}
function isSupportServiceRow(row) {
var category = String((row && row.cat) || "").trim();
return category.indexOf("경영지원") >= 0 || category.indexOf("서비스") >= 0;
}
function isBaronProjectRow(row) {
var category = String((row && row.cat) || "").trim();
if (category.indexOf("바론") < 0) return false;
if (isSupportServiceRow(row)) return false;
return true;
}
function bgSummarize(rows, selectedYear) {
var items = Array.isArray(rows) ? rows : [];
var targetYear = selectedYear || bgEnsureYear(items)[0];
var activeRows = items.filter(function (row) { return bgActiveInYear(row, targetYear); });
var newProjectRows = items.filter(function (row) { return bgStartedInYear(row, targetYear); });
var completedRows = items.filter(function (row) { return bgCompletedInYear(row, targetYear); });
var managementRows = newProjectRows.filter(isSupportServiceRow);
return {
targetYear: targetYear,
activeRows: activeRows,
newProjectRows: newProjectRows,
completedRows: completedRows,
managementRows: managementRows,
managementTotals: bgTotals(managementRows)
};
}
function bgMatches(row) {
var section = S.dashboard.section || "active";
var selectedYear = S.dashboard.year || bgEnsureYear(S.all)[0];
if (section === "new") return bgStartedInYear(row, selectedYear);
if (section === "completed") return bgCompletedInYear(row, selectedYear);
return bgActiveInYear(row, selectedYear);
}
function normalizeStatusLabel(status) {
var value = String(status || "").trim();
if (!value) return "-";
if (value.indexOf("진행") >= 0) return "과업 진행중";
return value;
}
function formatSplitPercent(split) {
var numeric = parseFloat(String(split || "").replace(/[^0-9.\-]/g, ""));
if (!Number.isFinite(numeric) || numeric === 0) return "분담율 -%";
return "분담율 " + numeric.toFixed(2) + "%";
}
function projectYear(row) {
var start = String((row && row.sDate) || "").trim();
var startMatch = start.match(/(20\d{2})/);
if (startMatch) return startMatch[1];
var name = String((row && row.name) || "").trim();
var nameMatch = name.match(/^(20\d{2})/);
if (nameMatch) return nameMatch[1];
var end = String((row && row.eDate) || "").trim();
var endMatch = end.match(/(20\d{2})/);
if (endMatch) return endMatch[1];
return "미지정";
}
function groupSortRank(row) {
var selectedYear = Number((S.dashboard && S.dashboard.year) || projectYear(row) || 0);
var startYear = Number(projectYear(row) || 0);
if (typeof bgCompletedInYear === "function" && bgCompletedInYear(row, String(selectedYear))) return 9999;
if (!startYear) return 9998;
return startYear;
}
function tableGroupLabel(row) {
var startYear = projectYear(row);
if (/^20\d{2}$/.test(startYear)) return startYear + "년";
return "미지정";
}
function renderLedgerTable() {
var table = document.querySelector(".panel table");
if (!table || !E.tbody) return;
var thead = table.querySelector("thead");
if (thead) {
thead.innerHTML = '<tr>'
+ '<th><div class="th-head"><button type="button" class="th-trigger" data-filter="cat" data-label="구분"><span class="th-title">구분</span><span class="th-mark"></span><span class="th-caret">▼</span></button><div id="filterCatMenu" class="th-menu" data-filter="cat"></div></div></th>'
+ '<th><div class="th-head"><button type="button" class="th-trigger" data-filter="code" data-label="사업코드"><span class="th-title">사업코드</span><span class="th-mark"></span><span class="th-caret">▼</span></button><div id="filterCodeMenu" class="th-menu" data-filter="code"></div></div></th>'
+ '<th><div class="th-head"><button type="button" class="th-trigger" data-filter="name" data-label="사업명(계약명)"><span class="th-title">사업명(계약명)</span><span class="th-mark"></span><span class="th-caret">▼</span></button><div id="filterNameMenu" class="th-menu" data-filter="name"></div></div></th>'
+ '<th><div class="th-head"><button type="button" class="th-trigger" data-filter="client" data-label="발주처(계약처)"><span class="th-title">발주처(계약처)</span><span class="th-mark"></span><span class="th-caret">▼</span></button><div id="filterClientMenu" class="th-menu" data-filter="client"></div></div></th>'
+ '<th><div class="th-head"><button type="button" class="th-trigger" data-filter="order" data-label="발주방법"><span class="th-title">발주방법</span><span class="th-mark"></span><span class="th-caret">▼</span></button><div id="filterOrderMenu" class="th-menu" data-filter="order"></div></div></th>'
+ '<th><div class="th-head"><button type="button" class="th-trigger" data-filter="status" data-label="진행상태"><span class="th-title">진행상태</span><span class="th-mark"></span><span class="th-caret">▼</span></button><div id="filterStatusMenu" class="th-menu" data-filter="status"></div></div></th>'
+ '<th class="num"><div class="th-head end"><button type="button" class="th-trigger" data-filter="amount" data-label="계약금"><span class="th-title">계약금</span><span class="th-mark"></span><span class="th-caret">▼</span></button><div id="filterAmountMenu" class="th-menu" data-filter="amount"></div></div></th>'
+ '<th class="num"><div class="th-head end"><button type="button" class="th-trigger" data-filter="outsource" data-label="외주비"><span class="th-title">외주비</span><span class="th-mark"></span><span class="th-caret">▼</span></button><div id="filterOutsourceMenu" class="th-menu" data-filter="outsource"></div></div></th>'
+ '<th class="num"><div class="th-head end"><button type="button" class="th-trigger" data-filter="receivable" data-label="미수금"><span class="th-title">미수금</span><span class="th-mark"></span><span class="th-caret">▼</span></button><div id="filterReceivableMenu" class="th-menu" data-filter="receivable"></div></div></th>'
+ '<th class="num"><div class="th-head end"><button type="button" class="th-trigger" data-filter="collected" data-label="수금액"><span class="th-title">수금액</span><span class="th-mark"></span><span class="th-caret">▼</span></button><div id="filterCollectedMenu" class="th-menu" data-filter="collected"></div></div></th>'
+ '<th class="num"><div class="th-head end"><button type="button" class="th-trigger" data-filter="rate" data-label="수금률"><span class="th-title">수금률</span><span class="th-mark"></span><span class="th-caret">▼</span></button><div id="filterRateMenu" class="th-menu" data-filter="rate"></div></div></th>'
+ "</tr>";
}
var rows = (Array.isArray(S.viewRows) ? S.viewRows : []).slice().sort(function (a, b) {
var ar = groupSortRank(a);
var br = groupSortRank(b);
if (ar !== br) return ar - br;
return Number(b.recv || 0) - Number(a.recv || 0);
});
S.viewRows = rows;
var lastGroupLabel = "";
E.tbody.innerHTML = rows.map(function (r) {
var groupLabel = tableGroupLabel(r);
var isCollapsed = !!S.collapsedGroups[groupLabel];
var groupRow = "";
if (groupLabel !== lastGroupLabel) {
groupRow = '<tr class="group-row"><td colspan="11"><button type="button" class="group-chip" data-group-label="' + escAttr(groupLabel) + '"><span>' + esc(groupLabel) + '</span><span class="group-toggle" aria-hidden="true">' + (isCollapsed ? "" : "") + "</span></button></td></tr>";
lastGroupLabel = groupLabel;
}
if (isCollapsed) return groupRow;
return groupRow + '<tr class="' + (isSettledRow(r) ? 'settled' : '') + '">'
+ '<td><div class="badge ' + esc(String(r.cat || "").indexOf("바론") >= 0 ? 'badge-baron' : 'badge-family') + '">' + esc(r.cat || "-") + '</div></td>'
+ '<td><div class="subline" style="margin-top:0;font-size:12px;color:#66756d">' + esc(r.code || "-") + '</div></td>'
+ '<td><button type="button" class="project-link" data-project-key="' + escAttr(String(r.code || "") + "|" + String(r.name || "")) + '">' + esc(r.name || "-") + '</button><div class="subline">' + esc(r.periodText || "-") + '</div></td>'
+ '<td><div class="client-main">' + esc((r.client || "").trim() || "-") + '</div><div class="subline">' + esc(formatSplitPercent(r.split)) + '</div></td>'
+ '<td><div>' + esc(r.order || "-") + '</div></td>'
+ '<td><div class="badge ' + (String(r.status || "").indexOf("완료") >= 0 ? 'ok' : '') + '">' + esc(normalizeStatusLabel(r.status)) + '</div></td>'
+ '<td class="num"><strong>' + esc(won(r.cSup || 0)) + '</strong></td>'
+ '<td class="num"><strong>' + esc(r.outsourceCost ? won(r.outsourceCost) : "-") + '</strong></td>'
+ '<td class="num"><strong>' + esc(won(r.recv || 0)) + '</strong></td>'
+ '<td class="num"><strong>' + esc(won(r.col || 0)) + '</strong></td>'
+ '<td class="num"><strong style="color:' + (isSettledRow(r) ? '#b7aa93' : '#1a5645') + '">' + esc((Number(r.rate || 0)).toFixed(2) + "%") + '</strong></td>'
+ '</tr>';
}).join("");
}
function renderCollectionBoard(r) {
var payments = Array.isArray(r.payments) && r.payments.length ? r.payments : [{
pay: r.pay || "-",
issueDate: r.issueDate || "",
collectDate: r.collectDateSummary || r.colDate || "",
collected: r.col || 0,
receivable: r.recv || Math.max(0, Number(r.sTot || 0) - Number(r.col || 0)),
note: r.note || "",
status: r.status || ""
}];
return '<div class="ledger-block collect"><div class="ledger-head"><div class="ledger-head-left"><div class="ledger-icon">C</div><div><div class="ledger-name">수금 및 기성 현황</div><div class="ledger-sub">기성 차수별 세금계산서 발행 및 수금 내역</div></div></div><div class="ledger-pill">총 수금 ' + esc(won(r.col || 0)) + '</div></div><div class="ledger-table-wrap"><table class="ledger-table"><thead><tr><th>기성 차수</th><th>세금계산서 발행일</th><th>수금일</th><th style="text-align:right">수금금액</th><th style="text-align:right">미수금액</th><th>비고</th></tr></thead><tbody>'
+ payments.map(function (payment, index) {
var noteParts = [];
if (payment.status) noteParts.push(payment.status);
if (payment.note) noteParts.push(payment.note);
return '<tr><td><span class="ledger-main">' + esc((index + 1) + "차") + '</span><span class="ledger-muted">' + esc(payment.pay || "-") + '</span></td><td><span class="ledger-main">' + esc(payment.issueDate ? d(payment.issueDate) : "-") + '</span></td><td><span class="ledger-main">' + esc(payment.collectDate ? d(payment.collectDate) : "-") + '</span></td><td class="ledger-amount">' + esc(won(payment.collected || 0)) + '</td><td class="ledger-amount" style="color:#a94832">' + esc(won(payment.receivable || 0)) + '</td><td><span class="ledger-note">' + esc(noteParts.join(" / ") || "-") + '</span></td></tr>';
}).join("")
+ "</tbody></table></div></div>";
}
function renderContactCard(label, name, company, department, phone, email) {
var hasValue = [name, company, department, phone, email].some(function (value) {
return String(value || "").trim() !== "";
});
if (!hasValue) {
return '<div class="inline-card"><div class="kvk">' + esc(label) + '</div><div class="summary-note">등록된 담당자 정보가 없습니다.</div></div>';
}
return '<div class="inline-card"><div class="kvk">' + esc(label) + '</div><div class="project-meta-grid">'
+ '<div class="kv"><div class="kvk">이름</div><div class="kvv">' + esc(name || "-") + '</div></div>'
+ '<div class="kv"><div class="kvk">소속</div><div class="kvv">' + esc(company || "-") + '</div><div class="summary-note">' + esc(department || "-") + '</div></div>'
+ '<div class="kv"><div class="kvk">연락처</div><div class="kvv">' + esc(phone || "-") + '</div></div>'
+ '<div class="kv"><div class="kvk">이메일</div><div class="kvv">' + esc(email || "-") + '</div></div>'
+ "</div></div>";
}
function renderProjectInline(r) {
var payments = Array.isArray(r.payments) ? r.payments : [];
var latestCollect = d(r.collectDateSummary || r.colDate);
var hasOutsource = (Array.isArray(r.outsourceItems) && r.outsourceItems.length > 0) || Number(r.outsourceCost || 0) > 0 || Number(r.outsourcePaid || 0) > 0 || Number(r.outsourceRemaining || 0) > 0;
var clientDisplay = typeof normalizeClientDisplay === "function" ? normalizeClientDisplay(r.client) : (String(r.client || "").trim() || "-");
var splitDisplay = typeof formatSplitDisplay === "function" ? formatSplitDisplay(r.split) : formatSplitPercent(r.split).replace("분담율 ", "");
var summaryCards = [
'<div class="summary-card"><div class="summary-label">계약금</div><div class="summary-value">' + esc(won(r.cSup || 0)) + '</div><div class="summary-note"></div></div>',
'<div class="summary-card"><div class="summary-label">수금액</div><div class="summary-value">' + esc(won(r.col || 0)) + '</div><div class="summary-note">' + esc(latestCollect === "-" ? "수금일 없음" : "최종 수금일 " + latestCollect) + '</div></div>',
'<div class="summary-card"><div class="summary-label">수금률</div><div class="summary-value">' + esc((Number(r.rate || 0)).toFixed(2) + "%") + '</div><div class="summary-note">' + esc(payments.length ? "기성 " + payments.length + "차까지 반영" : "차수 정보 없음") + '</div></div>',
'<div class="summary-card receivable"><div class="summary-label">미수금액</div><div class="summary-value">' + esc(won(r.recv || 0)) + '</div><div class="summary-note">잔여 수금 필요 금액</div></div>'
].join("");
var boards = [
hasOutsource && typeof renderOutsourceBoard === "function" ? renderOutsourceBoard(r) : "",
renderCollectionBoard(r)
].filter(Boolean).join("");
return '<div class="inline-panel"><div class="project-head project-head-grid"><div class="project-head-main"><div class="inline-card"><div class="project-meta-grid"><div class="kv"><div class="kvk">계약법인</div><div class="kvv">' + esc(r.corp || "-") + '</div></div><div class="kv"><div class="kvk">발주처</div><div class="kvv">' + esc(clientDisplay) + '</div><div class="summary-note">' + esc(splitDisplay ? "분담율 " + splitDisplay : "분담율 -") + '</div></div><div class="kv"><div class="kvk">발주방법</div><div class="kvv">' + esc(r.order || "-") + '</div></div><div class="kv"><div class="kvk">PM</div><div class="kvv">' + esc(r.pm || "-") + '</div></div></div></div><div class="inline-card"><div class="summary-grid">' + summaryCards + '</div><div class="project-progress progress"><div class="bar" style="width:' + esc(String(Math.max(0, Math.min(100, Number(r.rate || 0))))) + '%"></div></div></div></div><div class="project-contact-stack">' + renderContactCard("계약 / 청구 담당자", r.cmNm, r.cmCo, r.cmDp, r.cmPh, r.cmEm) + renderContactCard("부서 담당자", r.dmNm, r.dmCo, r.dmDp, r.dmPh, r.dmEm) + '</div></div><div class="ledger-stack">' + boards + '</div></div>';
}
function openProjectWindow(r) {
var popupKey = typeof rowKey === "function"
? rowKey(r).replace(/[^0-9a-zA-Z]/g, "_")
: String((r.code || "project") + "_" + (r.name || "")).replace(/[^0-9a-zA-Z_]/g, "_");
var popup = window.open("", "business_project_" + popupKey, "width=1600,height=980,resizable=yes,scrollbars=yes");
if (!popup) return;
var styleText = Array.from(document.querySelectorAll("style")).map(function (el) {
return el.textContent || "";
}).join("\n");
var detailHtml = renderProjectInline(r);
var pageHtml = '<!DOCTYPE html><html lang="ko"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>'
+ esc(r.name || "사업 상세")
+ '</title><link rel="stylesheet" href="/design-tokens.css?v=20260401-01"><link rel="stylesheet" href="/design-patterns.css?v=20260401-01"><style>' + styleText
+ 'body{margin:0;background:#f1eadf;color:#10251d;font-family:"Pretendard","Noto Sans KR","Malgun Gothic",sans-serif;}'
+ '.popup-wrap{max-width:1680px;margin:0 auto;padding:20px;}'
+ '@media (max-width: 1180px){.project-head-grid{grid-template-columns:1fr;}.summary-grid{grid-template-columns:repeat(2,minmax(0,1fr));}.project-meta-grid{grid-template-columns:1fr;}}'
+ '@media (max-width: 760px){.popup-wrap{padding:14px;}.summary-grid{grid-template-columns:1fr;}.ledger-head{flex-direction:column;align-items:flex-start;}.ledger-pill{white-space:normal;}.ledger-table-wrap{padding:0 10px 12px;overflow-x:auto;}}'
+ '</style></head><body><div class="popup-wrap"><div class="popup-head"><div class="popup-title">' + esc(r.name || "-") + '</div><div class="popup-sub">사업코드 ' + esc(r.code || "-") + ' · 계약법인 ' + esc(r.corp || "-") + '</div></div>' + detailHtml + "</div></body></html>";
popup.document.open();
popup.document.write(pageHtml);
popup.document.close();
popup.focus();
}
async function tryLoadDbDefaultBusinessLedger() {
if (window.__mhBusinessDefaultLoaded) return;
window.__mhBusinessDefaultLoaded = true;
try {
var response = await fetch("/api/integration/business-ledger-default");
if (!response.ok) throw new Error("기본 사업관리대장 원본을 불러오지 못했습니다.");
var fileName = response.headers.get("x-source-filename") || "사업관리대장-1.xlsx";
var buffer = await response.arrayBuffer();
if (!buffer || !buffer.byteLength) throw new Error("기본 사업관리대장 원본 데이터가 비어 있습니다.");
await loadLedgerFile(buffer, fileName);
} catch (error) {
console.error(error);
}
}
function applyDashboardChrome() {
if (!E.cards) return;
document.body.setAttribute("data-mh-ledger-enhanced", "true");
var wrap = document.querySelector(".wrap");
var panel = document.querySelector(".panel");
if (wrap && panel) {
var shell = wrap.querySelector(".business-shell");
if (!shell) {
shell = document.createElement("div");
shell.className = "business-shell";
wrap.insertBefore(shell, E.cards);
}
if (E.cards.parentNode !== shell) shell.appendChild(E.cards);
if (panel.parentNode !== shell) shell.appendChild(panel);
}
var years = bgEnsureYear(S.all);
var summary = bgSummarize(S.all, S.dashboard.year);
var rows = Array.isArray(S.rows) ? S.rows : [];
var visibleBaronProjectRows = rows.filter(isBaronProjectRow);
var totals = bgTotals(visibleBaronProjectRows);
var totalRate = typeof rate === "function" ? rate("", totals.col, totals.col + totals.recv) : 0;
var toolbarHtml = '<div class="cards-toolbar">'
+ '<div class="cards-toolbar-row">'
+ years.map(function (year) {
return '<button type="button" class="summary-year-chip ' + (S.dashboard.year === year ? "active" : "") + '" data-dashboard-year="' + escAttr(year) + '">' + esc(year) + "</button>";
}).join("")
+ '<div class="cards-toolbar-search"></div>'
+ "</div>"
+ '<div class="cards-toolbar-metrics">'
+ '<button type="button" class="summary-filter-chip ' + (S.dashboard.section === "active" ? "active" : "") + '" data-dashboard-section="active"><span class="label">' + esc(summary.targetYear) + '년 진행과업</span><span class="count">' + summary.activeRows.length.toLocaleString("ko-KR") + '건</span><span class="meta">전년도 이월 사업 포함</span></button>'
+ '<button type="button" class="summary-filter-chip ' + (S.dashboard.section === "new" ? "active" : "") + '" data-dashboard-section="new"><span class="label">' + esc(summary.targetYear) + '년 신규프로젝트</span><span class="count">' + summary.newProjectRows.length.toLocaleString("ko-KR") + '건</span><span class="meta">계약기간 시작년도 기준</span></button>'
+ '<button type="button" class="summary-filter-chip ' + (S.dashboard.section === "completed" ? "active" : "") + '" data-dashboard-section="completed"><span class="label">' + esc(summary.targetYear) + '년 완료과업</span><span class="count">' + summary.completedRows.length.toLocaleString("ko-KR") + '건</span><span class="meta">해당년도 종료 사업 기준</span></button>'
+ "</div></div>";
var cards = [
{ label: summary.targetYear + "년 프로젝트", value: visibleBaronProjectRows.length.toLocaleString("ko-KR") + " 건", note: "" },
{ label: "계약금", value: won(totals.c), note: "" },
{ label: "수금액", value: won(totals.col), note: "" },
{ label: "미수금", value: won(totals.recv), note: "" },
{ label: "수금률(%)", value: totalRate.toFixed(2) + "%", note: "" },
{ label: "경영지원서비스 금액", value: won(summary.managementTotals.c), note: "", className: "management" }
];
E.cards.innerHTML = toolbarHtml + cards.map(function (card) {
return '<div class="card ' + esc(card.className || "") + '"><div class="k">' + esc(card.label) + '</div><div class="v">' + esc(card.value) + '</div><div class="n">' + esc(card.note || "") + "</div></div>";
}).join("");
var searchWrap = E.cards.querySelector(".cards-toolbar-search");
if (searchWrap && E.search) {
searchWrap.appendChild(E.search);
E.search.placeholder = "전체 검색";
}
}
var originalRender = render;
render = function () {
originalRender();
applyDashboardChrome();
renderLedgerTable();
};
filter = function () {
bgEnsureYear(S.all);
var q = String(E.search.value || "").trim().toLowerCase();
var searched = !q ? S.all.slice() : S.all.filter(function (r) {
return [r.code, r.name, r.client, r.pm, r.status, r.cat, r.corp, r.pay, (r.payments || []).map(function (p) { return p.pay; }).join(" "), r.periodText].join(" ").toLowerCase().includes(q);
});
S.rows = searched.filter(function (r) {
return bgMatches(r) && matchesColumnFilters(r);
});
render();
};
if (E.cards && !E.cards.dataset.dashboardBound) {
E.cards.dataset.dashboardBound = "true";
E.cards.addEventListener("click", function (event) {
var yearButton = event.target && event.target.closest ? event.target.closest("[data-dashboard-year]") : null;
if (yearButton) {
S.dashboard.year = yearButton.getAttribute("data-dashboard-year") || S.dashboard.year;
filter();
return;
}
var sectionButton = event.target && event.target.closest ? event.target.closest("[data-dashboard-section]") : null;
if (sectionButton) {
S.dashboard.section = sectionButton.getAttribute("data-dashboard-section") || "active";
filter();
}
});
}
if (E.tbody && !E.tbody.dataset.projectBound) {
E.tbody.dataset.projectBound = "true";
E.tbody.addEventListener("click", function (event) {
var groupButton = event.target && event.target.closest ? event.target.closest("[data-group-label]") : null;
if (groupButton) {
var label = groupButton.getAttribute("data-group-label") || "";
if (label) {
S.collapsedGroups[label] = !S.collapsedGroups[label];
render();
}
return;
}
var trigger = event.target && event.target.closest ? event.target.closest(".project-link") : null;
if (!trigger) return;
var key = trigger.getAttribute("data-project-key") || "";
var rows = Array.isArray(S.viewRows) ? S.viewRows : [];
var row = rows.find(function (item) {
return (String(item.code || "") + "|" + String(item.name || "")) === key;
});
if (row) openProjectWindow(row);
});
}
setTimeout(function () {
try {
filter();
if (typeof loadLedgerFile === "function") {
tryLoadDbDefaultBusinessLedger();
}
} catch (error) {
console.error(error);
}
}, 0);
window.addEventListener("message", function (event) {
var data = event.data || {};
if (data.source !== "total-upload" || data.type !== "business") return;
setTimeout(function () {
try {
applyDashboardChrome();
renderLedgerTable();
} catch (error) {
console.error(error);
}
}, 50);
});
})();

View File

@@ -6,9 +6,18 @@
- `payment.html`
- `mh.html`
- `ledger/index.html`
- `ledger/ledger-override.css`
- `ledger/ledger-override.js`
- `ledger/MH 통합 대시보드_260320.css`
- `ledger/사업관리대장-1.xlsx`
규칙:
- `/integrations/payment` 는 이 디렉터리의 `payment.html`을 읽는다.
- `/integrations/mh` 는 이 디렉터리의 `mh.html`을 읽는다.
- `/integrations/ledger``ledger/index.html`을 읽는다.
- `/integrations/ledger-assets/*``ledger/` 하위 파일만 읽는다.
- `payment.html` 수정 원본은 `frontend/apps/payment/index.html`이고, `scripts/publish_payment_app.sh`로 반영한다.
- `mh.html` 수정 원본은 `frontend/apps/team/index.html`이고, `scripts/publish_team_app.sh`로 반영한다.
- 원본 참고 파일이나 비교용 파일은 이 디렉터리에 두지 않는다.

View File

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

View File

@@ -0,0 +1,918 @@
<!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-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
flex-wrap: wrap;
}
.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;
}
.toolbar-input {
width: min(360px, 100%);
padding: 11px 14px;
border-radius: 14px;
border: 1px solid rgba(132, 102, 54, 0.16);
background: rgba(255, 251, 244, 0.96);
color: #2f2419;
font: inherit;
}
.toolbar-input:focus {
outline: none;
border-color: rgba(129, 88, 31, 0.34);
box-shadow: 0 0 0 4px rgba(208, 176, 116, 0.16);
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.action-button {
border: 1px solid rgba(128, 98, 48, 0.14);
background: rgba(250, 240, 213, 0.62);
color: #6c4a1a;
border-radius: 999px;
padding: 9px 14px;
font: inherit;
font-size: 12px;
font-weight: 800;
cursor: pointer;
}
.action-button:hover {
background: rgba(244, 228, 186, 0.8);
}
.action-button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.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">
<div class="panel-toolbar">
<input id="table-search" class="toolbar-input" type="search" placeholder="테이블명, 설명, 화면명으로 검색" />
<div class="toolbar-actions">
<span id="table-count" class="meta-chip">0 / 0</span>
</div>
</div>
<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>
<div class="toolbar-actions">
<button id="preview-download" class="action-button" type="button" disabled>CSV 다운로드</button>
<button id="preview-close" class="modal-close" type="button" aria-label="닫기">×</button>
</div>
</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>
let allTables = [];
let currentPreview = null;
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function formatNumber(value) {
return new Intl.NumberFormat("ko-KR").format(Number(value || 0));
}
function formatDateTime(value) {
if (!value) return '<span class="muted">-</span>';
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return escapeHtml(value);
return parsed.toLocaleString("ko-KR", { hour12: false });
}
function formatBytes(value) {
const size = Number(value || 0);
if (size <= 0) return "0 B";
const units = ["B", "KB", "MB", "GB"];
let current = size;
let unit = 0;
while (current >= 1024 && unit < units.length - 1) {
current /= 1024;
unit += 1;
}
return `${current.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`;
}
function renderOverview(overview) {
const target = document.getElementById("overview");
target.innerHTML = [
["핵심 테이블", overview.visible_tables],
["전체 테이블", overview.total_tables],
["등록 인원", overview.registered_members],
["재직 인원", overview.active_members],
["고정 오피스 도면", overview.fixed_office_maps],
["현재 active 도면", overview.active_seat_maps],
["Import 배치", overview.import_batches],
["바이너리 원본", overview.binary_sources],
].map(([label, value]) => `
<article class="kpi">
<span class="kpi-label">${escapeHtml(label)}</span>
<span class="kpi-value">${formatNumber(value)}</span>
</article>
`).join("");
}
function renderTables(items) {
const target = document.getElementById("table-body");
const countTarget = document.getElementById("table-count");
if (countTarget) {
countTarget.textContent = `${formatNumber(items.length)} / ${formatNumber(allTables.length)}`;
}
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) {
currentPreview = payload;
const previewModal = document.getElementById("preview-modal");
const downloadButton = document.getElementById("preview-download");
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)}개 표시`;
if (downloadButton) {
downloadButton.disabled = !(payload.rows && payload.rows.length);
}
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();
allTables = payload.tables || [];
document.getElementById("generated-at").textContent = payload.generated_at
? `갱신 ${formatDateTime(payload.generated_at)}`
: "갱신 시각 없음";
renderOverview(payload.overview || {});
renderTables(allTables);
renderBatches(payload.import_batches || []);
renderBinarySources(payload.binary_sources || []);
renderNotes(payload.notes || []);
renderGroupSummary(payload.group_summary || {});
renderProductSummary(payload.product_summary || {});
renderScreenMap(payload.screen_map || []);
}
function toCsvValue(value) {
const text = String(value ?? "");
if (!/[",\n]/.test(text)) return text;
return `"${text.replaceAll('"', '""')}"`;
}
function downloadPreviewCsv() {
if (!currentPreview || !currentPreview.columns || !currentPreview.rows || !currentPreview.rows.length) return;
const headers = currentPreview.columns.map((column) => column.name);
const lines = [
headers.map(toCsvValue).join(","),
...currentPreview.rows.map((row) => headers.map((header) => toCsvValue(row[header] ?? "")).join(",")),
];
const blob = new Blob(["\ufeff" + lines.join("\n")], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${currentPreview.table_name || "table-preview"}.csv`;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}
function applyTableSearch(query) {
const normalized = String(query || "").trim().toLowerCase();
if (!normalized) {
renderTables(allTables);
return;
}
const filtered = allTables.filter((item) => {
const haystack = [
item.label,
item.table_ref,
item.description,
...(item.related_views || []),
item.domain,
item.group,
].join(" ").toLowerCase();
return haystack.includes(normalized);
});
renderTables(filtered);
}
document.getElementById("preview-close").addEventListener("click", () => {
const modal = document.getElementById("preview-modal");
modal.classList.remove("open");
modal.setAttribute("aria-hidden", "true");
});
document.getElementById("preview-download").addEventListener("click", downloadPreviewCsv);
document.getElementById("table-search").addEventListener("input", (event) => {
applyTableSearch(event.target.value);
});
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>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,21 @@
# Ledger Served Assets
`8081` 사업관리대장 화면이 실제로 읽는 런타임 파일 모음이다.
source-of-truth:
- [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)
- `index.html`: `/integrations/ledger` 응답 본문
- `frontend/apps/ledger/index.html` 템플릿에서 publish 시 생성
- `MH 통합 대시보드_260320.css`: ledger base stylesheet
- `ledger-override.css`: 8081 ledger 디자인/레이아웃 오버라이드
- `ledger-override.js`: 8081 ledger 상호작용/테이블/팝업 오버라이드
- `사업관리대장-1.xlsx`: startup 시 기본 원본 DB 동기화에 사용하는 기본 데이터 파일
규칙:
- backend는 `사업관리대장` 원본 wrapper를 더 이상 직접 읽지 않는다.
- runtime asset 수정은 `frontend/apps/ledger` 기준으로 먼저 반영하고, 이 디렉터리로 publish 한다.
- 원본 비교가 필요하면 `incoming-files/reference/ledger/`를 본다.

View File

@@ -0,0 +1,954 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>사업관리대장 Dashboard</title>
<style>
*{box-sizing:border-box}body{margin:0;background:#f8fafc;color:#0f172a;font-family:'Pretendard','Noto Sans KR','Malgun Gothic',sans-serif}
.wrap{max-width:1600px;margin:0 auto;padding:20px}
.top{display:grid;grid-template-columns:1fr minmax(260px,520px);gap:12px;align-items:end}
.title{font-size:34px;font-weight:900;letter-spacing:-.03em;margin:0}
.sub{font-size:12px;color:#64748b;font-weight:800;letter-spacing:.08em;text-transform:uppercase}
.controls{display:flex;gap:8px;justify-content:flex-end;flex-wrap:wrap}
.btn{border:1px solid #2563eb;background:#2563eb;color:#fff;border-radius:12px;padding:10px 14px;font-size:13px;font-weight:800;cursor:pointer}
.search{flex:1;min-width:250px;border:1px solid #e2e8f0;border-radius:12px;padding:10px 12px;font-size:13px;font-weight:700}
.status{margin:10px 0 14px;font-size:12px;font-weight:700;color:#64748b}
.cards{display:grid;grid-template-columns:repeat(5,minmax(150px,1fr));gap:10px;margin-bottom:12px}
.card{background:#fff;border:1px solid #e2e8f0;border-radius:14px;padding:10px 12px}
.card .k{font-size:11px;font-weight:800;color:#64748b}
.card .v{font-size:19px;font-weight:900;white-space:nowrap}
.panel{background:#fff;border:1px solid #e2e8f0;border-radius:20px;overflow:hidden}
.table-wrap{overflow:auto}
table{width:100%;min-width:1250px;border-collapse:collapse}
thead th{background:#0f172a;color:#ffffffd1;font-size:11px;text-transform:uppercase;letter-spacing:.12em;padding:12px 10px;text-align:left;white-space:nowrap;vertical-align:middle}
.th-head{position:relative;display:flex;align-items:center}
.th-head.end{justify-content:flex-end}
.th-trigger{display:inline-flex;align-items:center;gap:6px;border:0;background:none;padding:0;color:#ffffffd1;font:inherit;font-weight:900;letter-spacing:inherit;text-transform:inherit;cursor:pointer}
.th-trigger:hover,.th-trigger.active,.th-trigger.open{color:#fff}
.th-title{display:inline-block}
.th-meta{font-size:10px;color:#93c5fd;font-weight:800;letter-spacing:0;text-transform:none}
.th-mark{display:inline-flex;align-items:center;justify-content:center;min-width:8px;color:#60a5fa;font-size:12px;line-height:1}
.th-caret{font-size:10px;color:#93c5fd;transition:transform .15s ease}
.th-trigger.open .th-caret{transform:rotate(180deg)}
.th-menu{position:absolute;top:calc(100% + 8px);left:0;display:none;min-width:180px;max-width:320px;max-height:280px;overflow:auto;padding:6px;background:#fff;border:1px solid #cbd5e1;border-radius:12px;box-shadow:0 16px 40px #0f172a26;z-index:15}
.th-head.end .th-menu{left:auto;right:0}
.th-menu.open{display:block}
.th-option{display:block;width:100%;border:0;background:none;border-radius:8px;padding:9px 10px;text-align:left;font-size:12px;font-weight:700;color:#0f172a;cursor:pointer;white-space:normal;word-break:break-word}
.th-option:hover{background:#eff6ff}
.th-option.active{background:#dbeafe;color:#1d4ed8}
tbody td{padding:12px;border-bottom:1px solid #f1f5f9;font-size:13px;white-space:nowrap;vertical-align:middle}
tbody tr:hover{background:#eff6ff}
tbody tr.settled{background:#f8fafc;color:#94a3b8}
tbody tr.settled:hover{background:#f1f5f9}
tbody tr.settled .name,tbody tr.settled strong{color:#64748b}
tbody tr.settled .badge{border-color:#cbd5e1;background:#f8fafc;color:#64748b}
.num{text-align:right;font-variant-numeric:tabular-nums}
.name{font-weight:800;max-width:460px;overflow:hidden;text-overflow:ellipsis}
.subline{font-size:11px;color:#94a3b8;font-weight:700;margin-top:3px}
.badge{display:inline-flex;padding:3px 9px;border-radius:999px;border:1px solid #bfdbfe;background:#eff6ff;color:#1d4ed8;font-size:11px;font-weight:900}
.badge.ok{border-color:#bbf7d0;background:#f0fdf4;color:#047857}
.empty{display:none;padding:32px;text-align:center;color:#94a3b8;font-weight:800}
.hidden{display:none}
.modal{position:fixed;inset:0;background:#020617bf;backdrop-filter:blur(4px);display:none;align-items:center;justify-content:center;padding:16px;z-index:30}
.modal.show{display:flex}
.modal-card{width:min(1200px,100%);max-height:90vh;overflow:auto;background:#fff;border-radius:24px;border:1px solid #e2e8f0}
.m-top{padding:20px;border-bottom:1px solid #f1f5f9;background:#f8fafc;display:flex;justify-content:space-between;gap:10px}
.x{width:42px;height:42px;border:1px solid #e2e8f0;border-radius:12px;background:#fff;font-size:22px;font-weight:900;color:#64748b;cursor:pointer}
.m-body{padding:18px;display:grid;grid-template-columns:1.5fr 1fr;gap:12px}
.sec{border:1px solid #e2e8f0;border-radius:16px;padding:12px}
.sec.dark{background:#0f172a;color:#fff;border-color:#0f172a}
.grid3{display:grid;grid-template-columns:repeat(3,minmax(100px,1fr));gap:8px}
.grid4{display:grid;grid-template-columns:repeat(4,minmax(100px,1fr));gap:8px}
.kv{border:1px solid #e2e8f0;border-radius:12px;padding:9px}
.kvk{font-size:10px;color:#94a3b8;font-weight:900;text-transform:uppercase}
.kvv{font-size:13px;font-weight:800;margin-top:3px;word-break:break-word}
.line{display:flex;justify-content:space-between;gap:10px;padding:5px 0;border-bottom:1px dashed #e2e8f0;font-size:13px;font-weight:700}
.line:last-child{border-bottom:0}
.money{font-size:28px;font-weight:900}
.progress{height:11px;background:#94a3b833;border-radius:999px;overflow:hidden;margin-top:7px}
.bar{height:100%;background:#3b82f6;width:0%}
.pay-list{display:flex;flex-direction:column;gap:8px;margin-top:10px}
.pay-item{border:1px solid #e2e8f0;border-radius:12px;padding:10px 12px;background:#f8fafc}
.pay-head{display:flex;justify-content:space-between;gap:10px;align-items:flex-start}
.pay-name{font-size:13px;font-weight:900;word-break:break-word}
.pay-meta{margin-top:6px;display:grid;grid-template-columns:repeat(2,minmax(120px,1fr));gap:6px 10px;font-size:12px;color:#475569;font-weight:700}
.pay-empty{margin-top:10px;border:1px dashed #cbd5e1;border-radius:12px;padding:12px;color:#94a3b8;font-size:12px;font-weight:800;text-align:center}
.pay-note{margin-top:8px;border-top:1px dashed #fecaca;padding-top:8px;font-size:12px;color:#b91c1c;font-weight:800;white-space:pre-wrap}
.metric-btn{display:inline-flex;flex-direction:column;align-items:flex-end;gap:2px;border:0;background:none;padding:0;color:inherit;font:inherit;cursor:pointer}
.metric-btn strong{color:#0f172a;text-decoration:underline;text-decoration-color:#bfdbfe;text-underline-offset:3px}
tbody tr.settled .metric-btn strong{color:#64748b}
.metric-btn:hover strong{color:#1d4ed8;text-decoration-color:#1d4ed8}
.detail-row td{padding:0;border-bottom:1px solid #e2e8f0;background:#f8fafc}
.detail-row:hover{background:#f8fafc}
.detail-cell{padding:0}
.inline-panel{padding:16px 18px}
.inline-grid{display:grid;grid-template-columns:1.35fr 1fr;gap:12px}
.inline-stack{display:flex;flex-direction:column;gap:10px}
.inline-card{background:#fff;border:1px solid #e2e8f0;border-radius:16px;padding:12px}
.inline-hero{background:#0f172a;color:#fff;border-color:#0f172a}
.inline-hero-note{font-size:12px;color:#94a3b8;margin-top:6px}
.inline-hero-split{display:grid;grid-template-columns:1fr 1fr;gap:14px;align-items:end}
.inline-hero-col{min-width:0}
.inline-hero-col.right{padding-left:14px;border-left:1px solid #334155}
.out-list{display:flex;flex-direction:column;gap:8px;margin-top:10px}
.out-item{border:1px solid #e2e8f0;border-radius:12px;padding:10px 12px;background:#f8fafc}
.out-head{display:flex;justify-content:space-between;gap:10px;align-items:flex-start}
.out-vendor{font-size:13px;font-weight:900}
.out-name{margin-top:6px;font-size:13px;font-weight:800;word-break:break-word}
.out-meta{margin-top:8px;display:grid;grid-template-columns:repeat(2,minmax(140px,1fr));gap:6px 10px;font-size:12px;color:#475569;font-weight:700}
.out-payments{display:flex;flex-direction:column;gap:6px;margin-top:8px;padding-top:8px;border-top:1px dashed #cbd5e1}
.out-payment{background:#fff;border:1px solid #e2e8f0;border-radius:10px;padding:8px}
.out-payment-head{display:flex;justify-content:space-between;gap:10px;align-items:flex-start;font-size:12px;font-weight:800}
.out-payment-meta{margin-top:6px;display:grid;grid-template-columns:repeat(3,minmax(120px,1fr));gap:4px 8px;font-size:12px;color:#475569;font-weight:700}
.out-note{margin-top:8px;border-top:1px dashed #fecaca;padding-top:8px;font-size:12px;color:#b91c1c;font-weight:800;white-space:pre-wrap}
.project-head{display:grid;grid-template-columns:1.2fr .8fr;gap:12px;margin-bottom:12px}
.project-meta-grid{display:grid;grid-template-columns:repeat(4,minmax(110px,1fr));gap:8px}
.project-sections{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.section-card{background:#fff;border:1px solid #e2e8f0;border-radius:16px;padding:14px}
.section-head{display:flex;justify-content:space-between;gap:12px;align-items:flex-start;margin-bottom:10px}
.section-title{font-size:16px;font-weight:900}
.section-sub{margin-top:4px;font-size:12px;color:#64748b;font-weight:800}
.section-chip{display:inline-flex;align-items:center;gap:6px;border:1px solid #bfdbfe;background:#eff6ff;color:#1d4ed8;border-radius:999px;padding:5px 10px;font-size:11px;font-weight:900;white-space:nowrap}
.section-chip.out{border-color:#fecdd3;background:#fff1f2;color:#be123c}
.summary-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:8px}
.summary-card{background:#f8fafc;border:1px solid #e2e8f0;border-radius:14px;padding:12px;min-width:0}
.summary-label{font-size:11px;color:#64748b;font-weight:900;text-transform:uppercase}
.summary-value{margin-top:6px;font-size:clamp(12px,0.95vw,22px);font-weight:900;line-height:1.15;white-space:nowrap;max-width:100%;letter-spacing:-.03em}
.summary-note{margin-top:4px;font-size:12px;color:#94a3b8;font-weight:800}
.ledger-stack{display:flex;flex-direction:column;gap:14px}
.ledger-block{background:#fff;border:1px solid #e2e8f0;border-radius:18px;overflow:hidden}
.ledger-block.outsource{border-color:#fecdd3;background:#fff}
.ledger-block.collect{border-color:#c7d2fe;background:#fff}
.ledger-head{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:12px 14px}
.ledger-head-left{display:flex;align-items:center;gap:10px;min-width:0}
.ledger-icon{width:20px;height:20px;border-radius:999px;display:inline-flex;align-items:center;justify-content:center;font-size:12px;font-weight:900;color:#fff;flex:0 0 auto}
.ledger-block.outsource .ledger-icon{background:#f43f5e}
.ledger-block.collect .ledger-icon{background:#6366f1}
.ledger-name{font-size:13px;font-weight:900}
.ledger-sub{margin-top:2px;font-size:11px;color:#64748b;font-weight:800}
.ledger-pill{display:inline-flex;align-items:center;padding:6px 10px;border-radius:999px;font-size:11px;font-weight:900;white-space:nowrap}
.ledger-block.outsource .ledger-pill{border:1px solid #fecdd3;background:#fff1f2;color:#e11d48}
.ledger-block.collect .ledger-pill{border:1px solid #c7d2fe;background:#eef2ff;color:#4f46e5}
.ledger-table-wrap{padding:0 12px 12px}
.ledger-table{width:100%;min-width:0;border-collapse:collapse}
.ledger-table thead th{background:transparent;color:#94a3b8;font-size:11px;font-weight:900;letter-spacing:0;text-transform:none;padding:8px 10px;border-bottom:1px solid #e2e8f0}
.ledger-table tbody td{padding:10px;border-bottom:1px solid #eef2f7;font-size:12px;color:#334155;white-space:normal;background:#fff}
.ledger-table tbody tr:last-child td{border-bottom:0}
.ledger-main{font-weight:800;color:#0f172a}
.ledger-muted{display:block;margin-top:3px;font-size:11px;color:#94a3b8;font-weight:700}
.ledger-amount{font-weight:900;text-align:right;color:#0f172a}
.ledger-note{font-size:11px;color:#64748b;font-weight:700}
.ledger-empty{padding:14px 12px;color:#94a3b8;font-size:12px;font-weight:800;text-align:center}
.ledger-block.outsource .ledger-head{background:#fff1f2;border-bottom:1px solid #fecdd3}
.ledger-block.collect .ledger-head{background:#eef2ff;border-bottom:1px solid #c7d2fe}
.ledger-block.outsource .ledger-table thead th{background:#fff7f8}
.ledger-block.collect .ledger-table thead th{background:#f5f7ff}
@media(max-width:1280px){.top{grid-template-columns:1fr}.controls{justify-content:flex-start}.cards{grid-template-columns:repeat(2,minmax(140px,1fr))}.m-body{grid-template-columns:1fr}.inline-grid{grid-template-columns:1fr}.grid4{grid-template-columns:repeat(2,minmax(100px,1fr))}.inline-hero-split{grid-template-columns:1fr}.inline-hero-col.right{padding-left:0;border-left:0;border-top:1px solid #334155;padding-top:12px}.project-head{grid-template-columns:1fr}.project-meta-grid{grid-template-columns:repeat(2,minmax(110px,1fr))}.project-sections{grid-template-columns:1fr}.summary-grid{grid-template-columns:repeat(2,minmax(120px,1fr))}.ledger-head{align-items:flex-start;flex-direction:column}.ledger-pill{align-self:flex-start}}
</style>
<base href="/integrations/ledger-assets/"><link rel="stylesheet" href="/integrations/ledger-assets/MH%20통합%20대시보드_260320.css"><link rel="stylesheet" href="/integrations/ledger-assets/ledger-override.css?v=20260401-03"></head>
<body class="mh-business-theme">
<input id="file" type="file" accept=".csv,.xlsx,.xls" class="hidden" />
<div class="wrap">
<div class="top">
<div><div class="sub">Live Management</div><h1 class="title">사업관리대장 <span style="font-weight:300;color:#94a3b8">| Dashboard</span></h1></div>
<div class="controls"><button id="btnUpload" class="btn" type="button">파일 업로드</button><input id="search" class="search" placeholder="전체 검색" /></div>
</div>
<div id="status" class="status">CSV/XLSX 파일을 업로드하면 데이터가 표시됩니다.</div>
<div id="cards" class="cards"></div>
<div class="panel">
<div class="table-wrap">
<table>
<thead>
<tr>
<th>
<div class="th-head">
<button type="button" class="th-trigger" data-filter="code" data-label="구분 / 코드">
<span class="th-title">구분 / 코드</span><span class="th-mark"></span><span class="th-caret"></span>
</button>
<div id="filterCodeMenu" class="th-menu" data-filter="code"></div>
</div>
</th>
<th>
<div class="th-head">
<button type="button" class="th-trigger" data-filter="name" data-label="사업명">
<span class="th-title">사업명</span><span class="th-mark"></span><span class="th-caret"></span>
</button>
<div id="filterNameMenu" class="th-menu" data-filter="name"></div>
</div>
</th>
<th>
<div class="th-head">
<button type="button" class="th-trigger" data-filter="corp" data-label="계약법인">
<span class="th-title">계약법인</span><span class="th-mark"></span><span class="th-caret"></span>
</button>
<div id="filterCorpMenu" class="th-menu" data-filter="corp"></div>
</div>
</th>
<th>
<div class="th-head">
<button type="button" class="th-trigger" data-filter="status" data-label="진행상태">
<span class="th-title">진행상태</span><span class="th-mark"></span><span class="th-caret"></span>
</button>
<div id="filterStatusMenu" class="th-menu" data-filter="status"></div>
</div>
</th>
<th>
<div class="th-head">
<button type="button" class="th-trigger" data-filter="outsource" data-label="외주비">
<span class="th-title">외주비</span><span class="th-meta">(VAT 별도)</span><span class="th-mark"></span><span class="th-caret"></span>
</button>
<div id="filterOutsourceMenu" class="th-menu" data-filter="outsource"></div>
</div>
</th>
<th class="num">
<div class="th-head end">
<button type="button" class="th-trigger" data-filter="amount" data-label="계약금">
<span class="th-title">계약금</span><span class="th-meta">(VAT 별도)</span><span class="th-mark"></span><span class="th-caret"></span>
</button>
<div id="filterAmountMenu" class="th-menu" data-filter="amount"></div>
</div>
</th>
<th class="num">
<div class="th-head end">
<button type="button" class="th-trigger" data-filter="collected" data-label="수금액">
<span class="th-title">수금액</span><span class="th-meta">(VAT 별도)</span><span class="th-mark"></span><span class="th-caret"></span>
</button>
<div id="filterCollectedMenu" class="th-menu" data-filter="collected"></div>
</div>
</th>
<th class="num">
<div class="th-head end">
<button type="button" class="th-trigger" data-filter="rate" data-label="수금률">
<span class="th-title">수금률</span><span class="th-mark"></span><span class="th-caret"></span>
</button>
<div id="filterRateMenu" class="th-menu" data-filter="rate"></div>
</div>
</th>
</tr>
</thead>
<tbody id="tbody"></tbody>
</table>
</div>
<div id="empty" class="empty">표시할 데이터가 없습니다.</div>
</div>
</div>
<div id="collectModal" class="modal">
<div class="modal-card">
<div class="m-top"><div><div id="mCat" class="badge">미분류</div><div id="mTitle" style="font-size:28px;font-weight:900;margin-top:6px"></div><div id="mSub" style="font-size:13px;color:#64748b;font-weight:700;margin-top:4px"></div></div><button id="btnCollectClose" class="x" type="button">×</button></div>
<div class="m-body">
<div style="display:flex;flex-direction:column;gap:10px">
<div class="sec"><div class="grid3"><div class="kv"><div class="kvk">발주처</div><div id="mClient" class="kvv"></div></div><div class="kv"><div class="kvk">발주방법</div><div id="mOrder" class="kvv"></div></div><div class="kv"><div class="kvk">분담율</div><div id="mSplit" class="kvv"></div></div></div></div>
<div class="sec"><div class="line"><span>착수일</span><strong id="mStartDate"></strong></div><div class="line"><span>준공일</span><strong id="mEndDate"></strong></div><div class="line"><span>대금구분</span><strong id="mPayType"></strong></div><div id="mPayItems" class="pay-list"></div></div>
<div class="sec dark"><div style="display:flex;justify-content:space-between;gap:10px;align-items:flex-end"><div><div style="font-size:11px;color:#94a3b8;font-weight:900">총 계약 합계(VAT 포함)</div><div id="mContractTotal" class="money"></div><div id="mContractSupply" style="font-size:12px;color:#94a3b8"></div></div><div style="text-align:right"><div style="font-size:11px;color:#60a5fa;font-weight:900">수금금액</div><div id="mCollected" class="money" style="color:#60a5fa"></div><div id="mCollectDate" style="font-size:12px;color:#94a3b8"></div></div></div><div style="margin-top:10px;display:flex;justify-content:space-between"><span style="font-size:12px;color:#94a3b8;font-weight:900">수금 진행률</span><strong id="mRate" style="font-size:28px"></strong></div><div class="progress"><div id="mRateBar" class="bar"></div></div><div style="display:flex;justify-content:space-between;margin-top:7px"><span style="color:#fda4af;font-size:12px;font-weight:900">미수 금액</span><strong id="mReceivable" style="color:#fb7185"></strong></div></div>
</div>
<div style="display:flex;flex-direction:column;gap:10px">
<div class="sec"><div style="font-size:11px;color:#64748b;font-weight:900;letter-spacing:.1em;text-transform:uppercase">계약 / 청구 담당자</div><div style="margin-top:8px"><div id="mCmName" style="font-size:20px;font-weight:900"></div><div id="mCmOrg" style="font-size:13px;color:#0f172a;font-weight:800;margin-top:4px"></div><div id="mCmPhone" style="font-size:13px;font-weight:700;margin-top:8px"></div><div id="mCmEmail" style="font-size:13px;font-weight:700;margin-top:4px"></div></div></div>
<div class="sec"><div style="font-size:11px;color:#64748b;font-weight:900;letter-spacing:.1em;text-transform:uppercase">부서 담당자</div><div style="margin-top:8px"><div id="mDmName" style="font-size:20px;font-weight:900"></div><div id="mDmOrg" style="font-size:13px;color:#334155;font-weight:800;margin-top:4px"></div><div id="mDmPhone" style="font-size:13px;font-weight:700;margin-top:8px"></div><div id="mDmEmail" style="font-size:13px;font-weight:700;margin-top:4px"></div></div></div>
</div>
</div>
</div>
</div>
<div id="outsourceModal" class="modal">
<div class="modal-card">
<div class="m-top"><div><div class="badge">외주비 상세</div><div id="oTitle" style="font-size:28px;font-weight:900;margin-top:6px"></div><div id="oSub" style="font-size:13px;color:#64748b;font-weight:700;margin-top:4px"></div></div><button id="btnOutsourceClose" class="x" type="button">×</button></div>
<div class="m-body">
<div style="display:flex;flex-direction:column;gap:10px">
<div class="sec">
<div class="grid3">
<div class="kv"><div class="kvk">계약법인</div><div id="oCorp" class="kvv"></div></div>
<div class="kv"><div class="kvk">발주처</div><div id="oClient" class="kvv"></div></div>
<div class="kv"><div class="kvk">외주처 요약</div><div id="oVendors" class="kvv"></div></div>
</div>
</div>
<div class="sec">
<div class="line"><span>외주 총액</span><strong id="oTotal"></strong></div>
<div class="line"><span>외주 건수</span><strong id="oCount"></strong></div>
<div class="line"><span>계약기간</span><strong id="oPeriod"></strong></div>
<div id="oItems" class="out-list"></div>
</div>
</div>
<div style="display:flex;flex-direction:column;gap:10px">
<div class="sec dark">
<div style="font-size:11px;color:#94a3b8;font-weight:900">총 외주비(공급가액 기준)</div>
<div id="oTotalHero" class="money"></div>
<div id="oTotalHint" style="font-size:12px;color:#94a3b8;margin-top:6px"></div>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js"></script>
<script>
const FILTER_KEYS=["code","name","corp","status","outsource","amount","collected","rate"];
const S={all:[],rows:[],viewRows:[],file:"",filters:{},totals:null,expanded:{key:""}};
const E={file:document.getElementById("file"),btnUpload:document.getElementById("btnUpload"),search:document.getElementById("search"),status:document.getElementById("status"),cards:document.getElementById("cards"),tbody:document.getElementById("tbody"),empty:document.getElementById("empty"),collectModal:document.getElementById("collectModal"),btnCollectClose:document.getElementById("btnCollectClose"),outsourceModal:document.getElementById("outsourceModal"),btnOutsourceClose:document.getElementById("btnOutsourceClose"),filterButtons:Object.fromEntries(Array.from(document.querySelectorAll(".th-trigger")).map(el=>[el.dataset.filter,el])),filterMenus:Object.fromEntries(Array.from(document.querySelectorAll(".th-menu")).map(el=>[el.dataset.filter,el]))};
const G=id=>document.getElementById(id);
const esc=v=>String(v||"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");
const escAttr=v=>esc(v).replace(/"/g,"&quot;");
const n=v=>String(v||"").replace(/[\s\r\n]+/g,"").toLowerCase();
const num=v=>{v=String(v||"").trim();if(!v||v.startsWith("="))return 0;return parseFloat(v.replace(/[^0-9.\-]/g,""))||0;};
const won=v=>Math.round(v||0).toLocaleString("ko-KR")+" 원";
const d=v=>{v=String(v||"").trim();return !v||v==="~"?"-":v;};
const rate=(raw,col,sales)=>{const x=parseFloat(String(raw||"").replace(/[^0-9.\-]/g,""));if(Number.isFinite(x))return Math.max(0,Math.min(100,x));return sales>0?Math.max(0,Math.min(100,col/sales*100)):0;};
const score=t=>{t=String(t||"");let s=0,m=t.replace(/\s+/g,"");if(m.includes("사업관리대장"))s+=8;if(m.includes("총괄사업코드"))s+=8;if(m.includes("사업명(계약명)"))s+=7;s+=(t.match(/[가-힣]/g)||[]).length*0.01;s-=(t.match(/<2F>/g)||[]).length*0.5;return s;};
const rowKey=r=>[r.code||"",r.name||"",r.corp||"",r.client||""].join("|");
function parseCsv(txt){const out=[];let row=[],f="",q=false;for(let i=0;i<txt.length;i++){const c=txt[i];if(c==='"'){if(q&&txt[i+1]==='"'){f+='"';i++;}else q=!q;continue;}if(c===","&&!q){row.push(f);f="";continue;}if((c==="\n"||c==="\r")&&!q){if(c==="\r"&&txt[i+1]==="\n")i++;row.push(f);out.push(row);row=[];f="";continue;}f+=c;}row.push(f);out.push(row);if(out.length&&out[0].length)out[0][0]=String(out[0][0]||"").replace(/^\uFEFF/,"");return out;}
function hs(rows){
for(let i=0;i<rows.length;i++){
const a=(rows[i]||[]).map(n);
const hasName=a.some(v=>v.includes("사업명(계약명)")||v==="사업명"||v.includes("사업명"));
const hasCode=a.some(v=>v.includes("총괄사업코드")||v.includes("사업코드"));
const hasClient=a.some(v=>v.includes("발주처(매출처)")||v.includes("발주처"));
if(hasName&&(hasCode||hasClient)) return i;
}
return -1;
}
function ch(a,b){a=a||[];b=b||[];const m=Math.max(a.length,b.length),o=[];let carry="";for(let i=0;i<m;i++){const t=String(a[i]||"").replace(/\s+/g," ").trim(),s=String(b[i]||"").replace(/\s+/g," ").trim();if(t)carry=t;const top=t||carry;o.push(top&&s?(top+" "+s).trim():(top||s||""));}return o;}
function hi(headers,cands){const C=(cands||[]).map(n).filter(Boolean);for(const c of C){for(let i=0;i<headers.length;i++)if(n(headers[i])===c)return i;}return -1;}
function parseLedgerRows(R){
if(R.length&&R[0].length)R[0][0]=String(R[0][0]||"").replace(/^\uFEFF/,"");
const h=hs(R);if(h<0)throw new Error("헤더를 찾지 못했습니다.");
const H=ch(R[h],R[h+1]||[]),I={cat:hi(H,["사업구분","사업 구분"]),corp:hi(H,["계약법인","계약 법인"]),code:hi(H,["총괄사업코드","총괄 사업코드","사업코드"]),name:hi(H,["사업명 (계약명)","사업명(계약명)","사업명"]),pay:hi(H,["대금구분","대금 구분"]),yn:hi(H,["계약여부"]),order:hi(H,["발주방법"]),pm:hi(H,["pm"]),status:hi(H,["진행상태"]),client:hi(H,["발주처 (매출처)","발주처(매출처)","발주처"]),split:hi(H,["분담율"]),cDate:hi(H,["계약기간 계약일","계약일","발행일"]),sDate:hi(H,["계약기간 착수일","착수일"]),eDate:hi(H,["계약기간 준공일","준공일"]),cSup:hi(H,["계약금 공급가액","매출금액 공급가액","공급가액"]),cVat:hi(H,["계약금 부가세","매출금액 부가세","부가세"]),cTot:hi(H,["계약금 합계","매출금액 합계","합계","계약금","매출금액"]),colDate:hi(H,["매출금액 수금일","수금일"]),sSup:hi(H,["매출금액 공급가액","공급가액"]),sVat:hi(H,["매출금액 부가세","부가세"]),sTot:hi(H,["매출금액 합계","합계","매출금액"]),col:hi(H,["매출금액 수금금액","수금금액","수금액"]),recv:hi(H,["매출금액 미수금액","미수금액"]),r:hi(H,["매출금액 수금율","수금율"]),note:hi(H,["비고"]),cmCo:hi(H,["계약/청구담당자 회사"]),cmNm:hi(H,["계약/청구담당자 이름"]),cmDp:hi(H,["계약/청구담당자 부서"]),cmPh:hi(H,["계약/청구담당자 연락처"]),cmEm:hi(H,["계약/청구담당자 이메일"]),dmCo:hi(H,["부서담당자 회사"]),dmNm:hi(H,["부서담당자 이름"]),dmDp:hi(H,["부서담당자 부서"]),dmPh:hi(H,["부서담당자 연락처"]),dmEm:hi(H,["부서담당자 이메일"])};
const out=[];for(const row of R.slice(h+2)){const x={cat:I.cat>=0?String(row[I.cat]||"").trim():"",corp:I.corp>=0?String(row[I.corp]||"").trim():"",code:I.code>=0?String(row[I.code]||"").trim():"",name:I.name>=0?String(row[I.name]||"").trim():"",pay:I.pay>=0?String(row[I.pay]||"").trim():"",yn:I.yn>=0?String(row[I.yn]||"").trim():"",order:I.order>=0?String(row[I.order]||"").trim():"",pm:I.pm>=0?String(row[I.pm]||"").trim():"",status:I.status>=0?String(row[I.status]||"").trim():"",client:I.client>=0?String(row[I.client]||"").trim():"",split:I.split>=0?String(row[I.split]||"").trim():"",cDate:I.cDate>=0?String(row[I.cDate]||"").trim():"",sDate:I.sDate>=0?String(row[I.sDate]||"").trim():"",eDate:I.eDate>=0?String(row[I.eDate]||"").trim():"",cSup:I.cSup>=0?num(row[I.cSup]):0,cVat:I.cVat>=0?num(row[I.cVat]):0,cTot:I.cTot>=0?num(row[I.cTot]):0,colDate:I.colDate>=0?String(row[I.colDate]||"").trim():"",sSup:I.sSup>=0?num(row[I.sSup]):0,sVat:I.sVat>=0?num(row[I.sVat]):0,sTot:I.sTot>=0?num(row[I.sTot]):0,col:I.col>=0?num(row[I.col]):0,recv:I.recv>=0?num(row[I.recv]):0,rateRaw:I.r>=0?String(row[I.r]||"").trim():"",note:I.note>=0?String(row[I.note]||"").trim():"",cmCo:I.cmCo>=0?String(row[I.cmCo]||"").trim():"",cmNm:I.cmNm>=0?String(row[I.cmNm]||"").trim():"",cmDp:I.cmDp>=0?String(row[I.cmDp]||"").trim():"",cmPh:I.cmPh>=0?String(row[I.cmPh]||"").trim():"",cmEm:I.cmEm>=0?String(row[I.cmEm]||"").trim():"",dmCo:I.dmCo>=0?String(row[I.dmCo]||"").trim():"",dmNm:I.dmNm>=0?String(row[I.dmNm]||"").trim():"",dmDp:I.dmDp>=0?String(row[I.dmDp]||"").trim():"",dmPh:I.dmPh>=0?String(row[I.dmPh]||"").trim():"",dmEm:I.dmEm>=0?String(row[I.dmEm]||"").trim():""};
if(!x.name&&!x.code)continue;if(!x.code&&!x.corp&&!x.client&&!x.pm)continue;if(!x.cTot)x.cTot=x.cSup+x.cVat;if(!x.sTot)x.sTot=x.sSup+x.sVat;if(!x.recv)x.recv=Math.max(0,x.sTot-x.col);x.rate=rate(x.rateRaw,x.col,x.sTot);out.push(x);}
return out;
}
const hk=v=>String(v||"").normalize("NFKC").toLowerCase().replace(/[^0-9a-z가-힣]+/g,"");
function findHeaderIndex(headers,cands){
const normalized=(headers||[]).map(hk);
const candidates=(cands||[]).map(hk).filter(Boolean);
for(const c of candidates){
for(let i=0;i<normalized.length;i++){
if(!normalized[i]) continue;
if(normalized[i]===c||normalized[i].includes(c)||c.includes(normalized[i])) return i;
}
}
return -1;
}
function textAt(row,idx){return idx>=0?String(row[idx]??"").replace(/\u00a0/g," ").replace(/\s+/g," ").trim():"";}
function moneyAt(row,idx){return idx>=0?num(row[idx]):0;}
function lastText(values){for(let i=values.length-1;i>=0;i--){const v=d(values[i]);if(v!=="-")return v;}return "-";}
function paymentSummary(payments){
const labels=[...new Set((payments||[]).map(p=>String(p.pay||"").trim()).filter(Boolean))];
if(!labels.length) return "-";
if(labels.length<=2) return labels.join(", ");
return `${labels.slice(0,2).join(", ")}${labels.length-2}`;
}
function paymentRecord(x,fallbackPay){
const supply=x.sSup||0,vat=x.sVat||0,total=x.sTot||supply+vat,collected=x.col||0;
return {pay:String(x.pay||x.name||fallbackPay||"미입력").trim(),status:x.status||"",issueDate:x.issueDate||x.cDate||"",collectDate:x.colDate||"",supply,vat,total,collected,receivable:x.recv||Math.max(0,total-collected),rate:rate(x.rateRaw,collected,total),note:String(x.note||"").trim()};
}
function finalizeProject(project){
const payments=(project.payments||[]).filter(p=>p.pay||p.issueDate||p.collectDate||p.total||p.collected||p.receivable);
if(!payments.length&&(project.issueDate||project.colDate||project.sSup||project.sVat||project.sTot||project.col||project.recv)) payments.push(paymentRecord(project,project.pay||"일괄"));
project.payments=payments;
project.pay=paymentSummary(payments);
project.periodText=(d(project.sDate)==="-"&&d(project.eDate)==="-")?"-":`${d(project.sDate)} ~ ${d(project.eDate)}`;
project.issueDateSummary=lastText(payments.map(p=>p.issueDate));
project.collectDateSummary=lastText(payments.map(p=>p.collectDate));
return project;
}
function normalizeProjectKey(v){return hk(v);}
function normalizeProjectBase(v){
return hk(String(v||"").replace(/\([^)]*\)/g," ").replace(/\[[^\]]*\]/g," "));
}
function summarizeOutsourceVendors(vendors){
const list=(vendors||[]).filter(Boolean);
if(!list.length) return "";
if(list.length<=2) return list.join(", ");
return `${list.slice(0,2).join(", ")} \uC678 ${list.length-2}\uACF3`;
}
function calcVatExcluded(total){return total>0?Math.round(total/1.1):0;}
function outsourceTotalLabel(item){
const ex=Math.round(item&&item.contractEx||0);
const total=Math.round(item&&item.contractIn||0);
if(ex>0) return won(ex);
if(total>0) return won(calcVatExcluded(total));
return "-";
}
function cleanVendorName(value,sheetName){
const raw=String(value||sheetName||"").trim();
return raw.replace(/^\(\uC8FC\)\s*/,"").replace(/^\uC8FC\uC2DD\uD68C\uC0AC\s*/,"").replace(/^\uC678\uC8FC/,"").trim()||String(sheetName||"\uC678\uC8FC").replace(/^\uC678\uC8FC/,"").trim()||"\uC678\uC8FC";
}
function getOutsourceLayout(rows){
const header=rows[3]||[];
const hasVatContract=String(header[9]??"").includes("VAT\uD3EC\uD568");
if(hasVatContract){
return {hasVatContract:true,contractEx:8,contractIn:9,invoiceDate:10,paymentDate:11,paymentAmount:12,remainingAmount:13,progress:14,label:15,note:16};
}
return {hasVatContract:false,contractEx:8,contractIn:-1,invoiceDate:9,paymentDate:10,paymentAmount:11,remainingAmount:12,progress:13,label:-1,note:14};
}
function shouldStopOutsourceRows(row){
const first=String(row[0]??"").trim();
const project=String(row[2]??"").trim();
const detail=String(row[3]??"").trim();
const joined=[row[0],row[2],row[3],row[13],row[14],row[15],row[16]].map(v=>String(v??"").trim()).join(" ");
return first==="\uB0A0\uC9DC"||first.startsWith("*\uC790\uB8CC\uCD9C\uCC98")||project==="\uC801\uC694"||detail==="\uC801\uC694"||project.includes("\uC790\uB8CC\uCD9C\uCC98")||joined.includes("\uC6D0\uACC4\uC57D\uAE08")||joined.includes("\uC218\uAE08/\uC9C0\uAE09\uCC98");
}
function getOutsourceEntry(map,key,name){
const current=map.get(key);
if(current) return current;
const next={name,key,baseKey:normalizeProjectBase(name),vendors:new Set(),items:[],contract:0,contractIn:0,paid:0,paidIn:0,remaining:0,remainingIn:0};
map.set(key,next);
return next;
}
function createOutsourceItem(entry,vendor,projectName,detail,row,layout){
const contractEx=num(row[layout.contractEx]);
const contractIn=layout.contractIn>=0?num(row[layout.contractIn]):0;
const next={
vendor,
projectName,
detail:String(detail||"-").trim()||"-",
contractDate:String(row[4]??"").trim(),
startDate:String(row[5]??"").trim(),
endDate:String(row[7]??"").trim(),
contractEx,
contractIn,
invoiceDate:String(row[layout.invoiceDate]??"").trim(),
progress:String(row[layout.progress]??"").trim(),
note:"",
payments:[]
};
entry.items.push(next);
return next;
}
function buildOutsourcePayment(item,row,layout){
const invoiceDate=String(row[layout.invoiceDate]??"").trim();
const paymentDate=String(row[layout.paymentDate]??"").trim();
const paymentCell=String(row[layout.paymentAmount]??"").trim();
const remainingCell=String(row[layout.remainingAmount]??"").trim();
const paymentRaw=num(row[layout.paymentAmount]);
const remainingRaw=num(row[layout.remainingAmount]);
const label=layout.label>=0?String(row[layout.label]??"").trim():"";
const note=layout.note>=0?String(row[layout.note]??"").trim():String(row[14]??"").trim();
if(!(invoiceDate||paymentDate||paymentRaw||remainingRaw||label||note)) return null;
if(note&&!label&&!paymentDate&&!paymentRaw&&!remainingRaw&&!invoiceDate){
item.note=note;
}
return {
label,
note,
invoiceDate,
paymentDate,
paymentKnown:paymentCell!=="",
remainingKnown:remainingCell!=="",
paymentEx:paymentRaw?(layout.hasVatContract?calcVatExcluded(paymentRaw):paymentRaw):0,
paymentIn:layout.hasVatContract?paymentRaw:0,
remainingEx:remainingRaw?(layout.hasVatContract?calcVatExcluded(remainingRaw):remainingRaw):0,
remainingIn:layout.hasVatContract?remainingRaw:0
};
}
function finalizeOutsourceItem(item){
const payments=Array.isArray(item.payments)?item.payments.filter(Boolean):[];
const paidEx=Math.round(payments.reduce((sum,p)=>sum+(p.paymentEx||0),0));
const paidIn=Math.round(payments.reduce((sum,p)=>sum+(p.paymentIn||0),0));
let remainingEx=0;
let remainingIn=0;
for(let i=payments.length-1;i>=0;i--){
const payment=payments[i];
if(payment.remainingKnown){
remainingEx=Math.round(payment.remainingEx||0);
remainingIn=Math.round(payment.remainingIn||0);
break;
}
}
if(!remainingEx&&item.contractEx>0) remainingEx=Math.max(0,Math.round(item.contractEx-paidEx));
if(!remainingIn&&item.contractIn>0) remainingIn=Math.max(0,Math.round(item.contractIn-paidIn));
return {...item,payments,paidEx,paidIn,remainingEx,remainingIn};
}
function parseOutsourceRows(rows,sheetName,map){
if(!rows||rows.length<6) return;
const vendor=cleanVendorName((rows[1]||[])[0],sheetName);
const layout=getOutsourceLayout(rows);
let currentKey="",currentName="",currentItem=null;
for(const row of rows.slice(5)){
if(shouldStopOutsourceRows(row)) break;
const projectName=String(row[2]??"").trim();
const projectKey=normalizeProjectKey(projectName);
const detail=String(row[3]??"").trim();
const validProject=projectKey&&projectKey!=="ref";
if(validProject){
currentKey=projectKey;
currentName=projectName;
const entry=getOutsourceEntry(map,currentKey,currentName);
entry.vendors.add(vendor);
currentItem=createOutsourceItem(entry,vendor,currentName,detail,row,layout);
const firstPayment=buildOutsourcePayment(currentItem,row,layout);
if(firstPayment) currentItem.payments.push(firstPayment);
continue;
}
if(!currentKey) continue;
const entry=getOutsourceEntry(map,currentKey,currentName);
entry.vendors.add(vendor);
const contractEx=num(row[layout.contractEx]);
const contractIn=layout.contractIn>=0?num(row[layout.contractIn]):0;
const hasFinancialRow=!!(contractEx||contractIn||num(row[layout.paymentAmount])||num(row[layout.remainingAmount]));
const hasMetaRow=!!(String(row[layout.invoiceDate]??"").trim()||String(row[layout.paymentDate]??"").trim()||String(row[layout.progress]??"").trim()||detail);
if(detail&&hasMetaRow){
currentItem=createOutsourceItem(entry,vendor,currentName,detail,row,layout);
const payment=buildOutsourcePayment(currentItem,row,layout);
if(payment) currentItem.payments.push(payment);
continue;
}
if(!currentItem){
if(!(hasFinancialRow||hasMetaRow)) continue;
currentItem=createOutsourceItem(entry,vendor,currentName,detail||"\uC678\uC8FC \uACC4\uC57D",row,layout);
}else{
if(contractEx>0) currentItem.contractEx+=contractEx;
if(contractIn>0) currentItem.contractIn+=contractIn;
if(!currentItem.progress) currentItem.progress=String(row[layout.progress]??"").trim();
}
const payment=buildOutsourcePayment(currentItem,row,layout);
if(payment) currentItem.payments.push(payment);
}
}
function parseOutsourceSheets(workbook){
const map=new Map();
const names=(workbook&&workbook.SheetNames)||[];
for(const sheetName of names){
if(!String(sheetName||"").startsWith("\uC678\uC8FC")) continue;
const sheet=workbook.Sheets[sheetName];
if(!sheet) continue;
const rows=XLSX.utils.sheet_to_json(sheet,{header:1,raw:false,defval:""});
parseOutsourceRows(rows,sheetName,map);
}
for(const entry of map.values()){
entry.items=entry.items.map(finalizeOutsourceItem).filter(item=>item.contractEx||item.contractIn||item.paidEx||item.paidIn||item.remainingEx||item.remainingIn||item.detail||item.payments.length);
entry.contract=Math.round(entry.items.reduce((sum,item)=>sum+(item.contractEx||0),0));
entry.contractIn=Math.round(entry.items.reduce((sum,item)=>sum+(item.contractIn||0),0));
entry.paid=Math.round(entry.items.reduce((sum,item)=>sum+(item.paidEx||0),0));
entry.paidIn=Math.round(entry.items.reduce((sum,item)=>sum+(item.paidIn||0),0));
entry.remaining=Math.round(entry.items.reduce((sum,item)=>sum+(item.remainingEx||0),0));
entry.remainingIn=Math.round(entry.items.reduce((sum,item)=>sum+(item.remainingIn||0),0));
}
return map;
}
function resolveOutsourceEntry(record,outsourceMap){
const fullKey=normalizeProjectKey(record.name||"");
const baseKey=normalizeProjectBase(record.name||"");
if(fullKey&&outsourceMap.has(fullKey)) return outsourceMap.get(fullKey);
if(baseKey&&outsourceMap.has(baseKey)) return outsourceMap.get(baseKey);
let best=null,bestScore=0;
for(const entry of outsourceMap.values()){
const entryFull=String(entry&&entry.key||"");
const entryBase=String(entry&&entry.baseKey||normalizeProjectBase(entry&&entry.name||""));
for(const candidate of [entryFull,entryBase]){
if(!candidate) continue;
const matched=(fullKey&&fullKey.includes(candidate))||(candidate&&fullKey&&candidate.includes(fullKey))||(baseKey&&baseKey.includes(candidate))||(candidate&&baseKey&&candidate.includes(baseKey));
if(matched&&candidate.length>bestScore){
best=entry;
bestScore=candidate.length;
}
}
}
return best;
}
function attachOutsourceCosts(records,outsourceMap){
return (records||[]).map(record=>{
const entry=resolveOutsourceEntry(record,outsourceMap);
const outsourceCost=entry?Math.round(entry.contract||0):0;
const outsourcePaid=entry?Math.round(entry.paid||0):0;
const outsourceRemaining=entry?Math.round(entry.remaining||0):0;
const outsourceCostIn=entry?Math.round(entry.contractIn||0):0;
const outsourcePaidIn=entry?Math.round(entry.paidIn||0):0;
const outsourceRemainingIn=entry?Math.round(entry.remainingIn||0):0;
const outsourceVendors=entry?Array.from(entry.vendors):[];
const outsourceItems=entry&&Array.isArray(entry.items)?entry.items.slice():[];
return {
...record,
outsourceCost,
outsourcePaid,
outsourceRemaining,
outsourceCostIn,
outsourcePaidIn,
outsourceRemainingIn,
outsourceVendors,
outsourceVendorText:summarizeOutsourceVendors(outsourceVendors),
outsourceItems
};
});
}
function parseLedgerRecords(R){
if(R.length&&R[0].length)R[0][0]=String(R[0][0]||"").replace(/^\uFEFF/,"");
const h=hs(R);if(h<0)throw new Error("헤더를 찾지 못했습니다.");
ch(R[h],R[h+1]||[]);
const I={cat:1,corp:4,code:5,name:6,pay:7,yn:8,order:9,pm:10,status:11,client:12,split:13,cDate:14,sDate:15,eDate:17,cSup:18,cVat:19,cTot:20,issueDate:21,colDate:22,sSup:23,sVat:24,sTot:25,col:26,recv:27,r:28,note:29,cmCo:30,cmNm:31,cmDp:32,cmPh:33,cmEm:34,dmCo:35,dmNm:36,dmDp:37,dmPh:38,dmEm:39};
const out=[];let current=null;
for(const row of R.slice(h+2)){
const x={
cat:textAt(row,I.cat),corp:textAt(row,I.corp),code:textAt(row,I.code),name:textAt(row,I.name),pay:textAt(row,I.pay),
yn:textAt(row,I.yn),order:textAt(row,I.order),pm:textAt(row,I.pm),status:textAt(row,I.status),client:textAt(row,I.client),
split:textAt(row,I.split),cDate:textAt(row,I.cDate),sDate:textAt(row,I.sDate),eDate:textAt(row,I.eDate),
cSup:moneyAt(row,I.cSup),cVat:moneyAt(row,I.cVat),cTot:moneyAt(row,I.cTot),issueDate:textAt(row,I.issueDate),colDate:textAt(row,I.colDate),
sSup:moneyAt(row,I.sSup),sVat:moneyAt(row,I.sVat),sTot:moneyAt(row,I.sTot),col:moneyAt(row,I.col),recv:moneyAt(row,I.recv),rateRaw:textAt(row,I.r),
note:textAt(row,I.note),cmCo:textAt(row,I.cmCo),cmNm:textAt(row,I.cmNm),cmDp:textAt(row,I.cmDp),cmPh:textAt(row,I.cmPh),cmEm:textAt(row,I.cmEm),
dmCo:textAt(row,I.dmCo),dmNm:textAt(row,I.dmNm),dmDp:textAt(row,I.dmDp),dmPh:textAt(row,I.dmPh),dmEm:textAt(row,I.dmEm)
};
if(!x.cTot) x.cTot=x.cSup+x.cVat;
if(!x.sTot) x.sTot=x.sSup+x.sVat;
if(!x.recv) x.recv=Math.max(0,x.sTot-x.col);
x.rate=rate(x.rateRaw,x.col,x.sTot);
const isProject=!!(x.code||(x.name&&(x.cat||x.corp||x.client||x.yn||x.order||x.pm)));
const isPayment=!isProject&&!!(x.pay||x.name||x.issueDate||x.colDate||x.sSup||x.sVat||x.sTot||x.col||x.recv);
if(isProject){
if(!x.name&&!x.code) continue;
if(current) out.push(finalizeProject(current));
current={...x,payments:[]};
continue;
}
if(isPayment&&current) current.payments.push(paymentRecord(x,x.pay));
}
if(current) out.push(finalizeProject(current));
return out;
}
function extractLedgerTotals(rows){
const indexes={contract:20,collected:26,receivable:27,rate:28};
let summaryRow=null;
for(let i=(rows||[]).length-1;i>=0;i--){
const row=rows[i]||[];
const hasSummaryLabel=row.some(cell=>String(cell??"").replace(/\s+/g,"").includes("합계"));
if(hasSummaryLabel){summaryRow=row;break;}
}
if(!summaryRow) return null;
const contract=num(summaryRow[indexes.contract]);
const collected=num(summaryRow[indexes.collected]);
const receivable=num(summaryRow[indexes.receivable]);
const rateRaw=String(summaryRow[indexes.rate]??"").trim();
if(!(contract||collected||receivable||rateRaw)) return null;
const totalBase=collected+receivable;
return {contract,collected,receivable,rate:rate(rateRaw,collected,totalBase)};
}
function parseLedger(txt){
const rows=parseCsv(txt);
return {records:parseLedgerRecords(rows),totals:extractLedgerTotals(rows)};
}
function parseLedgerExcel(buf){
if(typeof XLSX==="undefined")throw new Error("XLSX 라이브러리를 불러오지 못했습니다.");
const wb=XLSX.read(buf,{type:"array",cellDates:false});
const outsourceMap=parseOutsourceSheets(wb);
const names=wb.SheetNames||[];
const preferredNames=names.filter(name=>String(name||"").includes("공유사업관리대장"));
const candidateNames=preferredNames.length?preferredNames:[...names];
let bestRecords=null;
let bestSheet="";
let bestScore=-1;
let bestTotals=null;
for(const name of candidateNames){
try{
const sheet=wb.Sheets[name];
const rows=XLSX.utils.sheet_to_json(sheet,{header:1,raw:false,defval:""});
const normalized=(rows||[]).map(r=>Array.isArray(r)?r.map(v=>String(v??"")):[]);
const records=attachOutsourceCosts(parseLedgerRecords(normalized),outsourceMap);
if(!records.length) continue;
const totals=extractLedgerTotals(normalized);
const bonus=String(name||"").includes("공유사업관리대장")?1000000:/사업관리대장/i.test(String(name||""))?10000:0;
const score=records.length+bonus;
if(score>bestScore){
bestScore=score;
bestRecords=records;
bestSheet=name;
bestTotals=totals;
}
}catch(_){
// try next sheet
}
}
if(!bestRecords) throw new Error("엑셀에서 사업관리대장 헤더를 찾지 못했습니다.");
return { records: bestRecords, sheetName: bestSheet, totals: bestTotals };
}
function decode(buf){const u=new TextDecoder("utf-8").decode(buf);let e="";try{e=new TextDecoder("euc-kr").decode(buf);}catch(_){e=u;}return score(e)>score(u)?e:u;}
function sumRows(rows){return rows.reduce((a,r)=>(a.c+=r.cTot||0,a.s+=r.sTot||0,a.col+=r.col||0,a.recv+=r.recv||0,a),{c:0,s:0,col:0,recv:0});}
function isSettledRow(r){
const noSales=(r.sTot||0)<=0&&(r.col||0)<=0&&(r.recv||0)<=0;
const statusDone=String(r.status||"").includes("완료");
const coopDone=String(r.yn||"").includes("업무협조")&&statusDone&&noSales;
return coopDone||(statusDone&&Math.round(r.recv||0)<=0&&(r.rate||0)>=100);
}
function hasActiveDashboardFilters(){
return !!String(E.search.value||"").trim()||FILTER_KEYS.some(key=>!!S.filters[key]);
}
function codeFilterLabel(r){return r.cat||"-";}
function periodFilterLabel(r){return `${d(r.sDate)} ~ ${d(r.eDate)}`;}
function outsourceFilterLabel(r){return r.outsourceCost?won(r.outsourceCost):"-";}
function amountFilterLabel(r){return won(r.cSup);}
function collectedFilterLabel(r){return won(r.col);}
function rateFilterLabel(r){return r.rate.toFixed(2)+"%";}
function uniqueFilterValues(rows,mapFn){
const seen=new Set(),out=[];
for(const row of rows){
const value=String(mapFn(row)||"").trim();
if(!value||seen.has(value)) continue;
seen.add(value);
out.push(value);
}
return out;
}
function filterDefinitions(){
return [
{key:"code",map:codeFilterLabel},
{key:"name",map:r=>r.name||"-"},
{key:"corp",map:r=>r.corp||"-"},
{key:"status",map:r=>r.status||"-"},
{key:"outsource",map:outsourceFilterLabel},
{key:"amount",map:amountFilterLabel},
{key:"collected",map:collectedFilterLabel},
{key:"rate",map:rateFilterLabel}
];
}
function closeFilterMenus(){
Object.values(E.filterMenus).forEach(menu=>menu.classList.remove("open"));
Object.values(E.filterButtons).forEach(btn=>btn.classList.remove("open"));
}
function updateFilterButtons(){
FILTER_KEYS.forEach(key=>{
const btn=E.filterButtons[key];
if(!btn) return;
const active=!!S.filters[key];
btn.classList.toggle("active",active);
btn.title=active?`${btn.dataset.label}: ${S.filters[key]}`:btn.dataset.label||"";
const mark=btn.querySelector(".th-mark");
if(mark) mark.textContent=active?"•":"";
});
}
function renderFilterMenu(key,values){
const menu=E.filterMenus[key];
if(!menu) return;
const current=String(S.filters[key]||"");
menu.innerHTML=`<button type="button" class="th-option${!current?" active":""}" data-filter-value="">전체</button>`+values.map(v=>`<button type="button" class="th-option${current===v?" active":""}" data-filter-value="${escAttr(v)}">${esc(v)}</button>`).join("");
}
function syncColumnFilters(rows){
filterDefinitions().forEach(def=>{
const values=uniqueFilterValues(rows,def.map);
if(S.filters[def.key]&&!values.includes(S.filters[def.key])) delete S.filters[def.key];
renderFilterMenu(def.key,values);
});
updateFilterButtons();
}
function toggleFilterMenu(key){
const menu=E.filterMenus[key],btn=E.filterButtons[key];
if(!menu||!btn) return;
const willOpen=!menu.classList.contains("open");
closeFilterMenus();
if(willOpen){
menu.classList.add("open");
btn.classList.add("open");
}
}
function setFilterValue(key,value){
if(value) S.filters[key]=value;
else delete S.filters[key];
syncColumnFilters(S.all);
closeFilterMenus();
filter();
}
function matchesColumnFilters(r){
if(S.filters.code&&codeFilterLabel(r)!==S.filters.code) return false;
if(S.filters.name&&(r.name||"-")!==S.filters.name) return false;
if(S.filters.corp&&(r.corp||"-")!==S.filters.corp) return false;
if(S.filters.status&&(r.status||"-")!==S.filters.status) return false;
if(S.filters.outsource&&outsourceFilterLabel(r)!==S.filters.outsource) return false;
if(S.filters.amount&&amountFilterLabel(r)!==S.filters.amount) return false;
if(S.filters.collected&&collectedFilterLabel(r)!==S.filters.collected) return false;
if(S.filters.rate&&rateFilterLabel(r)!==S.filters.rate) return false;
return true;
}
function setText(id,v){const el=G(id);if(el)el.textContent=v||"-";}
function renderPaymentsHtml(payments){
if(!payments||!payments.length) return '<div class="pay-empty">대금 차수 정보가 없습니다.</div>';
return payments.map(p=>`<div class="pay-item"><div class="pay-head"><div class="pay-name">${esc(p.pay||"미입력")}</div><div style="font-size:11px;color:#64748b;font-weight:800;white-space:nowrap">${esc(p.status||"-")}</div></div><div class="pay-meta"><span>발행일 ${esc(d(p.issueDate))}</span><span>수금일 ${esc(d(p.collectDate))}</span><span>공급가액 ${esc(won(p.supply))}</span><span>수금금액 ${esc(won(p.collected))}</span></div>${p.note?`<div class="pay-note">비고: ${esc(p.note)}</div>`:""}</div>`).join("");
}
function renderOutsourcePayments(payments){
const list=(payments||[]).filter(payment=>payment&&(payment.label||payment.note||payment.invoiceDate||payment.paymentDate||payment.paymentEx||payment.remainingEx||payment.paymentIn||payment.remainingIn));
if(!list.length) return "";
return `<div class="out-payments">${list.map((payment,index)=>`<div class="out-payment"><div class="out-payment-head"><span>${esc(payment.label||`\uC9C0\uAE09 ${index+1}`)}</span><span>${esc(payment.paymentDate?d(payment.paymentDate):"-")}</span></div><div class="out-payment-meta"><span>\uACC4\uC0B0\uC11C\uC77C\uC790 ${esc(payment.invoiceDate?d(payment.invoiceDate):"-")}</span><span>\uC9C0\uAE09\uAE08\uC561 ${esc(payment.paymentEx?won(payment.paymentEx):"-")}</span><span>\uC794\uC5EC\uAE08\uC561 ${esc(payment.remainingEx||payment.remainingEx===0?won(payment.remainingEx):"-")}</span></div>${payment.note?`<div class="out-note">\uBE44\uACE0: ${esc(payment.note)}</div>`:""}</div>`).join("")}</div>`;
}
function countOutsourceStages(r){
return (r.outsourceItems||[]).reduce((sum,item)=>{
const stages=(item.payments||[]).filter(payment=>payment&&(payment.label||payment.note||payment.invoiceDate||payment.paymentDate||payment.paymentEx||payment.remainingEx||payment.paymentIn||payment.remainingIn));
return sum+(stages.length||1);
},0);
}
function summarizeOutsourceCounts(r){
const vendors=(r.outsourceVendors||[]).length;
const contracts=(r.outsourceItems||[]).length;
const stages=countOutsourceStages(r);
const parts=[];
if(vendors) parts.push(`외주처 ${vendors.toLocaleString("ko-KR")}`);
if(contracts) parts.push(`계약 ${contracts.toLocaleString("ko-KR")}`);
if(stages) parts.push(`지급단계 ${stages.toLocaleString("ko-KR")}`);
return parts.join(" · ")||"외주 내역 없음";
}
function renderOutsourceHtml(items){
if(!items||!items.length) return '<div class="pay-empty">외주 상세 정보가 없습니다.</div>';
return items.map(item=>{
const stageCount=(item.payments||[]).filter(payment=>payment&&(payment.label||payment.note||payment.invoiceDate||payment.paymentDate||payment.paymentEx||payment.remainingEx||payment.paymentIn||payment.remainingIn)).length;
const stageText=stageCount?`지급단계 ${stageCount.toLocaleString("ko-KR")}`:"지급내역 없음";
const periodText=(d(item.startDate)==="-"&&d(item.endDate)==="-")?"-":`${d(item.startDate)} ~ ${d(item.endDate)}`;
return `<div class="out-item"><div class="out-head"><div><div class="out-vendor">${esc(item.vendor||"외주")}</div><div class="out-name">${esc(item.detail||"-")}</div></div><div style="font-size:11px;color:#64748b;font-weight:800;white-space:nowrap">${esc(item.progress||stageText)}</div></div><div class="out-meta"><span>계약기간 ${esc(periodText)}</span><span>계약금액 ${esc(item.contractEx?won(item.contractEx):"-")}</span><span>지급금액 ${esc(item.paidEx||item.paidEx===0?won(item.paidEx):"-")}</span><span>잔여금액 ${esc(item.remainingEx||item.remainingEx===0?won(item.remainingEx):"-")}</span><span>계산서일자 ${esc(item.invoiceDate?d(item.invoiceDate):"-")}</span><span>${esc(stageText)}</span></div>${item.note?`<div class="out-note">비고: ${esc(item.note)}</div>`:""}${renderOutsourcePayments(item.payments||[])}</div>`;
}).join("");
}
function renderContactCompact(label,name,company,dept,phone,email){
return `<div class="summary-card"><div class="summary-label">${esc(label)}</div><div style="margin-top:6px;font-size:16px;font-weight:900">${esc(name||"-")}</div><div class="summary-note">${esc([company||"-",dept||"-"].join(" · "))}</div><div class="summary-note">${esc(`전화 ${phone||"-"} / 메일 ${email||"-"}`)}</div></div>`;
}
function renderOutsourceBoard(r){
const items=r.outsourceItems||[];
if(!items.length){
return `<div class="ledger-block outsource"><div class="ledger-head"><div class="ledger-head-left"><div class="ledger-icon">O</div><div><div class="ledger-name">외주 계약 / 지급 현황</div><div class="ledger-sub">등록된 외주 데이터 없음</div></div></div><div class="ledger-pill">총 계약 0원</div></div><div class="ledger-empty">외주 상세 정보가 없습니다.</div></div>`;
}
return `<div class="ledger-block outsource"><div class="ledger-head"><div class="ledger-head-left"><div class="ledger-icon">O</div><div><div class="ledger-name">외주 계약 / 지급 현황</div><div class="ledger-sub">VAT 별도</div></div></div><div class="ledger-pill">총 계약 ${esc(r.outsourceCost?won(r.outsourceCost):"-")}</div></div><div class="ledger-table-wrap"><table class="ledger-table"><thead><tr><th>외주처 / 계약명</th><th>계약기간</th><th style="text-align:right">계약금액</th><th style="text-align:right">지급금액</th><th style="text-align:right">잔여금액</th><th>진행현황</th><th>비고</th></tr></thead><tbody>${items.map(item=>{const periodText=(d(item.startDate)==="-"&&d(item.endDate)==="-")?"-":`${d(item.startDate)} ~ ${d(item.endDate)}`;const noteLines=(item.payments||[]).map(payment=>{const label=String(payment.label||"").trim();const note=String(payment.note||"").trim();if(!label&&!note) return "";if(label&&note) return `${label}: ${note}`;return label||note;}).filter(Boolean);if(item.note) noteLines.unshift(item.note);return `<tr><td><span class="ledger-main">${esc(item.vendor||"외주")}</span><span class="ledger-muted">${esc(item.detail||"-")}</span></td><td><span class="ledger-main">${esc(periodText)}</span></td><td class="ledger-amount">${esc(item.contractEx?won(item.contractEx):"-")}</td><td class="ledger-amount">${esc(item.paidEx||item.paidEx===0?won(item.paidEx):"-")}</td><td class="ledger-amount">${esc(item.remainingEx||item.remainingEx===0?won(item.remainingEx):"-")}</td><td><span class="ledger-note">${esc(item.progress||"-")}</span></td><td><span class="ledger-note">${esc(noteLines.join(" / ")||"-")}</span></td></tr>`;}).join("")}</tbody></table></div></div>`;
}
function renderCollectionBoard(r){
const payments=r.payments&&r.payments.length?r.payments:[{pay:r.pay||"-",issueDate:r.issueDate||"",collectDate:r.collectDateSummary||r.colDate||"",supply:r.sSup||0,collected:r.col||0,receivable:r.recv||Math.max(0,(r.sTot||0)-(r.col||0)),rate:r.rate||0,note:r.note||"",status:r.status||"-"}];
return `<div class="ledger-block collect"><div class="ledger-head"><div class="ledger-head-left"><div class="ledger-icon">C</div><div><div class="ledger-name">수금 및 기성 현황</div><div class="ledger-sub">VAT 별도</div></div></div><div class="ledger-pill">총 수금 ${esc(won(r.col))}</div></div><div class="ledger-table-wrap"><table class="ledger-table"><thead><tr><th>발행 / 수금일</th><th>구분</th><th style="text-align:right">공급가액</th><th style="text-align:right">수금금액</th><th style="text-align:right">미수금액</th><th style="text-align:right">수금율</th><th>비고</th></tr></thead><tbody>${payments.map(payment=>{const dateParts=[payment.issueDate?`발행 ${d(payment.issueDate)}`:"",payment.collectDate?`수금 ${d(payment.collectDate)}`:""].filter(Boolean);const noteParts=[];if(payment.status) noteParts.push(payment.status);if(payment.note) noteParts.push(payment.note);return `<tr><td><span class="ledger-main">${esc(dateParts[0]||"-")}</span><span class="ledger-muted">${esc(dateParts[1]||"수금일 없음")}</span></td><td><span class="ledger-main">${esc(payment.pay||"미입력")}</span></td><td class="ledger-amount">${esc(won(payment.supply||0))}</td><td class="ledger-amount">${esc(won(payment.collected||0))}</td><td class="ledger-amount">${esc(won(payment.receivable||0))}</td><td class="ledger-amount">${esc(((payment.rate||0).toFixed?payment.rate.toFixed(2):Number(payment.rate||0).toFixed(2))+"%")}</td><td><span class="ledger-note">${esc(noteParts.join(" / ")||"-")}</span></td></tr>`;}).join("")}</tbody></table></div></div>`;
}
function renderProjectInline(r){
const payments=r.payments||[];
const latestCollect=d(r.collectDateSummary||r.colDate);
const collectCountText=payments.length?`차수 ${payments.length.toLocaleString("ko-KR")}`:"수금 내역 없음";
const outsourceCountText=summarizeOutsourceCounts(r);
const hasOutsource=(r.outsourceItems||[]).length>0||(r.outsourceCost||0)>0||(r.outsourcePaid||0)>0||(r.outsourceRemaining||0)>0;
const summaryCards=[
`<div class="summary-card"><div class="summary-label">계약금</div><div class="summary-value">${esc(won(r.cSup))}</div><div class="summary-note">VAT 별도</div></div>`,
`<div class="summary-card"><div class="summary-label">수금액</div><div class="summary-value">${esc(won(r.col))}</div><div class="summary-note">${esc(latestCollect==="-"?"수금일 없음":`최종 수금일 ${latestCollect}`)}</div></div>`,
`<div class="summary-card"><div class="summary-label">수금율</div><div class="summary-value">${esc(r.rate.toFixed(2)+"%")}</div><div class="summary-note">${esc(collectCountText)}</div></div>`
].filter(Boolean).join("");
const bottomNotes=[
`<div class="summary-note">미수금액 ${esc(won(r.recv))}</div>`
].join("");
const boards=[
hasOutsource?renderOutsourceBoard(r):"",
renderCollectionBoard(r)
].filter(Boolean).join("");
return `<div class="inline-panel"><div class="project-head"><div class="inline-card"><div class="project-meta-grid"><div class="kv"><div class="kvk">계약법인</div><div class="kvv">${esc(r.corp||"-")}</div></div><div class="kv"><div class="kvk">발주처</div><div class="kvv">${esc(r.client||"-")}</div></div><div class="kv"><div class="kvk">발주방법</div><div class="kvv">${esc(r.order||"-")}</div></div><div class="kv"><div class="kvk">PM</div><div class="kvv">${esc(r.pm||"-")}</div></div></div><div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:10px">${renderContactCompact("계약 / 청구 담당자",r.cmNm,r.cmCo,r.cmDp,r.cmPh,r.cmEm)}${renderContactCompact("부서 담당자",r.dmNm,r.dmCo,r.dmDp,r.dmPh,r.dmEm)}</div></div><div class="inline-card"><div class="summary-grid">${summaryCards}</div><div style="margin-top:10px" class="progress"><div class="bar" style="width:${Math.max(0,Math.min(100,r.rate||0))}%"></div></div><div style="display:flex;justify-content:space-between;gap:10px;margin-top:10px">${bottomNotes}</div></div></div><div class="ledger-stack">${boards}</div></div>`;
}
function closeAllModals(){
E.collectModal.classList.remove("show");
E.outsourceModal.classList.remove("show");
}
function toggleInlineDetail(r){
const key=rowKey(r);
S.expanded.key=S.expanded.key===key?"":key;
render();
}
function openCollectionModal(r){
setText("mCat",r.cat||"미분류");G("mCat").classList.toggle("ok",(r.status||"").includes("완료"));setText("mTitle",r.name||"-");setText("mSub","Project Code: "+(r.code||"-")+" · 계약법인: "+(r.corp||"-"));
setText("mClient",r.client||"-");setText("mOrder",r.order||"-");setText("mSplit",r.split||"-");setText("mStartDate",d(r.sDate));setText("mEndDate",d(r.eDate));setText("mPayType",r.pay||"-");G("mPayItems").innerHTML=renderPaymentsHtml(r.payments||[]);
setText("mContractTotal",won(r.cTot));setText("mContractSupply","공급가액: "+won(r.cSup));setText("mCollected",won(r.col));setText("mCollectDate",(r.payments&&r.payments.length>1?"최근 수금일: ":"수금일: ")+d(r.collectDateSummary||r.colDate));setText("mRate",r.rate.toFixed(2)+"%");setText("mReceivable",won(r.recv));G("mRateBar").style.width=Math.max(0,Math.min(100,r.rate||0))+"%";
setText("mCmName",r.cmNm||"-");setText("mCmOrg",(r.cmCo||"-")+" · "+(r.cmDp||"-"));setText("mCmPhone","전화: "+(r.cmPh||"-"));setText("mCmEmail","메일: "+(r.cmEm||"-"));
setText("mDmName",r.dmNm||"-");setText("mDmOrg",(r.dmCo||"-")+" · "+(r.dmDp||"-"));setText("mDmPhone","전화: "+(r.dmPh||"-"));setText("mDmEmail","메일: "+(r.dmEm||"-"));
closeAllModals();
E.collectModal.classList.add("show");
}
function openOutsourceModal(r){
setText("oTitle",r.name||"-");
setText("oSub","Project Code: "+(r.code||"-")+" · PM: "+(r.pm||"-"));
setText("oCorp",r.corp||"-");
setText("oClient",r.client||"-");
setText("oVendors",r.outsourceVendorText||"-");
setText("oTotal",r.outsourceCost?won(r.outsourceCost):"-");
setText("oCount",(r.outsourceItems||[]).length?`${(r.outsourceItems||[]).length.toLocaleString("ko-KR")}`:"0건");
setText("oPeriod",r.periodText||"-");
setText("oTotalHero",r.outsourceCost?won(r.outsourceCost):"-");
setText("oTotalHint",(r.outsourceItems||[]).length?"시트별 외주 상세 내역 합산":"외주 상세 정보가 없습니다.");
G("oItems").innerHTML=renderOutsourceHtml(r.outsourceItems||[]);
closeAllModals();
E.outsourceModal.classList.add("show");
}
function outsourceSummaryText(r){
const contracts=(r.outsourceItems||[]).length;
const stages=countOutsourceStages(r);
const parts=[];
if(contracts) parts.push(`계약 ${contracts.toLocaleString("ko-KR")}`);
if(stages) parts.push(`지급단계 ${stages.toLocaleString("ko-KR")}`);
if(parts.length) return parts.join(" · ");
return "-";
}
function render(){
const rows=S.rows,t=sumRows(rows),viewRows=rows.slice().sort((a,b)=>{const as=isSettledRow(a),bs=isSettledRow(b);if(as!==bs)return as?1:-1;return (b.recv||0)-(a.recv||0);});
const useSheetTotals=!!(S.totals&&!hasActiveDashboardFilters());
const totalContract=useSheetTotals?S.totals.contract:t.c;
const totalCollected=useSheetTotals?S.totals.collected:t.col;
const totalReceivable=useSheetTotals?S.totals.receivable:t.recv;
const totalRate=useSheetTotals?S.totals.rate:rate("",totalCollected,totalCollected+totalReceivable);
S.viewRows=viewRows;
E.cards.innerHTML=[["총 프로젝트수",rows.length.toLocaleString("ko-KR")+" 건"],["총 계약금",won(totalContract)],["총 수금금액",won(totalCollected)],["총 미수금액",won(totalReceivable)],["총 수금율",totalRate.toFixed(2)+"%"]].map(c=>`<div class="card"><div class="k">${esc(c[0])}</div><div class="v">${esc(c[1])}</div></div>`).join("");
E.tbody.innerHTML=viewRows.map((r,i)=>{
const key=rowKey(r);
const detailOpen=S.expanded.key===key;
const detailHtml=detailOpen?renderProjectInline(r):"";
return `<tr data-i="${i}" class="${isSettledRow(r)?"settled":""}"><td><div class="badge">${esc(r.cat||"-")}</div><div class="subline">ID: ${esc(r.code||"-")}</div></td><td><div class="name">${esc(r.name||"-")}</div><div class="subline">${esc(r.periodText||"-")}</div></td><td><div>${esc(r.corp||"-")}</div></td><td><div class="badge ${(r.status||"").includes("완료")?"ok":""}">${esc(r.status||"-")}</div><div class="subline">${esc(r.yn||"-")}</div></td><td class="num"><strong>${esc(r.outsourceCost?won(r.outsourceCost):"-")}</strong></td><td class="num"><strong>${esc(won(r.cSup))}</strong></td><td class="num"><strong>${esc(won(r.col))}</strong></td><td class="num"><strong style="color:${isSettledRow(r)?"#94a3b8":"#2563eb"}">${esc(r.rate.toFixed(2)+"%")}</strong></td></tr>${detailHtml?`<tr class="detail-row"><td class="detail-cell" colspan="8">${detailHtml}</td></tr>`:""}`;
}).join("");
E.empty.style.display=rows.length?"none":"block";
const settledCount=S.all.filter(isSettledRow).length;
E.status.textContent=S.all.length?`로드 완료: ${S.all.length.toLocaleString("ko-KR")}${S.file?` · 파일: ${S.file}`:""}${settledCount?` · 완납 ${settledCount.toLocaleString("ko-KR")}건 하단 정렬`:""}`:"CSV/XLSX 파일을 업로드하면 데이터가 표시됩니다.";
}
function filter(){const q=String(E.search.value||"").trim().toLowerCase();const searched=!q?S.all.slice():S.all.filter(r=>[r.code,r.name,r.client,r.pm,r.status,r.cat,r.corp,r.pay,(r.payments||[]).map(p=>p.pay).join(" "),r.periodText,r.outsourceVendorText,(r.outsourceItems||[]).map(item=>[item.vendor,item.detail,item.progress,item.note,(item.payments||[]).map(payment=>[payment.label,payment.note,payment.invoiceDate,payment.paymentDate].join(" ")).join(" ")].join(" ")).join(" "),outsourceFilterLabel(r),amountFilterLabel(r),collectedFilterLabel(r)].join(" ").toLowerCase().includes(q));S.rows=searched.filter(matchesColumnFilters);render();}
function applyParsedLedgerResult(fileName,parsed,sheetName){
S.all=parsed.records;
S.totals=parsed.totals||null;
S.file=(fileName||"")+(sheetName?` [${sheetName}]`:"");
syncColumnFilters(S.all);
filter();
}
async function loadLedgerFile(buffer,fileName){
const isExcel=/\.(xlsx|xls)$/i.test(String(fileName||""));
if(isExcel){
const parsed=parseLedgerExcel(buffer);
applyParsedLedgerResult(fileName,parsed,parsed.sheetName||"");
return;
}
const parsed=parseLedger(decode(buffer));
applyParsedLedgerResult(fileName,parsed,"");
}
E.btnUpload.addEventListener("click",()=>E.file.click());
E.file.addEventListener("change",async e=>{
const f=e.target.files&&e.target.files[0];
try{
if(f){
const buf=await f.arrayBuffer();
await loadLedgerFile(buf,f.name||"");
}
}catch(err){
S.all=[];S.rows=[];S.totals=null;syncColumnFilters([]);closeAllModals();render();E.status.textContent="업로드 실패: "+(err&&err.message?err.message:String(err));
}
e.target.value="";
});
E.search.addEventListener("input",filter);
Object.values(E.filterButtons).forEach(btn=>btn.addEventListener("click",e=>{e.stopPropagation();toggleFilterMenu(btn.dataset.filter);}));
Object.values(E.filterMenus).forEach(menu=>menu.addEventListener("click",e=>{
e.stopPropagation();
const option=e.target&&e.target.closest?e.target.closest("button[data-filter-value]"):null;
if(!option) return;
setFilterValue(menu.dataset.filter,option.getAttribute("data-filter-value")||"");
}));
E.tbody.addEventListener("click",e=>{
const rowEl=e.target&&e.target.closest?e.target.closest("tr[data-i]"):null;
if(!rowEl) return;
const r=S.viewRows[parseInt(rowEl.getAttribute("data-i"),10)];
if(!r) return;
toggleInlineDetail(r);
});
E.btnCollectClose.addEventListener("click",closeAllModals);
E.btnOutsourceClose.addEventListener("click",closeAllModals);
E.collectModal.addEventListener("click",e=>{if(e.target===E.collectModal)closeAllModals();});
E.outsourceModal.addEventListener("click",e=>{if(e.target===E.outsourceModal)closeAllModals();});
document.addEventListener("click",e=>{if(!(e.target&&e.target.closest&&e.target.closest(".th-head")))closeFilterMenus();});
document.addEventListener("keydown",e=>{if(e.key==="Escape"){closeFilterMenus();closeAllModals();}});
window.addEventListener("message",async e=>{
const data=e.data||{};
if(data.source==="total-control"&&data.type==="embedded-host") E.btnUpload.style.display="none";
if(data.source!=="total-upload"||data.type!=="business") return;
try{
const buffer=data.buffer instanceof ArrayBuffer?data.buffer:(data.buffer&&data.buffer.buffer instanceof ArrayBuffer?data.buffer.buffer:null);
if(!buffer) throw new Error("업로드 데이터가 비어 있습니다.");
await loadLedgerFile(buffer,data.fileName||"사업관리대장.xlsx");
}catch(err){
S.all=[];S.rows=[];S.totals=null;syncColumnFilters([]);closeAllModals();render();E.status.textContent="업로드 실패: "+(err&&err.message?err.message:String(err));
}
});
syncColumnFilters([]);
render();
</script>
<script src="/integrations/ledger-assets/ledger-override.js?v=20260401-03"></script></body>
</html>

View File

@@ -0,0 +1,328 @@
html,
body {
margin: 0;
padding: 0;
}
body.mh-business-theme {
overflow-x: hidden;
background:
radial-gradient(circle at top left, rgba(214, 138, 58, 0.16), transparent 24%),
radial-gradient(circle at top right, rgba(47, 153, 115, 0.10), transparent 20%),
linear-gradient(180deg, #f6efe6 0%, #f1eadf 100%);
}
body.mh-business-theme .wrap {
width: min(100%, 2000px);
max-width: 2000px;
margin: 0 auto;
padding: 18px 18px 26px;
box-sizing: border-box;
}
body.mh-business-theme .top,
body.mh-business-theme .status {
display: none !important;
}
body.mh-business-theme .cards {
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
gap: 14px;
margin: 0 0 16px;
}
body.mh-business-theme .business-shell {
width: 100%;
box-sizing: border-box;
margin-top: 2px;
padding: 18px;
border-radius: 32px;
background:
radial-gradient(circle at 16% 14%, rgba(255,255,255,0.05), transparent 18%),
radial-gradient(circle at 88% 8%, rgba(255,255,255,0.04), transparent 16%),
linear-gradient(145deg, #0b352b 0%, #174e41 52%, #245f50 100%);
box-shadow: 0 26px 54px rgba(15, 58, 47, 0.16);
border: 1px solid rgba(255,255,255,0.08);
}
body.mh-business-theme .cards-toolbar {
grid-column: 1 / -1;
display: flex;
flex-direction: column;
gap: 14px;
padding: 10px 0 2px;
}
body.mh-business-theme .cards-toolbar-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
body.mh-business-theme .cards-toolbar-search {
margin-left: auto;
display: flex;
align-items: center;
min-width: min(360px, 100%);
flex: 1 1 320px;
max-width: 520px;
}
body.mh-business-theme .cards-toolbar-search .search {
width: 100%;
min-width: 0;
border-radius: 999px;
border: 1px solid rgba(255,255,255,0.12);
background: rgba(255,255,255,0.10);
color: #f4efe6;
padding: 14px 18px;
font-size: 14px;
font-weight: 800;
box-shadow: inset 0 1px 0 rgba(255,255,255,0.04);
}
body.mh-business-theme .cards-toolbar-search .search::placeholder {
color: rgba(244, 239, 230, 0.74);
}
body.mh-business-theme #btnUpload {
display: none !important;
}
body.mh-business-theme .cards-toolbar-metrics {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
}
body.mh-business-theme .summary-year-chip {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 60px;
padding: 10px 16px;
border-radius: 999px;
border: 1px solid rgba(255,255,255,0.14);
background: rgba(255,255,255,0.08);
color: #f4efe6;
font-size: 12px;
font-weight: 900;
cursor: pointer;
}
body.mh-business-theme .summary-year-chip.active {
background: linear-gradient(180deg, #fff8ee 0%, #f2dec0 100%);
color: #0a2a22;
border-color: rgba(242, 196, 132, 0.58);
box-shadow: 0 12px 28px rgba(10, 42, 34, 0.18);
}
body.mh-business-theme .summary-filter-chip {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
width: 100%;
min-height: 98px;
padding: 18px 22px;
border-radius: 999px;
border: 1px solid rgba(255,255,255,0.14);
background: linear-gradient(180deg, rgba(255,255,255,0.10) 0%, rgba(255,255,255,0.07) 100%);
color: #f4efe6;
box-shadow: inset 0 1px 0 rgba(255,255,255,0.04), 0 16px 30px rgba(7, 28, 22, 0.14);
cursor: pointer;
text-align: center;
}
body.mh-business-theme .summary-filter-chip.active {
background: linear-gradient(180deg, #fff8ee 0%, #f2dec0 100%);
color: #0a2a22;
border-color: rgba(242, 196, 132, 0.58);
}
body.mh-business-theme .summary-filter-chip .label {
color: rgba(244, 239, 230, 0.78);
font-size: 13px;
font-weight: 900;
}
body.mh-business-theme .summary-filter-chip.active .label {
color: rgba(10, 42, 34, 0.78);
}
body.mh-business-theme .summary-filter-chip .count {
color: #fff7e6;
font-size: 32px;
line-height: 1;
font-weight: 900;
}
body.mh-business-theme .summary-filter-chip.active .count {
color: #b86b1f;
}
body.mh-business-theme .summary-filter-chip .meta {
color: #f2c484;
font-size: 11px;
font-weight: 800;
text-align: center;
}
body.mh-business-theme .summary-filter-chip.active .meta {
color: #7c5a20;
}
body.mh-business-theme .card {
grid-column: span 2;
min-height: 110px;
border-radius: 24px;
border: 1px solid rgba(217, 197, 168, 0.55);
background: linear-gradient(180deg, rgba(255,250,243,0.96) 0%, rgba(248,242,232,0.96) 100%);
padding: 18px 20px;
box-shadow: 0 18px 32px rgba(15, 58, 47, 0.08);
}
body.mh-business-theme .card.management {
grid-column: span 2;
}
body.mh-business-theme .card .k {
color: #5b6d63;
font-size: 12px;
font-weight: 900;
}
body.mh-business-theme .card .v {
margin-top: 8px;
color: #17392f;
font-size: 30px;
font-weight: 900;
}
body.mh-business-theme .card .n {
margin-top: 8px;
color: #7b6953;
font-size: 11px;
font-weight: 700;
}
body.mh-business-theme .panel {
border-radius: 28px;
border: 1px solid rgba(217, 197, 168, 0.55);
box-shadow: 0 18px 32px rgba(15, 58, 47, 0.08);
}
body.mh-business-theme .table-wrap {
width: 100%;
max-width: 100%;
border-radius: 28px;
overflow-x: hidden !important;
}
body.mh-business-theme .table-vat-note {
display: none !important;
}
body.mh-business-theme table {
width: 100% !important;
min-width: 0 !important;
table-layout: fixed;
background: rgba(255, 250, 243, 0.96);
}
body.mh-business-theme thead th {
background: #0f352b;
color: #fff5e6;
border-right: 1px solid rgba(242, 196, 132, 0.2);
}
body.mh-business-theme tbody td {
background: rgba(255, 250, 243, 0.96);
}
body.mh-business-theme .group-row td {
padding: 12px 14px 10px;
background: linear-gradient(180deg, rgba(255, 248, 238, 0.98) 0%, rgba(242, 222, 192, 0.78) 100%);
border-top: 1px solid rgba(214, 138, 58, 0.26);
border-bottom: 1px solid rgba(217, 197, 168, 0.54);
}
body.mh-business-theme .group-chip {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
border-radius: 999px;
background: rgba(255, 250, 243, 0.98);
border: 1px solid rgba(214, 138, 58, 0.3);
color: #17392f;
font-size: 12px;
font-weight: 900;
box-shadow: 0 8px 18px rgba(15, 58, 47, 0.08);
cursor: pointer;
}
body.mh-business-theme .group-chip .group-toggle {
margin-left: 4px;
width: 22px;
height: 22px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
background: rgba(242, 196, 132, 0.18);
color: #b66e22;
font-size: 14px;
line-height: 1;
}
body.mh-business-theme .project-link {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0;
border: 0;
background: none;
color: #17392f;
font: inherit;
font-weight: 900;
text-align: left;
cursor: pointer;
}
body.mh-business-theme .project-link:hover {
color: #0f6a55;
}
@media (max-width: 1280px) {
body.mh-business-theme .cards-toolbar-metrics {
grid-template-columns: 1fr;
}
body.mh-business-theme .card {
grid-column: span 4;
}
}
@media (max-width: 880px) {
body.mh-business-theme .wrap {
padding: 12px 12px 20px;
}
body.mh-business-theme .cards {
grid-template-columns: 1fr;
}
body.mh-business-theme .card {
grid-column: auto;
}
body.mh-business-theme .cards-toolbar-search {
margin-left: 0;
max-width: none;
flex-basis: 100%;
}
}

View File

@@ -0,0 +1,498 @@
(function () {
window.__mhLedgerEnhancementLoaded = false;
if (typeof S === "undefined" || typeof E === "undefined" || typeof render !== "function") return;
window.__mhLedgerEnhancementLoaded = true;
if (!S.dashboard) S.dashboard = { year: "", section: "active" };
if (!S.collapsedGroups) S.collapsedGroups = {};
function bgToday() {
var now = new Date();
return new Date(now.getFullYear(), now.getMonth(), now.getDate());
}
function bgParseDate(value) {
var text = String(value || "").trim();
if (!text) return null;
var match = text.match(/(20\d{2})\D?(\d{1,2})\D?(\d{1,2})/);
if (match) {
var parsed = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
return isNaN(parsed.getTime()) ? null : parsed;
}
var fallback = new Date(text);
if (isNaN(fallback.getTime())) return null;
return new Date(fallback.getFullYear(), fallback.getMonth(), fallback.getDate());
}
function bgYearFromText(value) {
var match = String(value || "").trim().match(/(20\d{2})/);
return match ? match[1] : "";
}
function bgStartYear(row) {
return bgYearFromText(row && row.sDate);
}
function bgEndYear(row) {
return bgYearFromText(row && row.eDate);
}
function bgDisplayYear(row) {
var start = bgStartYear(row);
if (start) return start;
var contractMatch = String((row && row.cDate) || "").trim().match(/(20\d{2})/);
if (contractMatch) return contractMatch[1];
var nameMatch = String((row && row.name) || "").trim().match(/^(20\d{2})/);
if (nameMatch) return nameMatch[1];
return bgEndYear(row) || "미지정";
}
function bgCompletionYear(row) {
return bgEndYear(row) || bgDisplayYear(row);
}
function bgDateOrYearStart(row) {
var yearText = bgDisplayYear(row);
return bgParseDate(row && row.sDate) || bgParseDate(row && row.cDate) || (/^20\d{2}$/.test(yearText) ? new Date(Number(yearText), 0, 1) : null);
}
function bgDateOrYearEnd(row) {
var completionYear = bgCompletionYear(row);
return bgParseDate(row && row.eDate) || (/^20\d{2}$/.test(completionYear) ? new Date(Number(completionYear), 11, 31) : null);
}
function bgYearCutoff(year) {
var targetYear = Number(year || 0);
if (!targetYear) return null;
var today = bgToday();
if (targetYear < today.getFullYear()) return new Date(targetYear, 11, 31);
if (targetYear === today.getFullYear()) return today;
return null;
}
function bgYearStartDate(year) {
var targetYear = Number(year || 0);
return targetYear ? new Date(targetYear, 0, 1) : null;
}
function bgActiveInYear(row, year) {
var cutoff = bgYearCutoff(year);
var yearStart = bgYearStartDate(year);
var startDate = bgDateOrYearStart(row);
var endDate = bgDateOrYearEnd(row);
if (!(cutoff && yearStart && startDate)) return false;
if (startDate > cutoff) return false;
if (endDate && endDate < yearStart) return false;
return !(endDate && endDate <= cutoff);
}
function bgStartedInYear(row, year) {
var cutoff = bgYearCutoff(year);
var startDate = bgDateOrYearStart(row);
if (!(cutoff && startDate)) return false;
return startDate.getFullYear() === Number(year || 0) && startDate <= cutoff;
}
function bgCompletedInYear(row, year) {
var cutoff = bgYearCutoff(year);
var endDate = bgDateOrYearEnd(row);
if (!(cutoff && endDate)) return false;
return endDate.getFullYear() === Number(year || 0) && endDate <= cutoff;
}
function bgYearRange(row) {
var years = [];
var startYear = Number(bgDisplayYear(row) || 0);
var endYear = Number(bgCompletionYear(row) || 0);
if (startYear && endYear && endYear >= startYear) {
for (var year = startYear; year <= endYear; year += 1) years.push(String(year));
} else if (startYear) {
years.push(String(startYear));
}
return years;
}
function bgYears(rows) {
var currentYear = new Date().getFullYear();
var years = Array.from(new Set((Array.isArray(rows) ? rows : []).flatMap(bgYearRange).filter(function (year) {
return /^20\d{2}$/.test(year);
}))).sort(function (a, b) {
return Number(b) - Number(a);
});
years = years.filter(function (year) {
var numericYear = Number(year);
return numericYear >= 2018 && numericYear <= currentYear;
});
return years.length ? years : [String(currentYear)];
}
function bgEnsureYear(rows) {
var years = bgYears(rows);
if (!years.includes(S.dashboard.year)) S.dashboard.year = years[0];
return years;
}
function bgTotals(targetRows) {
return (Array.isArray(targetRows) ? targetRows : []).reduce(function (acc, row) {
acc.c += Number((row && row.cSup) || 0);
acc.col += Number((row && row.col) || 0);
acc.recv += Number((row && row.recv) || 0);
return acc;
}, { c: 0, col: 0, recv: 0 });
}
function isSupportServiceRow(row) {
var category = String((row && row.cat) || "").trim();
return category.indexOf("경영지원") >= 0 || category.indexOf("서비스") >= 0;
}
function isBaronProjectRow(row) {
var category = String((row && row.cat) || "").trim();
if (category.indexOf("바론") < 0) return false;
if (isSupportServiceRow(row)) return false;
return true;
}
function bgSummarize(rows, selectedYear) {
var items = Array.isArray(rows) ? rows : [];
var targetYear = selectedYear || bgEnsureYear(items)[0];
var activeRows = items.filter(function (row) { return bgActiveInYear(row, targetYear); });
var newProjectRows = items.filter(function (row) { return bgStartedInYear(row, targetYear); });
var completedRows = items.filter(function (row) { return bgCompletedInYear(row, targetYear); });
var managementRows = newProjectRows.filter(isSupportServiceRow);
return {
targetYear: targetYear,
activeRows: activeRows,
newProjectRows: newProjectRows,
completedRows: completedRows,
managementRows: managementRows,
managementTotals: bgTotals(managementRows)
};
}
function bgMatches(row) {
var section = S.dashboard.section || "active";
var selectedYear = S.dashboard.year || bgEnsureYear(S.all)[0];
if (section === "new") return bgStartedInYear(row, selectedYear);
if (section === "completed") return bgCompletedInYear(row, selectedYear);
return bgActiveInYear(row, selectedYear);
}
function normalizeStatusLabel(status) {
var value = String(status || "").trim();
if (!value) return "-";
if (value.indexOf("진행") >= 0) return "과업 진행중";
return value;
}
function formatSplitPercent(split) {
var numeric = parseFloat(String(split || "").replace(/[^0-9.\-]/g, ""));
if (!Number.isFinite(numeric) || numeric === 0) return "분담율 -%";
return "분담율 " + numeric.toFixed(2) + "%";
}
function projectYear(row) {
var start = String((row && row.sDate) || "").trim();
var startMatch = start.match(/(20\d{2})/);
if (startMatch) return startMatch[1];
var name = String((row && row.name) || "").trim();
var nameMatch = name.match(/^(20\d{2})/);
if (nameMatch) return nameMatch[1];
var end = String((row && row.eDate) || "").trim();
var endMatch = end.match(/(20\d{2})/);
if (endMatch) return endMatch[1];
return "미지정";
}
function groupSortRank(row) {
var selectedYear = Number((S.dashboard && S.dashboard.year) || projectYear(row) || 0);
var startYear = Number(projectYear(row) || 0);
if (typeof bgCompletedInYear === "function" && bgCompletedInYear(row, String(selectedYear))) return 9999;
if (!startYear) return 9998;
return startYear;
}
function tableGroupLabel(row) {
var startYear = projectYear(row);
if (/^20\d{2}$/.test(startYear)) return startYear + "년";
return "미지정";
}
function renderLedgerTable() {
var table = document.querySelector(".panel table");
if (!table || !E.tbody) return;
var thead = table.querySelector("thead");
if (thead) {
thead.innerHTML = '<tr>'
+ '<th><div class="th-head"><button type="button" class="th-trigger" data-filter="cat" data-label="구분"><span class="th-title">구분</span><span class="th-mark"></span><span class="th-caret">▼</span></button><div id="filterCatMenu" class="th-menu" data-filter="cat"></div></div></th>'
+ '<th><div class="th-head"><button type="button" class="th-trigger" data-filter="code" data-label="사업코드"><span class="th-title">사업코드</span><span class="th-mark"></span><span class="th-caret">▼</span></button><div id="filterCodeMenu" class="th-menu" data-filter="code"></div></div></th>'
+ '<th><div class="th-head"><button type="button" class="th-trigger" data-filter="name" data-label="사업명(계약명)"><span class="th-title">사업명(계약명)</span><span class="th-mark"></span><span class="th-caret">▼</span></button><div id="filterNameMenu" class="th-menu" data-filter="name"></div></div></th>'
+ '<th><div class="th-head"><button type="button" class="th-trigger" data-filter="client" data-label="발주처(계약처)"><span class="th-title">발주처(계약처)</span><span class="th-mark"></span><span class="th-caret">▼</span></button><div id="filterClientMenu" class="th-menu" data-filter="client"></div></div></th>'
+ '<th><div class="th-head"><button type="button" class="th-trigger" data-filter="order" data-label="발주방법"><span class="th-title">발주방법</span><span class="th-mark"></span><span class="th-caret">▼</span></button><div id="filterOrderMenu" class="th-menu" data-filter="order"></div></div></th>'
+ '<th><div class="th-head"><button type="button" class="th-trigger" data-filter="status" data-label="진행상태"><span class="th-title">진행상태</span><span class="th-mark"></span><span class="th-caret">▼</span></button><div id="filterStatusMenu" class="th-menu" data-filter="status"></div></div></th>'
+ '<th class="num"><div class="th-head end"><button type="button" class="th-trigger" data-filter="amount" data-label="계약금"><span class="th-title">계약금</span><span class="th-mark"></span><span class="th-caret">▼</span></button><div id="filterAmountMenu" class="th-menu" data-filter="amount"></div></div></th>'
+ '<th class="num"><div class="th-head end"><button type="button" class="th-trigger" data-filter="outsource" data-label="외주비"><span class="th-title">외주비</span><span class="th-mark"></span><span class="th-caret">▼</span></button><div id="filterOutsourceMenu" class="th-menu" data-filter="outsource"></div></div></th>'
+ '<th class="num"><div class="th-head end"><button type="button" class="th-trigger" data-filter="receivable" data-label="미수금"><span class="th-title">미수금</span><span class="th-mark"></span><span class="th-caret">▼</span></button><div id="filterReceivableMenu" class="th-menu" data-filter="receivable"></div></div></th>'
+ '<th class="num"><div class="th-head end"><button type="button" class="th-trigger" data-filter="collected" data-label="수금액"><span class="th-title">수금액</span><span class="th-mark"></span><span class="th-caret">▼</span></button><div id="filterCollectedMenu" class="th-menu" data-filter="collected"></div></div></th>'
+ '<th class="num"><div class="th-head end"><button type="button" class="th-trigger" data-filter="rate" data-label="수금률"><span class="th-title">수금률</span><span class="th-mark"></span><span class="th-caret">▼</span></button><div id="filterRateMenu" class="th-menu" data-filter="rate"></div></div></th>'
+ "</tr>";
}
var rows = (Array.isArray(S.viewRows) ? S.viewRows : []).slice().sort(function (a, b) {
var ar = groupSortRank(a);
var br = groupSortRank(b);
if (ar !== br) return ar - br;
return Number(b.recv || 0) - Number(a.recv || 0);
});
S.viewRows = rows;
var lastGroupLabel = "";
E.tbody.innerHTML = rows.map(function (r) {
var groupLabel = tableGroupLabel(r);
var isCollapsed = !!S.collapsedGroups[groupLabel];
var groupRow = "";
if (groupLabel !== lastGroupLabel) {
groupRow = '<tr class="group-row"><td colspan="11"><button type="button" class="group-chip" data-group-label="' + escAttr(groupLabel) + '"><span>' + esc(groupLabel) + '</span><span class="group-toggle" aria-hidden="true">' + (isCollapsed ? "" : "") + "</span></button></td></tr>";
lastGroupLabel = groupLabel;
}
if (isCollapsed) return groupRow;
return groupRow + '<tr class="' + (isSettledRow(r) ? 'settled' : '') + '">'
+ '<td><div class="badge ' + esc(String(r.cat || "").indexOf("바론") >= 0 ? 'badge-baron' : 'badge-family') + '">' + esc(r.cat || "-") + '</div></td>'
+ '<td><div class="subline" style="margin-top:0;font-size:12px;color:#66756d">' + esc(r.code || "-") + '</div></td>'
+ '<td><button type="button" class="project-link" data-project-key="' + escAttr(String(r.code || "") + "|" + String(r.name || "")) + '">' + esc(r.name || "-") + '</button><div class="subline">' + esc(r.periodText || "-") + '</div></td>'
+ '<td><div class="client-main">' + esc((r.client || "").trim() || "-") + '</div><div class="subline">' + esc(formatSplitPercent(r.split)) + '</div></td>'
+ '<td><div>' + esc(r.order || "-") + '</div></td>'
+ '<td><div class="badge ' + (String(r.status || "").indexOf("완료") >= 0 ? 'ok' : '') + '">' + esc(normalizeStatusLabel(r.status)) + '</div></td>'
+ '<td class="num"><strong>' + esc(won(r.cSup || 0)) + '</strong></td>'
+ '<td class="num"><strong>' + esc(r.outsourceCost ? won(r.outsourceCost) : "-") + '</strong></td>'
+ '<td class="num"><strong>' + esc(won(r.recv || 0)) + '</strong></td>'
+ '<td class="num"><strong>' + esc(won(r.col || 0)) + '</strong></td>'
+ '<td class="num"><strong style="color:' + (isSettledRow(r) ? '#b7aa93' : '#1a5645') + '">' + esc((Number(r.rate || 0)).toFixed(2) + "%") + '</strong></td>'
+ '</tr>';
}).join("");
}
function renderCollectionBoard(r) {
var payments = Array.isArray(r.payments) && r.payments.length ? r.payments : [{
pay: r.pay || "-",
issueDate: r.issueDate || "",
collectDate: r.collectDateSummary || r.colDate || "",
collected: r.col || 0,
receivable: r.recv || Math.max(0, Number(r.sTot || 0) - Number(r.col || 0)),
note: r.note || "",
status: r.status || ""
}];
return '<div class="ledger-block collect"><div class="ledger-head"><div class="ledger-head-left"><div class="ledger-icon">C</div><div><div class="ledger-name">수금 및 기성 현황</div><div class="ledger-sub">기성 차수별 세금계산서 발행 및 수금 내역</div></div></div><div class="ledger-pill">총 수금 ' + esc(won(r.col || 0)) + '</div></div><div class="ledger-table-wrap"><table class="ledger-table"><thead><tr><th>기성 차수</th><th>세금계산서 발행일</th><th>수금일</th><th style="text-align:right">수금금액</th><th style="text-align:right">미수금액</th><th>비고</th></tr></thead><tbody>'
+ payments.map(function (payment, index) {
var noteParts = [];
if (payment.status) noteParts.push(payment.status);
if (payment.note) noteParts.push(payment.note);
return '<tr><td><span class="ledger-main">' + esc((index + 1) + "차") + '</span><span class="ledger-muted">' + esc(payment.pay || "-") + '</span></td><td><span class="ledger-main">' + esc(payment.issueDate ? d(payment.issueDate) : "-") + '</span></td><td><span class="ledger-main">' + esc(payment.collectDate ? d(payment.collectDate) : "-") + '</span></td><td class="ledger-amount">' + esc(won(payment.collected || 0)) + '</td><td class="ledger-amount" style="color:#a94832">' + esc(won(payment.receivable || 0)) + '</td><td><span class="ledger-note">' + esc(noteParts.join(" / ") || "-") + '</span></td></tr>';
}).join("")
+ "</tbody></table></div></div>";
}
function renderContactCard(label, name, company, department, phone, email) {
var hasValue = [name, company, department, phone, email].some(function (value) {
return String(value || "").trim() !== "";
});
if (!hasValue) {
return '<div class="inline-card"><div class="kvk">' + esc(label) + '</div><div class="summary-note">등록된 담당자 정보가 없습니다.</div></div>';
}
return '<div class="inline-card"><div class="kvk">' + esc(label) + '</div><div class="project-meta-grid">'
+ '<div class="kv"><div class="kvk">이름</div><div class="kvv">' + esc(name || "-") + '</div></div>'
+ '<div class="kv"><div class="kvk">소속</div><div class="kvv">' + esc(company || "-") + '</div><div class="summary-note">' + esc(department || "-") + '</div></div>'
+ '<div class="kv"><div class="kvk">연락처</div><div class="kvv">' + esc(phone || "-") + '</div></div>'
+ '<div class="kv"><div class="kvk">이메일</div><div class="kvv">' + esc(email || "-") + '</div></div>'
+ "</div></div>";
}
function renderProjectInline(r) {
var payments = Array.isArray(r.payments) ? r.payments : [];
var latestCollect = d(r.collectDateSummary || r.colDate);
var hasOutsource = (Array.isArray(r.outsourceItems) && r.outsourceItems.length > 0) || Number(r.outsourceCost || 0) > 0 || Number(r.outsourcePaid || 0) > 0 || Number(r.outsourceRemaining || 0) > 0;
var clientDisplay = typeof normalizeClientDisplay === "function" ? normalizeClientDisplay(r.client) : (String(r.client || "").trim() || "-");
var splitDisplay = typeof formatSplitDisplay === "function" ? formatSplitDisplay(r.split) : formatSplitPercent(r.split).replace("분담율 ", "");
var summaryCards = [
'<div class="summary-card"><div class="summary-label">계약금</div><div class="summary-value">' + esc(won(r.cSup || 0)) + '</div><div class="summary-note"></div></div>',
'<div class="summary-card"><div class="summary-label">수금액</div><div class="summary-value">' + esc(won(r.col || 0)) + '</div><div class="summary-note">' + esc(latestCollect === "-" ? "수금일 없음" : "최종 수금일 " + latestCollect) + '</div></div>',
'<div class="summary-card"><div class="summary-label">수금률</div><div class="summary-value">' + esc((Number(r.rate || 0)).toFixed(2) + "%") + '</div><div class="summary-note">' + esc(payments.length ? "기성 " + payments.length + "차까지 반영" : "차수 정보 없음") + '</div></div>',
'<div class="summary-card receivable"><div class="summary-label">미수금액</div><div class="summary-value">' + esc(won(r.recv || 0)) + '</div><div class="summary-note">잔여 수금 필요 금액</div></div>'
].join("");
var boards = [
hasOutsource && typeof renderOutsourceBoard === "function" ? renderOutsourceBoard(r) : "",
renderCollectionBoard(r)
].filter(Boolean).join("");
return '<div class="inline-panel"><div class="project-head project-head-grid"><div class="project-head-main"><div class="inline-card"><div class="project-meta-grid"><div class="kv"><div class="kvk">계약법인</div><div class="kvv">' + esc(r.corp || "-") + '</div></div><div class="kv"><div class="kvk">발주처</div><div class="kvv">' + esc(clientDisplay) + '</div><div class="summary-note">' + esc(splitDisplay ? "분담율 " + splitDisplay : "분담율 -") + '</div></div><div class="kv"><div class="kvk">발주방법</div><div class="kvv">' + esc(r.order || "-") + '</div></div><div class="kv"><div class="kvk">PM</div><div class="kvv">' + esc(r.pm || "-") + '</div></div></div></div><div class="inline-card"><div class="summary-grid">' + summaryCards + '</div><div class="project-progress progress"><div class="bar" style="width:' + esc(String(Math.max(0, Math.min(100, Number(r.rate || 0))))) + '%"></div></div></div></div><div class="project-contact-stack">' + renderContactCard("계약 / 청구 담당자", r.cmNm, r.cmCo, r.cmDp, r.cmPh, r.cmEm) + renderContactCard("부서 담당자", r.dmNm, r.dmCo, r.dmDp, r.dmPh, r.dmEm) + '</div></div><div class="ledger-stack">' + boards + '</div></div>';
}
function openProjectWindow(r) {
var popupKey = typeof rowKey === "function"
? rowKey(r).replace(/[^0-9a-zA-Z]/g, "_")
: String((r.code || "project") + "_" + (r.name || "")).replace(/[^0-9a-zA-Z_]/g, "_");
var popup = window.open("", "business_project_" + popupKey, "width=1600,height=980,resizable=yes,scrollbars=yes");
if (!popup) return;
var styleText = Array.from(document.querySelectorAll("style")).map(function (el) {
return el.textContent || "";
}).join("\n");
var detailHtml = renderProjectInline(r);
var pageHtml = '<!DOCTYPE html><html lang="ko"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>'
+ esc(r.name || "사업 상세")
+ '</title><link rel="stylesheet" href="/design-tokens.css?v=20260401-01"><link rel="stylesheet" href="/design-patterns.css?v=20260401-01"><style>' + styleText
+ 'body{margin:0;background:#f1eadf;color:#10251d;font-family:"Pretendard","Noto Sans KR","Malgun Gothic",sans-serif;}'
+ '.popup-wrap{max-width:1680px;margin:0 auto;padding:20px;}'
+ '@media (max-width: 1180px){.project-head-grid{grid-template-columns:1fr;}.summary-grid{grid-template-columns:repeat(2,minmax(0,1fr));}.project-meta-grid{grid-template-columns:1fr;}}'
+ '@media (max-width: 760px){.popup-wrap{padding:14px;}.summary-grid{grid-template-columns:1fr;}.ledger-head{flex-direction:column;align-items:flex-start;}.ledger-pill{white-space:normal;}.ledger-table-wrap{padding:0 10px 12px;overflow-x:auto;}}'
+ '</style></head><body><div class="popup-wrap"><div class="popup-head"><div class="popup-title">' + esc(r.name || "-") + '</div><div class="popup-sub">사업코드 ' + esc(r.code || "-") + ' · 계약법인 ' + esc(r.corp || "-") + '</div></div>' + detailHtml + "</div></body></html>";
popup.document.open();
popup.document.write(pageHtml);
popup.document.close();
popup.focus();
}
async function tryLoadDbDefaultBusinessLedger() {
if (window.__mhBusinessDefaultLoaded) return;
window.__mhBusinessDefaultLoaded = true;
try {
var response = await fetch("/api/integration/business-ledger-default");
if (!response.ok) throw new Error("기본 사업관리대장 원본을 불러오지 못했습니다.");
var fileName = response.headers.get("x-source-filename") || "사업관리대장-1.xlsx";
var buffer = await response.arrayBuffer();
if (!buffer || !buffer.byteLength) throw new Error("기본 사업관리대장 원본 데이터가 비어 있습니다.");
await loadLedgerFile(buffer, fileName);
} catch (error) {
console.error(error);
}
}
function applyDashboardChrome() {
if (!E.cards) return;
document.body.setAttribute("data-mh-ledger-enhanced", "true");
var wrap = document.querySelector(".wrap");
var panel = document.querySelector(".panel");
if (wrap && panel) {
var shell = wrap.querySelector(".business-shell");
if (!shell) {
shell = document.createElement("div");
shell.className = "business-shell";
wrap.insertBefore(shell, E.cards);
}
if (E.cards.parentNode !== shell) shell.appendChild(E.cards);
if (panel.parentNode !== shell) shell.appendChild(panel);
}
var years = bgEnsureYear(S.all);
var summary = bgSummarize(S.all, S.dashboard.year);
var rows = Array.isArray(S.rows) ? S.rows : [];
var visibleBaronProjectRows = rows.filter(isBaronProjectRow);
var totals = bgTotals(visibleBaronProjectRows);
var totalRate = typeof rate === "function" ? rate("", totals.col, totals.col + totals.recv) : 0;
var toolbarHtml = '<div class="cards-toolbar">'
+ '<div class="cards-toolbar-row">'
+ years.map(function (year) {
return '<button type="button" class="summary-year-chip ' + (S.dashboard.year === year ? "active" : "") + '" data-dashboard-year="' + escAttr(year) + '">' + esc(year) + "</button>";
}).join("")
+ '<div class="cards-toolbar-search"></div>'
+ "</div>"
+ '<div class="cards-toolbar-metrics">'
+ '<button type="button" class="summary-filter-chip ' + (S.dashboard.section === "active" ? "active" : "") + '" data-dashboard-section="active"><span class="label">' + esc(summary.targetYear) + '년 진행과업</span><span class="count">' + summary.activeRows.length.toLocaleString("ko-KR") + '건</span><span class="meta">전년도 이월 사업 포함</span></button>'
+ '<button type="button" class="summary-filter-chip ' + (S.dashboard.section === "new" ? "active" : "") + '" data-dashboard-section="new"><span class="label">' + esc(summary.targetYear) + '년 신규프로젝트</span><span class="count">' + summary.newProjectRows.length.toLocaleString("ko-KR") + '건</span><span class="meta">계약기간 시작년도 기준</span></button>'
+ '<button type="button" class="summary-filter-chip ' + (S.dashboard.section === "completed" ? "active" : "") + '" data-dashboard-section="completed"><span class="label">' + esc(summary.targetYear) + '년 완료과업</span><span class="count">' + summary.completedRows.length.toLocaleString("ko-KR") + '건</span><span class="meta">해당년도 종료 사업 기준</span></button>'
+ "</div></div>";
var cards = [
{ label: summary.targetYear + "년 프로젝트", value: visibleBaronProjectRows.length.toLocaleString("ko-KR") + " 건", note: "" },
{ label: "계약금", value: won(totals.c), note: "" },
{ label: "수금액", value: won(totals.col), note: "" },
{ label: "미수금", value: won(totals.recv), note: "" },
{ label: "수금률(%)", value: totalRate.toFixed(2) + "%", note: "" },
{ label: "경영지원서비스 금액", value: won(summary.managementTotals.c), note: "", className: "management" }
];
E.cards.innerHTML = toolbarHtml + cards.map(function (card) {
return '<div class="card ' + esc(card.className || "") + '"><div class="k">' + esc(card.label) + '</div><div class="v">' + esc(card.value) + '</div><div class="n">' + esc(card.note || "") + "</div></div>";
}).join("");
var searchWrap = E.cards.querySelector(".cards-toolbar-search");
if (searchWrap && E.search) {
searchWrap.appendChild(E.search);
E.search.placeholder = "전체 검색";
}
}
var originalRender = render;
render = function () {
originalRender();
applyDashboardChrome();
renderLedgerTable();
};
filter = function () {
bgEnsureYear(S.all);
var q = String(E.search.value || "").trim().toLowerCase();
var searched = !q ? S.all.slice() : S.all.filter(function (r) {
return [r.code, r.name, r.client, r.pm, r.status, r.cat, r.corp, r.pay, (r.payments || []).map(function (p) { return p.pay; }).join(" "), r.periodText].join(" ").toLowerCase().includes(q);
});
S.rows = searched.filter(function (r) {
return bgMatches(r) && matchesColumnFilters(r);
});
render();
};
if (E.cards && !E.cards.dataset.dashboardBound) {
E.cards.dataset.dashboardBound = "true";
E.cards.addEventListener("click", function (event) {
var yearButton = event.target && event.target.closest ? event.target.closest("[data-dashboard-year]") : null;
if (yearButton) {
S.dashboard.year = yearButton.getAttribute("data-dashboard-year") || S.dashboard.year;
filter();
return;
}
var sectionButton = event.target && event.target.closest ? event.target.closest("[data-dashboard-section]") : null;
if (sectionButton) {
S.dashboard.section = sectionButton.getAttribute("data-dashboard-section") || "active";
filter();
}
});
}
if (E.tbody && !E.tbody.dataset.projectBound) {
E.tbody.dataset.projectBound = "true";
E.tbody.addEventListener("click", function (event) {
var groupButton = event.target && event.target.closest ? event.target.closest("[data-group-label]") : null;
if (groupButton) {
var label = groupButton.getAttribute("data-group-label") || "";
if (label) {
S.collapsedGroups[label] = !S.collapsedGroups[label];
render();
}
return;
}
var trigger = event.target && event.target.closest ? event.target.closest(".project-link") : null;
if (!trigger) return;
var key = trigger.getAttribute("data-project-key") || "";
var rows = Array.isArray(S.viewRows) ? S.viewRows : [];
var row = rows.find(function (item) {
return (String(item.code || "") + "|" + String(item.name || "")) === key;
});
if (row) openProjectWindow(row);
});
}
setTimeout(function () {
try {
filter();
if (typeof loadLedgerFile === "function") {
tryLoadDbDefaultBusinessLedger();
}
} catch (error) {
console.error(error);
}
}, 0);
window.addEventListener("message", function (event) {
var data = event.data || {};
if (data.source !== "total-upload" || data.type !== "business") return;
setTimeout(function () {
try {
applyDashboardChrome();
renderLedgerTable();
} catch (error) {
console.error(error);
}
}, 50);
});
})();

Binary file not shown.

View File

@@ -338,11 +338,16 @@ body {
.modal-content.wide #modal-fields {
min-height: 0;
overflow-y: auto;
padding-right: 4px;
}
.modal-content.wide #modal-footer-area {
margin-top: 0 !important;
flex-shrink: 0;
position: relative;
z-index: 2;
background: var(--color-surface);
}
.member-photo-field {
@@ -761,6 +766,7 @@ body {
height: 300px;
border: 0;
background: var(--color-surface);
pointer-events: none;
}
.seat-preview-card.is-assigned .seat-preview-frame {
@@ -1033,7 +1039,8 @@ body {
.modal-footer-actions {
display: flex;
gap: 10px;
width: 100%;
width: auto;
flex: 1 1 auto;
justify-content: flex-end;
align-items: center;
}
@@ -1045,10 +1052,12 @@ body {
padding: 14px 18px;
transition: all 0.2s ease;
border: 1px solid transparent;
white-space: nowrap;
writing-mode: horizontal-tb;
}
.modal-btn-cancel {
flex: 1 1 0;
flex: 0 1 140px;
background: var(--color-surface-strong);
color: var(--color-text-soft);
border-color: var(--color-border);
@@ -1059,7 +1068,7 @@ body {
}
.modal-btn-save {
flex: 1 1 0;
flex: 0 1 140px;
background: var(--color-header);
color: #fff;
box-shadow: 0 10px 22px rgba(47, 153, 115, 0.2);
@@ -1070,6 +1079,8 @@ body {
}
.modal-btn-delete {
flex: 0 0 132px;
min-width: 132px;
background: rgba(198, 71, 56, 0.12);
color: #a33427;
border-color: rgba(198, 71, 56, 0.2);

View File

@@ -4,6 +4,7 @@ let selectedDept = '전체';
let editingMembers = [];
let collapsedUnits = new Set();
let isListMode = false;
let isListDetailModal = false;
let emptyStateMessage = '서버에 조직 데이터가 없습니다. 상단의 업로드 버튼으로 초기 데이터를 넣어주세요.';
let photoPreviewObjectUrl = null;
let seatMapLayoutCache = null;
@@ -485,6 +486,24 @@ function createNodeDOM(node, parentId) {
return nodeItem;
}
function applyMemberPlacementFromTarget(member, targetLevel, targetName, targetMember = null) {
const targetLevelIndex = levelOrder.indexOf(targetLevel);
if (targetLevelIndex === -1) {
return member;
}
for (let index = 0; index <= targetLevelIndex; index += 1) {
member[levelOrder[index]] = targetMember ? (targetMember[levelOrder[index]] || '') : (index === targetLevelIndex ? targetName : member[levelOrder[index]]);
}
if (!targetMember) {
member[targetLevel] = targetName;
}
for (let index = targetLevelIndex + 1; index < levelOrder.length; index += 1) {
member[levelOrder[index]] = '';
}
rebuildMemberPath(member);
return member;
}
function drawLines() {
const container = document.getElementById('tree-root');
const svg = document.getElementById('svg-canvas');
@@ -628,6 +647,11 @@ function render() {
deptBox.id = deptId;
deptBox.className = 'dept-box';
deptBox.setAttribute('data-level', '부서');
if (isAdmin) {
deptBox.ondragover = (event) => handleDragOver(event);
deptBox.ondragleave = (event) => handleDragLeave(event);
deptBox.ondrop = (event) => handleDrop(event, '부서', deptName);
}
deptBox.innerHTML = `<div class="dept-header ${isAdmin ? 'clickable-title' : ''} ${hasMembers ? 'has-members' : ''}" ${isAdmin ? `onclick="openOrgEditModal('부서', '${jsString(deptName)}')"` : ''}>${deptName} (${totalCount})</div>`;
if (hasMembers) {
@@ -827,6 +851,53 @@ function openAddModal(event) {
openModal(null);
}
function renderListViewShell(defaultDate) {
const modal = document.getElementById('modal');
modal.querySelector('.modal-content').classList.add('wide');
document.getElementById('modal-title').innerText = '인원 명단';
const fieldsArea = document.getElementById('modal-fields');
fieldsArea.className = 'flex flex-col w-full overflow-hidden';
fieldsArea.style.maxHeight = '75vh';
fieldsArea.style.overflowY = 'hidden';
fieldsArea.innerHTML = `
<div class="list-toolbar">
<div class="list-toolbar-row">
<div class="list-toolbar-group">
<button type="button" onclick="showCurrentListView()" class="list-mode-btn">현재 명단</button>
</div>
<div class="list-toolbar-divider" aria-hidden="true"></div>
<div class="list-toolbar-group list-date-group">
<input type="date" id="list-snapshot-date" value="${escapeHtml(defaultDate)}" class="list-date-input">
<button type="button" onclick="loadSnapshotListView()" class="list-mode-btn">기준일 조회</button>
</div>
<div class="list-toolbar-divider" aria-hidden="true"></div>
<div class="list-toolbar-group list-date-group">
<input type="date" id="list-compare-from" value="${escapeHtml(defaultDate)}" class="list-date-input">
<span class="list-date-separator">~</span>
<input type="date" id="list-compare-to" value="${escapeHtml(defaultDate)}" class="list-date-input">
<button type="button" onclick="loadCompareListView()" class="list-mode-btn">변경 비교</button>
</div>
</div>
<div class="list-toolbar-row">
<input type="text" id="list-search-input" placeholder="이름 또는 부서/팀 검색 (Enter 시 이동)" class="flex-1 bg-[#f6eddd] border-2 border-[#e0d0b4] p-3 rounded-xl text-sm outline-none font-bold text-[#1f2f25] focus:border-[#d68a3a] transition-all" onkeydown="if(event.key==='Enter') handleListSearch(this.value)">
<button type="button" onclick="handleListSearch(document.getElementById('list-search-input').value)" class="bg-[#214634] text-white px-5 rounded-xl font-bold text-sm">검색</button>
</div>
<div id="list-view-status" class="list-view-status"></div>
</div>
<div id="list-table-container" class="overflow-y-auto flex-1 border rounded-xl"></div>
`;
modal.style.display = 'flex';
}
function returnToListViewModal() {
if (!isListMode) {
return;
}
isListDetailModal = false;
renderListViewShell(listViewState.snapshotDate || getDefaultHistoryDate());
renderListViewModalContent();
}
function updateParentList() {
const type = document.getElementById('new-unit-type').value;
const parentSelect = document.getElementById('new-unit-parent');
@@ -1159,7 +1230,8 @@ function openModal(id) {
modal.querySelector('.modal-content').classList.add('wide');
fieldsArea.className = 'flex flex-col w-full';
fieldsArea.style.maxHeight = 'none';
fieldsArea.style.overflowY = 'visible';
fieldsArea.style.overflowY = 'auto';
isListDetailModal = isListMode;
const sourceValues = isListMode ? editingMembers : members;
let orgFields = '<div id="modal-sec-org" class="hidden grid grid-cols-2 gap-3 modal-form-grid">';
@@ -1279,11 +1351,17 @@ function openModal(id) {
}
function closeModal() {
if (isListMode && isListDetailModal) {
returnToListViewModal();
return;
}
resetPhotoPreviewObjectUrl();
document.getElementById('modal').style.display = 'none';
document.getElementById('modal-fields').className = 'grid grid-cols-2 gap-x-8 gap-y-5';
document.getElementById('modal-fields').style.maxHeight = 'none';
document.getElementById('modal-fields').style.overflowY = 'visible';
document.querySelector('.modal-content').classList.remove('wide');
isListDetailModal = false;
isListMode = false;
}
@@ -1337,8 +1415,7 @@ async function saveMember() {
if (!id) {
targetList.push(member);
}
renderListViewTable();
closeModal();
returnToListViewModal();
return;
}
@@ -1367,11 +1444,16 @@ async function deleteMember(id) {
}
if (isListMode) {
const shouldReturnToList = isListDetailModal;
const idx = editingMembers.findIndex((member) => member._id === id);
if (idx !== -1) {
editingMembers.splice(idx, 1);
}
renderListViewTable();
if (shouldReturnToList) {
returnToListViewModal();
} else {
renderListViewTable();
}
return;
}
@@ -1396,44 +1478,11 @@ function openListViewModal(event) {
listViewState.compareToDate = defaultDate;
listViewState.snapshotMembers = [];
listViewState.compareItems = [];
const modal = document.getElementById('modal');
modal.querySelector('.modal-content').classList.add('wide');
document.getElementById('modal-title').innerText = '인원 명단';
const fieldsArea = document.getElementById('modal-fields');
fieldsArea.className = 'flex flex-col w-full overflow-hidden';
fieldsArea.style.maxHeight = '75vh';
isListMode = true;
isListDetailModal = false;
editingMembers = cloneMembers(members);
fieldsArea.innerHTML = `
<div class="list-toolbar">
<div class="list-toolbar-row">
<div class="list-toolbar-group">
<button type="button" onclick="showCurrentListView()" class="list-mode-btn">현재 명단</button>
</div>
<div class="list-toolbar-divider" aria-hidden="true"></div>
<div class="list-toolbar-group list-date-group">
<input type="date" id="list-snapshot-date" value="${escapeHtml(defaultDate)}" class="list-date-input">
<button type="button" onclick="loadSnapshotListView()" class="list-mode-btn">기준일 조회</button>
</div>
<div class="list-toolbar-divider" aria-hidden="true"></div>
<div class="list-toolbar-group list-date-group">
<input type="date" id="list-compare-from" value="${escapeHtml(defaultDate)}" class="list-date-input">
<span class="list-date-separator">~</span>
<input type="date" id="list-compare-to" value="${escapeHtml(defaultDate)}" class="list-date-input">
<button type="button" onclick="loadCompareListView()" class="list-mode-btn">변경 비교</button>
</div>
</div>
<div class="list-toolbar-row">
<input type="text" id="list-search-input" placeholder="이름 또는 부서/팀 검색 (Enter 시 이동)" class="flex-1 bg-[#f6eddd] border-2 border-[#e0d0b4] p-3 rounded-xl text-sm outline-none font-bold text-[#1f2f25] focus:border-[#d68a3a] transition-all" onkeydown="if(event.key==='Enter') handleListSearch(this.value)">
<button type="button" onclick="handleListSearch(document.getElementById('list-search-input').value)" class="bg-[#214634] text-white px-5 rounded-xl font-bold text-sm">검색</button>
</div>
<div id="list-view-status" class="list-view-status"></div>
</div>
<div id="list-table-container" class="overflow-y-auto flex-1 border rounded-xl"></div>
`;
renderListViewShell(defaultDate);
renderListViewModalContent();
modal.style.display = 'flex';
}
async function applyListViewChanges() {
@@ -1697,12 +1746,29 @@ function toggleUnitCollapse(level, name) {
let draggedGroup = null;
function handleListGroupDragStart(event, level, name) {
draggedIdx = null;
draggedGroup = { level, name };
event.dataTransfer.effectAllowed = 'move';
}
function handleListGroupDrop(event, targetLevel, targetName) {
event.preventDefault();
if (draggedIdx !== null) {
const movingMember = editingMembers[draggedIdx];
if (!movingMember) {
return;
}
const moved = editingMembers.splice(draggedIdx, 1)[0];
applyMemberPlacementFromTarget(moved, targetLevel, targetName, null);
let targetIdx = editingMembers.findIndex((member) => member[targetLevel] === targetName);
if (targetIdx === -1) {
targetIdx = editingMembers.length;
}
editingMembers.splice(targetIdx, 0, moved);
draggedIdx = null;
renderListViewTable();
return;
}
if (!draggedGroup || (draggedGroup.level === targetLevel && draggedGroup.name === targetName)) {
return;
}
@@ -1743,6 +1809,7 @@ function handleListSearch(value) {
let draggedIdx = null;
function handleListDragStart(event, index) {
draggedGroup = null;
draggedIdx = index;
event.dataTransfer.effectAllowed = 'move';
event.target.classList.add('dragging');
@@ -1753,7 +1820,11 @@ function handleListDrop(event, targetIdx) {
if (draggedIdx === null || draggedIdx === targetIdx) {
return;
}
const targetMember = editingMembers[targetIdx] || null;
const moved = editingMembers.splice(draggedIdx, 1)[0];
if (targetMember) {
applyMemberPlacementFromTarget(moved, levelOrder[levelOrder.length - 1], targetMember[levelOrder[levelOrder.length - 1]] || '', targetMember);
}
editingMembers.splice(targetIdx, 0, moved);
draggedIdx = null;
renderListViewTable();
@@ -1804,13 +1875,10 @@ async function handleDrop(event, targetLevel, targetName) {
}
const nextMembers = cloneMembers(members);
const moved = nextMembers[memberIndex];
for (let index = 0; index <= targetLevelIndex; index += 1) {
moved[levelOrder[index]] = targetMember ? targetMember[levelOrder[index]] : targetName;
if (targetLevelIndex === -1) {
return;
}
for (let index = targetLevelIndex + 1; index < levelOrder.length; index += 1) {
moved[levelOrder[index]] = '';
}
rebuildMemberPath(moved);
applyMemberPlacementFromTarget(moved, targetLevel, targetName, targetMember);
nextMembers.splice(memberIndex, 1);
nextMembers.push(moved);
await syncMembers(nextMembers);
@@ -1859,10 +1927,7 @@ async function handleDropMember(event, targetId) {
}
const moved = nextMembers[movingIdx];
const target = nextMembers[targetIdx];
levelOrder.forEach((level) => {
moved[level] = target[level];
});
rebuildMemberPath(moved);
applyMemberPlacementFromTarget(moved, levelOrder[levelOrder.length - 1], target[levelOrder[levelOrder.length - 1]] || '', target);
nextMembers.splice(movingIdx, 1);
targetIdx = nextMembers.findIndex((member) => member._id === targetId);
nextMembers.splice(insertAfter ? targetIdx + 1 : targetIdx, 0, moved);

View File

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

View File

@@ -0,0 +1,53 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
echo "[smoke] checking dev containers"
docker ps --format '{{.Names}}' | grep -qx 'mh-dashboard-organization-dev-backend-1'
docker ps --format '{{.Names}}' | grep -qx 'mh-dashboard-organization-dev-proxy-1'
echo "[smoke] running 8081 endpoint checks"
docker exec mh-dashboard-organization-dev-backend-1 python - <<'PY'
import sys
import urllib.request
checks = [
("health", "http://127.0.0.1:8000/api/health", b'"status"'),
("db-status-api", "http://127.0.0.1:8000/api/admin/db-status", b'"tables"'),
("ledger-default-api", "http://127.0.0.1:8000/api/integration/business-ledger-default", b'PK'),
("legacy-organization", "http://127.0.0.1:8000/legacy/organization", b'organization'),
("payment", "http://127.0.0.1:8000/integrations/payment", b'const App = () =>'),
("ledger", "http://127.0.0.1:8000/integrations/ledger", b'xlsx.full.min.js'),
("mh", "http://127.0.0.1:8000/integrations/mh", b'const App = () =>'),
("proxy-root", "http://proxy/", b'app.js'),
("proxy-db-status", "http://proxy/db-status.html", b'전체 테이블 현황'),
]
failed = []
for name, url, needle in checks:
try:
with urllib.request.urlopen(url, timeout=8) as response:
body = response.read()
status = getattr(response, "status", response.getcode())
if status != 200:
failed.append(f"{name}: unexpected status {status}")
continue
if needle not in body:
failed.append(f"{name}: missing expected marker {needle!r}")
continue
print(f"[ok] {name} -> {status}")
except Exception as exc:
failed.append(f"{name}: {exc}")
if failed:
print("[smoke] failures detected:")
for item in failed:
print(f" - {item}")
sys.exit(1)
print("[smoke] all checks passed")
PY

View File

@@ -44,7 +44,7 @@ copy_optional_path "incoming-files/1.png"
copy_optional_path "incoming-files/260320.html"
copy_optional_path "incoming-files/sample style.css"
copy_optional_path "incoming-files/seat/center_chair_people_map(2).html"
copy_optional_path "incoming-files/사업관리대장"
copy_optional_path "incoming-files/reference/ledger"
echo "[6/6] Dev worktree ready"
echo "Path: ${DEV_DIR}"

View File

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

22
scripts/publish_ledger_app.sh Executable file
View File

@@ -0,0 +1,22 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
APP_DIR="${ROOT_DIR}/frontend/apps/ledger"
TARGET_DIR="${ROOT_DIR}/incoming-files/served/ledger"
LEDGER_ASSET_VERSION="${LEDGER_ASSET_VERSION:-20260401-03}"
mkdir -p "${TARGET_DIR}"
cp "${APP_DIR}/assets/MH 통합 대시보드_260320.css" "${TARGET_DIR}/MH 통합 대시보드_260320.css"
cp "${APP_DIR}/assets/ledger-override.css" "${TARGET_DIR}/ledger-override.css"
cp "${APP_DIR}/assets/ledger-override.js" "${TARGET_DIR}/ledger-override.js"
HEAD_ASSETS='<base href="/integrations/ledger-assets/"><link rel="stylesheet" href="/integrations/ledger-assets/MH%20통합%20대시보드_260320.css"><link rel="stylesheet" href="/integrations/ledger-assets/ledger-override.css?v='"${LEDGER_ASSET_VERSION}"'">'
BODY_SCRIPTS='<script src="/integrations/ledger-assets/ledger-override.js?v='"${LEDGER_ASSET_VERSION}"'"></script>'
perl -0pe 's|__LEDGER_HEAD_ASSETS__|'"${HEAD_ASSETS}"'|g; s|__LEDGER_BODY_SCRIPTS__|'"${BODY_SCRIPTS}"'|g' \
"${APP_DIR}/index.html" > "${TARGET_DIR}/index.html"
echo "Published ledger app source to ${TARGET_DIR}"

13
scripts/publish_payment_app.sh Executable file
View File

@@ -0,0 +1,13 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
APP_DIR="${ROOT_DIR}/frontend/apps/payment"
TARGET_FILE="${ROOT_DIR}/incoming-files/served/payment.html"
COMPARE_FILE="${ROOT_DIR}/incoming-files/payment.html"
cp "${APP_DIR}/index.html" "${TARGET_FILE}"
cp "${APP_DIR}/index.html" "${COMPARE_FILE}"
echo "Published payment app source to ${TARGET_FILE}"

13
scripts/publish_team_app.sh Executable file
View File

@@ -0,0 +1,13 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
APP_DIR="${ROOT_DIR}/frontend/apps/team"
TARGET_FILE="${ROOT_DIR}/incoming-files/served/mh.html"
COMPARE_FILE="${ROOT_DIR}/incoming-files/mh.html"
cp "${APP_DIR}/index.html" "${TARGET_FILE}"
cp "${APP_DIR}/index.html" "${COMPARE_FILE}"
echo "Published team app source to ${TARGET_FILE}"