10 Commits

Author SHA1 Message Date
hyunho
c0564ee326 Improve ledger filters and dev sync checks 2026-04-02 11:17:01 +09:00
hyunho
f8ea345882 Refactor app structure and simplify team docs 2026-04-02 11:13:43 +09:00
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
88 changed files with 15498 additions and 4840 deletions

55
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,55 @@
# Contributing
## 기본 규칙
- `main`은 팀 기준 브랜치로 사용합니다.
- 기능 개발과 버그 수정은 각자 작업 브랜치에서 진행합니다.
- 직접 `8080` 기준 파일을 수정하지 않습니다.
- 검증은 먼저 `8081` 개발 환경에서 수행합니다.
- 커밋은 한 기능 또는 한 버그 단위로 작게 나눕니다.
- 작업 시작 전 [docs/TEAM_GUIDE.md](docs/TEAM_GUIDE.md)를 먼저 읽습니다.
## 권장 브랜치 이름
- `feature/<name>-<topic>`
- `fix/<name>-<topic>`
- `chore/<name>-<topic>`
예:
- `fix/alex-organization-save`
- `feature/minsu-ledger-filter`
## 작업 순서
1. `main` 최신 상태를 받습니다.
2. 작업 브랜치를 만듭니다.
3. 필요한 경우 `./scripts/prepare_dev_worktree.sh`로 격리된 개발 워크스페이스를 준비합니다.
4. `8081`에서 수정과 검증을 진행합니다.
5. 관련 publish 스크립트가 있는 화면은 publish 후 실제 런타임 파일까지 확인합니다.
6. `docs/REGRESSION_CHECKLIST.md` 기준으로 필요한 시나리오를 점검합니다.
7. 커밋 후 PR을 생성합니다.
## PR 규칙
- PR 하나에는 한 주제만 담습니다.
- PR 본문에 아래 내용을 포함합니다.
- 작업 목적
- 변경 범위
- 검증 방법
- DB 영향 여부
- 공용 구조 파일 수정 시 영향 화면을 명시합니다.
## 파일 수정 기준
- 탭 화면 수정은 먼저 `frontend/apps/*`를 봅니다.
- 조직현황은 `frontend/apps/organization``legacy/static/*` 구조를 함께 확인합니다.
- integration 화면 런타임은 `incoming-files/served/*`지만, 직접 수정 원본은 `frontend/apps/*`입니다.
- 원본 참고 파일은 `incoming-files/reference/*`에만 둡니다.
## 문서 기준
- 저장소 진입: [README.md](README.md)
- 작업 시작 기준: [docs/TEAM_GUIDE.md](docs/TEAM_GUIDE.md)
- 개발/운영 DB 원칙: [docs/DEV_PROD_DB_PROTOCOL.md](docs/DEV_PROD_DB_PROTOCOL.md)
- 실제 서빙 책임 맵: [docs/architecture/8081_SERVING_MAP.md](docs/architecture/8081_SERVING_MAP.md)

View File

@@ -8,8 +8,8 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <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" /> <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> <script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="/legacy/static/common.css?v=20260331-01" /> <link rel="stylesheet" href="/legacy/static/common.css?v=20260402-02" />
<link rel="stylesheet" href="/legacy/static/organization.css?v=20260331-01" /> <link rel="stylesheet" href="/legacy/static/organization.css?v=20260402-02" />
</head> </head>
<body> <body>
<input type="file" id="upload-excel" class="hidden" accept=".xlsx, .csv" /> <input type="file" id="upload-excel" class="hidden" accept=".xlsx, .csv" />
@@ -60,6 +60,6 @@
</div> </div>
</div> </div>
<script src="/legacy/static/organization.js?v=20260331-01"></script> <script src="/legacy/static/organization.js?v=20260402-02"></script>
</body> </body>
</html> </html>

59
README.md Normal file
View File

@@ -0,0 +1,59 @@
# MH Dashboard Organization
조직현황, 자리배치도, 프로젝트별 분석, 팀/개인별 분석을 하나의 대시보드로 제공하는 사내 웹 애플리케이션입니다.
## 구성
- `frontend/`
- 허브 화면과 공통 스타일
- `frontend/apps/`
- 화면별 source-of-truth 앱 소스
- `legacy/static/`
- 조직현황 레거시 런타임 자산
- `incoming-files/served/`
- integration 화면의 실제 런타임 서빙 자산
- `incoming-files/reference/`
- 원본 참고 자산
- `backend/app/`
- FastAPI 백엔드
- `scripts/`
- 실행, publish, 검증, 동기화 스크립트
## 핵심 원칙
- `frontend/apps/*`가 탭별 수정 원본입니다.
- `incoming-files/served/*``legacy/static/*`는 런타임 자산입니다.
- 조직현황/멤버/자리배치 관련 검증은 `8081` 개발 환경에서 먼저 수행합니다.
- `8080`은 기준 데이터와 공개 환경, `8081`은 검증 환경으로 다룹니다.
## 시작 문서
- 첫 문서: [docs/TEAM_GUIDE.md](docs/TEAM_GUIDE.md)
- 협업 규칙: [CONTRIBUTING.md](CONTRIBUTING.md)
- 개발/운영 DB 원칙: [docs/DEV_PROD_DB_PROTOCOL.md](docs/DEV_PROD_DB_PROTOCOL.md)
- 실제 서빙 책임 맵: [docs/architecture/8081_SERVING_MAP.md](docs/architecture/8081_SERVING_MAP.md)
- 디자인 기준: [docs/architecture/DESIGN_SSOT.md](docs/architecture/DESIGN_SSOT.md)
## 빠른 실행
기본 공개 환경:
```bash
docker compose up -d --build
```
격리된 `8081` 개발 환경:
```bash
./scripts/prepare_dev_worktree.sh
cd .dev-worktree-8081
docker compose -p mh-dashboard-organization-dev --env-file .env -f docker-compose.8081.yml up -d --build
```
## publish 스크립트
- 조직현황: `./scripts/publish_organization_app.sh`
- 프로젝트별 분석: `./scripts/publish_payment_app.sh`
- 팀/개인별 분석: `./scripts/publish_team_app.sh`
- 사업관리대장: `./scripts/publish_ledger_app.sh`
- DB 상태: `./scripts/publish_db_status_app.sh`

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() 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 ( CREATE TABLE IF NOT EXISTS history_revisions (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
scope TEXT NOT NULL DEFAULT 'organization', 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() 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 SCHEMA IF NOT EXISTS auth;
CREATE TABLE IF NOT EXISTS auth.users ( 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 CREATE UNIQUE INDEX IF NOT EXISTS integration_project_category_mappings_key_idx
ON integration_project_category_mappings (source_key, normalized_project_key); 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 CREATE INDEX IF NOT EXISTS member_versions_member_time_idx
ON member_versions (member_id, valid_from, valid_to); 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 CREATE INDEX IF NOT EXISTS history_revisions_scope_created_idx
ON history_revisions (scope, created_at DESC); 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 $$ DO $$
BEGIN BEGIN
IF NOT EXISTS ( 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 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 ip_address INET;
ALTER TABLE auth.login_audit_logs ADD COLUMN IF NOT EXISTS user_agent TEXT; 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,19 @@
from .organization import (
fetch_current_member_state,
fetch_current_seat_assignments,
fetch_history_revision,
fetch_history_revision_created_at,
fetch_history_revisions,
fetch_members,
fetch_members_as_of,
)
__all__ = [
"fetch_current_member_state",
"fetch_current_seat_assignments",
"fetch_history_revision",
"fetch_history_revision_created_at",
"fetch_history_revisions",
"fetch_members",
"fetch_members_as_of",
]

View File

@@ -0,0 +1,145 @@
from __future__ import annotations
from datetime import date, datetime, time, timedelta
from typing import Callable
from fastapi import HTTPException
def fetch_members(get_conn) -> list[dict[str, object]]:
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT 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
FROM members
ORDER BY sort_order ASC, id ASC
"""
)
return cur.fetchall()
def fetch_history_revision(cur, revision_id: int) -> dict[str, object] | None:
cur.execute(
"""
SELECT id, revision_label, created_at, note
FROM history_revisions
WHERE scope = 'organization'
AND id = %s
""",
(revision_id,),
)
return cur.fetchone()
def fetch_history_revisions(
cur,
*,
app_timezone,
day: date | None = None,
limit: int = 100,
) -> list[dict[str, object]]:
safe_limit = max(1, min(int(limit), 500))
if day is None:
cur.execute(
"""
SELECT id, revision_label, created_at, note
FROM history_revisions
WHERE scope = 'organization'
ORDER BY created_at DESC, id DESC
LIMIT %s
""",
(safe_limit,),
)
return cur.fetchall()
day_start = datetime.combine(day, time.min, tzinfo=app_timezone)
day_end = day_start + timedelta(days=1)
cur.execute(
"""
SELECT id, revision_label, created_at, note
FROM history_revisions
WHERE scope = 'organization'
AND created_at >= %s
AND created_at < %s
ORDER BY created_at ASC, id ASC
LIMIT %s
""",
(day_start, day_end, safe_limit),
)
return cur.fetchall()
def fetch_history_revision_created_at(cur, revision_id: int) -> datetime:
revision = fetch_history_revision(cur, revision_id)
if revision is None:
raise HTTPException(status_code=404, detail="History revision not found.")
return revision["created_at"]
def fetch_current_member_state(cur) -> dict[int, dict[str, object]]:
cur.execute(
"""
SELECT 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
FROM members
"""
)
return {int(row["id"]): row for row in cur.fetchall()}
def fetch_current_seat_assignments(cur) -> dict[int, dict[str, object]]:
cur.execute(
"""
SELECT member_id, seat_map_id, seat_slot_id, seat_label, updated_at
FROM seat_positions
"""
)
return {int(row["member_id"]): row for row in cur.fetchall()}
def fetch_members_as_of(cur, as_of: datetime) -> list[dict[str, object]]:
cur.execute(
"""
SELECT mv.member_id AS id,
mv.name,
COALESCE(m.employee_id, '') AS employee_id,
mv.company,
mv.rank,
mv.role,
mv.department,
mv.grp,
mv.division,
mv.team,
mv.cell,
mv.work_status,
mv.work_time,
mv.phone,
mv.email,
COALESCE(sav.seat_label, '') AS seat_label,
mv.photo_url,
COALESCE(m.sort_order, 2147483647) AS sort_order,
mv.created_at,
mv.valid_from AS updated_at,
mv.valid_to AS history_valid_to,
mv.revision_no,
hr.created_at AS revision_created_at
FROM member_versions mv
LEFT JOIN members m
ON m.id = mv.member_id
LEFT JOIN history_revisions hr
ON hr.id = mv.revision_no
LEFT JOIN seat_assignment_versions sav
ON sav.member_id = mv.member_id
AND sav.valid_from <= %s
AND (sav.valid_to IS NULL OR sav.valid_to > %s)
WHERE mv.valid_from <= %s
AND (mv.valid_to IS NULL OR mv.valid_to > %s)
ORDER BY COALESCE(m.sort_order, 2147483647) ASC, mv.member_id ASC
""",
(as_of, as_of, as_of, as_of),
)
return cur.fetchall()

View File

@@ -0,0 +1,13 @@
from .auth import register_auth_routes
from .integration import register_integration_routes
from .organization import register_member_routes
from .seatmap import register_seatmap_routes
from .system import register_system_routes
__all__ = [
"register_auth_routes",
"register_integration_routes",
"register_member_routes",
"register_seatmap_routes",
"register_system_routes",
]

View File

@@ -0,0 +1,3 @@
from ..auth_routes import register_auth_routes
__all__ = ["register_auth_routes"]

View File

@@ -0,0 +1,3 @@
from ..integration_routes import register_integration_routes
__all__ = ["register_integration_routes"]

View File

@@ -0,0 +1,3 @@
from ..member_routes import register_member_routes
__all__ = ["register_member_routes"]

View File

@@ -0,0 +1,3 @@
from ..seatmap_routes import register_seatmap_routes
__all__ = ["register_seatmap_routes"]

View File

@@ -0,0 +1,3 @@
from ..system_routes import register_system_routes
__all__ = ["register_system_routes"]

View File

@@ -0,0 +1,15 @@
from .organization import (
MemberBulkPayload,
MemberPayload,
SeatLayoutPayload,
SeatLayoutPlacementPayload,
SeatMapPayload,
)
__all__ = [
"MemberBulkPayload",
"MemberPayload",
"SeatLayoutPayload",
"SeatLayoutPlacementPayload",
"SeatMapPayload",
]

View File

@@ -0,0 +1,58 @@
from __future__ import annotations
from pydantic import BaseModel, Field
class MemberPayload(BaseModel):
id: int | None = None
name: str = Field(min_length=1)
employee_id: str = ""
company: str = ""
rank: str = ""
role: str = ""
department: str = ""
grp: str = ""
division: str = ""
team: str = ""
cell: str = ""
work_status: str = ""
work_time: str = ""
phone: str = ""
email: str = ""
seat_label: str = ""
photo_url: str = ""
sort_order: int | None = None
class MemberBulkPayload(BaseModel):
items: list[MemberPayload]
class SeatMapPayload(BaseModel):
name: str = Field(min_length=1)
image_url: str = ""
source_type: str = "image"
source_url: str = ""
preview_svg: str = ""
view_box_min_x: float | None = None
view_box_min_y: float | None = None
view_box_width: float | None = None
view_box_height: float | None = None
image_width: int | None = None
image_height: int | None = None
grid_rows: int = Field(default=1, ge=1, le=200)
grid_cols: int = Field(default=1, ge=1, le=200)
cell_gap: int = Field(default=0, ge=0, le=24)
is_active: bool = True
class SeatLayoutPlacementPayload(BaseModel):
member_id: int
seat_slot_id: int | None = None
row_index: int = Field(default=0, ge=0)
col_index: int = Field(default=0, ge=0)
seat_label: str = ""
class SeatLayoutPayload(BaseModel):
placements: list[SeatLayoutPlacementPayload]

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,21 @@
from .organization import (
create_history_revision,
merge_import_member,
normalize_phone,
pick_existing_member,
replace_members,
serialize_member_payload,
sync_member_versions,
sync_seat_assignment_versions,
)
__all__ = [
"create_history_revision",
"merge_import_member",
"normalize_phone",
"pick_existing_member",
"replace_members",
"serialize_member_payload",
"sync_member_versions",
"sync_seat_assignment_versions",
]

View File

@@ -0,0 +1,350 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Callable
from ..repositories import (
fetch_current_member_state,
fetch_current_seat_assignments,
fetch_history_revision_created_at,
fetch_members,
)
from ..schemas import MemberPayload
def normalize_phone(value: object) -> str:
raw = str(value or "").strip()
digits = "".join(ch for ch in raw if ch.isdigit())
if not digits:
return ""
if len(digits) == 10 and not digits.startswith("0"):
digits = f"0{digits}"
if len(digits) == 11 and digits.startswith("0"):
return f"{digits[:3]}-{digits[3:7]}-{digits[7:]}"
if len(digits) == 10 and digits.startswith("0"):
return f"{digits[:3]}-{digits[3:6]}-{digits[6:]}"
return raw
def serialize_member_payload(item, sort_order: int) -> tuple[object, ...]:
return (
item.name.strip(),
item.employee_id.strip(),
item.company.strip(),
item.rank.strip(),
item.role.strip(),
item.department.strip(),
item.grp.strip(),
item.division.strip(),
item.team.strip(),
item.cell.strip(),
item.work_status.strip(),
item.work_time.strip(),
normalize_phone(item.phone),
item.email.strip(),
item.seat_label.strip(),
item.photo_url.strip(),
sort_order,
)
def create_history_revision(cur, label_prefix: str, note: str, *, app_timezone) -> int:
cur.execute(
"""
INSERT INTO history_revisions (scope, revision_label, note)
VALUES ('organization', %s, %s)
RETURNING id
""",
(f"{label_prefix}-{datetime.now(app_timezone).strftime('%Y%m%d-%H%M%S-%f')}", note),
)
return int(cur.fetchone()["id"])
def sync_member_versions(
cur,
member_ids: list[int],
change_reason: str,
revision_no: int,
effective_at: datetime | None = None,
) -> None:
if not member_ids:
return
event_at = effective_at or datetime.now(timezone.utc)
unique_ids = sorted(set(int(member_id) for member_id in member_ids))
current_members = fetch_current_member_state(cur)
cur.execute(
"""
SELECT id, member_id, name, company, rank, role, department, grp, division, team, cell,
work_status, work_time, phone, email, photo_url, valid_from, valid_to
FROM member_versions
WHERE member_id = ANY(%s)
AND valid_to IS NULL
""",
(unique_ids,),
)
active_versions = {int(row["member_id"]): row for row in cur.fetchall()}
for member_id in unique_ids:
current = current_members.get(member_id)
active = active_versions.get(member_id)
if current is None:
if active is not None:
cur.execute(
"UPDATE member_versions SET valid_to = %s WHERE id = %s AND valid_to IS NULL",
(event_at, int(active["id"])),
)
continue
current_tuple = (
str(current.get("name") or ""),
str(current.get("company") or ""),
str(current.get("rank") or ""),
str(current.get("role") or ""),
str(current.get("department") or ""),
str(current.get("grp") or ""),
str(current.get("division") or ""),
str(current.get("team") or ""),
str(current.get("cell") or ""),
str(current.get("work_status") or ""),
str(current.get("work_time") or ""),
str(current.get("phone") or ""),
str(current.get("email") or ""),
str(current.get("photo_url") or ""),
)
active_tuple = None
if active is not None:
active_tuple = (
str(active.get("name") or ""),
str(active.get("company") or ""),
str(active.get("rank") or ""),
str(active.get("role") or ""),
str(active.get("department") or ""),
str(active.get("grp") or ""),
str(active.get("division") or ""),
str(active.get("team") or ""),
str(active.get("cell") or ""),
str(active.get("work_status") or ""),
str(active.get("work_time") or ""),
str(active.get("phone") or ""),
str(active.get("email") or ""),
str(active.get("photo_url") or ""),
)
if active_tuple == current_tuple:
continue
if active is not None:
cur.execute(
"UPDATE member_versions SET valid_to = %s WHERE id = %s AND valid_to IS NULL",
(event_at, int(active["id"])),
)
cur.execute(
"""
INSERT INTO member_versions (
member_id, name, company, rank, role, department, grp, division, team, cell,
work_status, work_time, phone, email, photo_url,
valid_from, valid_to, revision_no, changed_by_user_id, change_reason
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NULL, %s, NULL, %s)
""",
(member_id, *current_tuple, event_at, revision_no, change_reason),
)
def sync_seat_assignment_versions(
cur,
member_ids: list[int],
change_reason: str,
revision_no: int,
effective_at: datetime | None = None,
) -> None:
if not member_ids:
return
event_at = effective_at or datetime.now(timezone.utc)
unique_ids = sorted(set(int(member_id) for member_id in member_ids))
current_assignments = fetch_current_seat_assignments(cur)
cur.execute(
"""
SELECT id, member_id, seat_map_id, seat_slot_id, seat_label
FROM seat_assignment_versions
WHERE member_id = ANY(%s)
AND valid_to IS NULL
""",
(unique_ids,),
)
active_versions = {int(row["member_id"]): row for row in cur.fetchall()}
for member_id in unique_ids:
current = current_assignments.get(member_id)
active = active_versions.get(member_id)
current_tuple = None
if current is not None:
current_tuple = (
current.get("seat_map_id"),
current.get("seat_slot_id"),
str(current.get("seat_label") or ""),
)
active_tuple = None
if active is not None:
active_tuple = (
active.get("seat_map_id"),
active.get("seat_slot_id"),
str(active.get("seat_label") or ""),
)
if active_tuple == current_tuple:
continue
if active is not None:
cur.execute(
"UPDATE seat_assignment_versions SET valid_to = %s WHERE id = %s AND valid_to IS NULL",
(event_at, int(active["id"])),
)
if current is None:
continue
cur.execute(
"""
INSERT INTO seat_assignment_versions (
member_id, seat_map_id, seat_slot_id, seat_label,
valid_from, valid_to, revision_no, changed_by_user_id, change_reason
)
VALUES (%s, %s, %s, %s, %s, NULL, %s, NULL, %s)
""",
(
member_id,
current.get("seat_map_id"),
current.get("seat_slot_id"),
str(current.get("seat_label") or ""),
event_at,
revision_no,
change_reason,
),
)
def merge_import_member(item: MemberPayload, existing: dict[str, object] | None) -> MemberPayload:
if existing is None:
return item
payload = item.model_copy(deep=True)
if not payload.photo_url.strip():
payload.photo_url = str(existing.get("photo_url") or "")
if not payload.seat_label.strip():
payload.seat_label = str(existing.get("seat_label") or "")
return payload
def pick_existing_member(
item: MemberPayload,
existing_by_employee_id: dict[str, list[dict[str, object]]],
existing_by_name: dict[str, list[dict[str, object]]],
matched_ids: set[int],
) -> dict[str, object] | None:
employee_id = item.employee_id.strip()
if employee_id:
for candidate in existing_by_employee_id.get(employee_id, []):
candidate_id = int(candidate["id"])
if candidate_id not in matched_ids:
return candidate
name = item.name.strip()
if name:
available = [
candidate
for candidate in existing_by_name.get(name, [])
if int(candidate["id"]) not in matched_ids
]
if len(available) == 1:
return available[0]
return None
def replace_members(
items: list[MemberPayload],
*,
get_conn,
sync_auth_users_from_members: Callable[[object], None],
app_timezone,
) -> list[dict[str, object]]:
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT 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
FROM members
ORDER BY id ASC
"""
)
existing_members = cur.fetchall()
existing_by_employee_id: dict[str, list[dict[str, object]]] = {}
existing_by_name: dict[str, list[dict[str, object]]] = {}
for member in existing_members:
employee_id = str(member.get("employee_id") or "").strip()
name = str(member.get("name") or "").strip()
if employee_id:
existing_by_employee_id.setdefault(employee_id, []).append(member)
if name:
existing_by_name.setdefault(name, []).append(member)
matched_ids: set[int] = set()
for index, item in enumerate(items):
existing = pick_existing_member(item, existing_by_employee_id, existing_by_name, matched_ids)
merged_item = merge_import_member(item, existing)
if existing is None:
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)
""",
serialize_member_payload(merged_item, index),
)
continue
matched_ids.add(int(existing["id"]))
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 = %s,
updated_at = NOW()
WHERE id = %s
""",
(*serialize_member_payload(merged_item, index), int(existing["id"])),
)
stale_ids = [int(member["id"]) for member in existing_members if int(member["id"]) not in matched_ids]
if stale_ids:
cur.execute("DELETE FROM members WHERE id = ANY(%s)", (stale_ids,))
sync_auth_users_from_members(cur)
cur.execute("SELECT id FROM members")
current_ids = [int(row["id"]) for row in cur.fetchall()]
affected_member_ids = sorted(set(current_ids + [int(member["id"]) for member in existing_members]))
if affected_member_ids:
revision_no = create_history_revision(
cur,
"members-bulk-sync",
"Bulk member sync applied",
app_timezone=app_timezone,
)
revision_created_at = fetch_history_revision_created_at(cur, revision_no)
sync_member_versions(cur, affected_member_ids, "members-bulk-sync", revision_no, revision_created_at)
sync_seat_assignment_versions(cur, affected_member_ids, "members-bulk-sync", revision_no, revision_created_at)
conn.commit()
return fetch_members(get_conn)

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

@@ -286,9 +286,9 @@ API 보호 예시:
기존 프론트엔드의 mock 로그인 제거. 기존 프론트엔드의 mock 로그인 제거.
변경 대상: 변경 대상:
- [frontend/public/app.js](/home/hyunho/projects/mh-dashboard-organization/frontend/public/app.js) - [frontend/public/app.js](../frontend/public/app.js)
- [backend/app/main.py](/home/hyunho/projects/mh-dashboard-organization/backend/app/main.py) - [backend/app/main.py](../backend/app/main.py)
- [backend/app/config.py](/home/hyunho/projects/mh-dashboard-organization/backend/app/config.py) - [backend/app/config.py](../backend/app/config.py)
### Phase 4 ### Phase 4

View File

@@ -23,19 +23,10 @@
- 이 저장소를 서버로 복사합니다. - 이 저장소를 서버로 복사합니다.
- `.env.example`을 기준으로 `.env` 파일을 만들고 실제 DB 비밀번호를 넣습니다. - `.env.example`을 기준으로 `.env` 파일을 만들고 실제 DB 비밀번호를 넣습니다.
## 4-1. 현재 로컬 PC 기준 WSL 작업 표준 ## 4-1. 로컬 개발 환경 원칙
- 현재 로컬 개발 서버는 `WSL2 + Ubuntu-24.04` 기준으로 구성했습니다. - 로컬 개발은 Linux 계열 개발 환경을 권장합니다.
- 기본 작업 사용자는 `hyunho`니다. - Docker, Python, 파일 경로가 실제 배포 환경과 최대한 비슷한 환경에서 작업하는 것이 안전합니다.
- 앞으로의 기준 작업 경로는 아래입니다. - Windows 호스트를 사용하는 경우에도, 실제 실행 경로와 편집 경로가 어긋나지 않도록 주의합니다.
- `/home/hyunho/projects/mh-dashboard-organization`
- Windows 폴더는 원본 참고용으로 남아 있을 수 있지만, 실제 실행과 개발 기준은 WSL 내부 경로로 맞추는 것을 권장합니다.
## 4-2. VS Code는 어떤 경로를 열어야 하나
- VS Code 좌측 아래에 `WSL: Ubuntu-24.04` 가 보이는 상태로 여는 것이 가장 안전합니다.
- VS Code에서 `Remote-WSL: Reopen Folder in WSL` 기능으로 다시 열 수 있습니다.
- 다시 열어야 할 권장 경로는 아래입니다.
- `/home/hyunho/projects/mh-dashboard-organization`
- 이렇게 열면 Docker, Python, Linux 경로, 실행 환경이 실제 서버와 가장 비슷하게 맞춰집니다.
## 5. Docker 설치 관련 메모 ## 5. Docker 설치 관련 메모
- 메신저에 공유된 명령어는 Ubuntu 서버에 Docker를 설치하기 위한 절차입니다. - 메신저에 공유된 명령어는 Ubuntu 서버에 Docker를 설치하기 위한 절차입니다.
@@ -73,8 +64,6 @@
## 10. 현재 로컬 테스트 접속 정보 ## 10. 현재 로컬 테스트 접속 정보
- 접속 주소: `http://localhost:8080` - 접속 주소: `http://localhost:8080`
- 상태 확인 API: `http://localhost:8080/api/health` - 상태 확인 API: `http://localhost:8080/api/health`
- WSL 내부 실행 경로:
- `/home/hyunho/projects/mh-dashboard-organization`
## 11. 운영 검증 체크포인트 ## 11. 운영 검증 체크포인트
- 엑셀 또는 CSV 업로드 후 `GET /api/members` 에서 데이터가 조회되는지 확인합니다. - 엑셀 또는 CSV 업로드 후 `GET /api/members` 에서 데이터가 조회되는지 확인합니다.

View File

@@ -2,7 +2,7 @@
## Purpose ## Purpose
이 문서는 `total` 브랜치에서 진행한 통합 작업을 기능 단위로 정리한 개발 히스토리다. 이 문서는 이 저장소에서 진행한 통합 작업을 기능 단위로 정리한 개발 히스토리다.
목표는 다음 두 가지다. 목표는 다음 두 가지다.
- 지금까지 어떤 기능을 어떤 방식으로 붙였는지 빠르게 파악 - 지금까지 어떤 기능을 어떤 방식으로 붙였는지 빠르게 파악
@@ -161,12 +161,12 @@
### 작업 내용 ### 작업 내용
- WSL 내부 `0.0.0.0:8080` 바인딩 확인 - 개발 환경의 외부 접속 경로를 정리
- Windows host에서 `portproxy`와 방화벽 규칙으로 다른 PC 접속 가능하게 정리 - 호스트 방화벽과 포트 포워딩 규칙으로 다른 PC 접속 가능하게 구성
### 유의사항 ### 유의사항
- Windows LAN IP 또는 WSL IP가 바뀌면 `portproxy``connectaddress` 다시 맞춰야 한다 - 호스트 IP나 포워딩 대상 IP가 바뀌면 포트 포워딩 설정을 다시 맞춰야 한다
- 운영 안정성을 위해 향후 자동화 스크립트화가 필요함 - 운영 안정성을 위해 향후 자동화 스크립트화가 필요함
## 10. 인증 기본 구조 추가 ## 10. 인증 기본 구조 추가
@@ -226,7 +226,7 @@
### 설계 문서 ### 설계 문서
- [HISTORY_ASOF_DB_PLAN.md](/home/hyunho/projects/mh-dashboard-organization/docs/HISTORY_ASOF_DB_PLAN.md) - [HISTORY_ASOF_DB_PLAN.md](HISTORY_ASOF_DB_PLAN.md)
## Next Focus ## Next Focus

View File

@@ -10,15 +10,15 @@
### 코드 경로 ### 코드 경로
- 공개용 `8080`: `/home/hyunho/projects/mh-dashboard-organization` - 공개용 `8080`: 메인 workspace
- 작업용 `8081`: `/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081` - 작업용 `8081`: 메인 workspace 아래의 격리 worktree
### 작업용 Compose 기준 ### 작업용 Compose 기준
- 공개용 `8080` stack: `docker-compose.yml` - 공개용 `8080` stack: `docker-compose.yml`
- 작업용 `8081` stack: `docker-compose.8081.yml` - 작업용 `8081` stack: `docker-compose.8081.yml`
- 작업용 project name 기본값: `mh-dashboard-organization-dev` - 작업용 project name 기본값: `mh-dashboard-organization-dev`
- 작업용 `8081`는 반드시 `/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081`에서 띄운다 - 작업용 `8081`는 반드시 격리된 worktree에서 띄운다
### DB 볼륨 ### DB 볼륨
@@ -102,7 +102,7 @@
1. `8080``8081` 모두 기동 상태 확인 1. `8080``8081` 모두 기동 상태 확인
2. 이번 작업이 `코드 변경`인지 `데이터 변경`인지 먼저 구분 2. 이번 작업이 `코드 변경`인지 `데이터 변경`인지 먼저 구분
3. 공개용 기준 데이터가 필요한 화면이면 `8081` DB를 먼저 `8080` 기준으로 맞춤 3. 공개용 기준 데이터가 필요한 화면이면 `8081` DB를 먼저 `8080` 기준으로 맞춤
4. 작업 전후 검증은 [REGRESSION_CHECKLIST.md](/home/hyunho/projects/mh-dashboard-organization/docs/REGRESSION_CHECKLIST.md) 기준으로 수행 4. 작업 전후 검증은 [REGRESSION_CHECKLIST.md](REGRESSION_CHECKLIST.md) 기준으로 수행
### 2. 기능 개발 중 ### 2. 기능 개발 중
@@ -154,7 +154,7 @@
2. 공개용 기준 데이터가 필요한지 판단 2. 공개용 기준 데이터가 필요한지 판단
3. 필요하면 `8081` DB를 `8080` 기준으로 먼저 동기화 3. 필요하면 `8081` DB를 `8080` 기준으로 먼저 동기화
4. 그 뒤 기능 개발과 검증 수행 4. 그 뒤 기능 개발과 검증 수행
5. 검증은 [REGRESSION_CHECKLIST.md](/home/hyunho/projects/mh-dashboard-organization/docs/REGRESSION_CHECKLIST.md) 기준으로 수행 5. 검증은 [REGRESSION_CHECKLIST.md](REGRESSION_CHECKLIST.md) 기준으로 수행
6. 검증 완료 후 공개용에 코드 승격 6. 검증 완료 후 공개용에 코드 승격
## 다음 액션 ## 다음 액션
@@ -167,14 +167,14 @@
반복 가능한 동기화 스크립트: 반복 가능한 동기화 스크립트:
- [sync_prod_db_to_dev.sh](/home/hyunho/projects/mh-dashboard-organization/scripts/sync_prod_db_to_dev.sh) - [sync_prod_db_to_dev.sh](../scripts/sync_prod_db_to_dev.sh)
- [docker-compose.8081.yml](/home/hyunho/projects/mh-dashboard-organization/docker-compose.8081.yml) - [docker-compose.8081.yml](../docker-compose.8081.yml)
사용 방법: 사용 방법:
```bash ```bash
./scripts/prepare_dev_worktree.sh ./scripts/prepare_dev_worktree.sh
cd /home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081 cd <repo>/.dev-worktree-8081
docker compose -p mh-dashboard-organization-dev --env-file .env -f docker-compose.8081.yml up -d --build docker compose -p mh-dashboard-organization-dev --env-file .env -f docker-compose.8081.yml up -d --build
./scripts/sync_prod_db_to_dev.sh minimal ./scripts/sync_prod_db_to_dev.sh minimal
./scripts/sync_prod_db_to_dev.sh full ./scripts/sync_prod_db_to_dev.sh full
@@ -187,15 +187,15 @@ docker compose -p mh-dashboard-organization-dev --env-file .env -f docker-compos
- 로컬 전용 디자인 참고 자산 복사 - 로컬 전용 디자인 참고 자산 복사
- `incoming-files/sample style.css` - `incoming-files/sample style.css`
- `incoming-files/260320.html` - `incoming-files/260320.html`
- `incoming-files/사업관리대장/` - `incoming-files/reference/ledger/`
- `incoming-files/1.png` - `incoming-files/1.png`
- `incoming-files/seat/center_chair_people_map(2).html` - `incoming-files/seat/center_chair_people_map(2).html`
중요: 중요:
- `8081`은 현재 메인 workspace를 직접 마운트하면 안 된다 - `8081`은 현재 메인 workspace를 직접 마운트하면 안 된다
- 컨테이너가 `/home/hyunho/projects/mh-dashboard-organization/...` 물고 있으면 분리 상태가 깨진 것이다 - 컨테이너가 메인 workspace를 직접 물고 있으면 분리 상태가 깨진 것이다
- 정상 상태는 `docker inspect mh-dashboard-organization-dev-backend-1` 기준 마운트 소스가 `/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/...`로 나와야 한다 - 정상 상태는 `docker inspect mh-dashboard-organization-dev-backend-1` 기준 마운트 소스가 격리 worktree 경로로 나와야 한다
규칙: 규칙:

View File

@@ -5,7 +5,7 @@
- 2026-03-27 기준 `curl http://localhost:8080/api/health` 정상 - 2026-03-27 기준 `curl http://localhost:8080/api/health` 정상
- 2026-03-27 기준 `curl http://localhost:8080/api/members` 에서 `items` 비어 있지 않음 - 2026-03-27 기준 `curl http://localhost:8080/api/members` 에서 `items` 비어 있지 않음
- 다른 PC 접속도 현재 확인됨 - 다른 PC 접속도 현재 확인됨
- 개발/운영 DB 분리 운영 원칙은 [DEV_PROD_DB_PROTOCOL.md](/home/hyunho/projects/mh-dashboard-organization/docs/DEV_PROD_DB_PROTOCOL.md) 기준으로 관리 - 개발/운영 DB 분리 운영 원칙은 [DEV_PROD_DB_PROTOCOL.md](DEV_PROD_DB_PROTOCOL.md) 기준으로 관리
## 1. 컨테이너 기동 ## 1. 컨테이너 기동
- `docker compose build` - `docker compose build`

View File

@@ -1,145 +0,0 @@
# Next Session Checkpoint
## Current Base
- `8080` 공개 기준 브랜치: `total`
- `8081` 작업 기준 브랜치: `work-8081`
- `8080` 공개 기준 커밋: `637b390`
- `8081` worktree 경로: `/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081`
- `8081` 실제 서빙 책임 맵: [architecture/8081_SERVING_MAP.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/architecture/8081_SERVING_MAP.md)
- 메인 히스토리: [DEVELOPMENT_HISTORY.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/DEVELOPMENT_HISTORY.md)
- 작업 룰북: [WORK_RULEBOOK.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/WORK_RULEBOOK.md)
- 실행 플로우: [WORK_EXECUTION_FLOW.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/WORK_EXECUTION_FLOW.md)
- dev/prod DB 프로토콜: [DEV_PROD_DB_PROTOCOL.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/DEV_PROD_DB_PROTOCOL.md)
- 회귀 체크리스트: [REGRESSION_CHECKLIST.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/REGRESSION_CHECKLIST.md)
## Mandatory Start Rule
당일 첫 작업 전에는 아래 순서를 먼저 확인한다.
1. 브랜치 기준 확인
2. 열린 이슈 확인
3. [WORK_RULEBOOK.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/WORK_RULEBOOK.md) 확인
4. 이 문서 확인
5. `git status`, 변경 파일, 미추적 파일 확인
주의:
- `8080` 기준 코드는 직접 수정하지 않는다.
- 새 작업은 항상 `.dev-worktree-8081`에서 진행한다.
- 커밋과 푸시는 사용자 지시가 있을 때만 수행한다.
## Confirmed Runtime Rule
- `8080`은 루트 workspace의 `total` 기준으로 유지한다.
- `8081``.dev-worktree-8081` + `work-8081` 기준으로만 수정한다.
- `main`, `hyunho`는 보류 브랜치이며 현재 작업에 사용하지 않는다.
- `8081` 변경을 `8080`에 올릴 때는 reviewed file diff 기준으로만 반영한다.
- `8081` DB는 운영 정본이 아니라 `8080` 기준 검증용 복제본처럼 다룬다.
## What Was Stabilized
### Branch / Worktree Safety
- 기존 `8081` 작업본은 [`.dev-worktree-8081-backup-2026-04-01`](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081-backup-2026-04-01)로 보존
- 현재 [`.dev-worktree-8081`](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081)는 `work-8081` 기준으로 재생성
- `8080` 루트 workspace는 그대로 두고 분리 운영
### 8081 Design / Serving Baseline
- 디자인 SSOT 토큰:
- [frontend/public/design-tokens.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/design-tokens.css)
- 디자인 SSOT 패턴:
- [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)
- 로그인 기본 스타일은 [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`를 직접 링크
디자인 수정 우선순위:
1. [frontend/public/design-tokens.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/design-tokens.css)
2. [frontend/public/design-patterns.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/design-patterns.css)
3. 화면별 실제 서빙 파일
주의:
- `incoming-files/sample style.css`는 참고 기준이지만 직접 런타임 수정 파일이 아니다.
- `incoming-files` 원본/reference 파일을 먼저 고치지 않는다.
- 새 디자인 수정은 먼저 토큰/패턴 파일에서 해결 가능한지 확인한 뒤, 불가피할 때만 화면별 파일에 내린다.
### 1차 구조 정리 진행분
- 이슈 기준:
- `#14` 전체 구조 정리 umbrella
- `#18` 1차: 파일 책임 맵 정리 및 프런트 서빙 경로 정돈
- `#19` 2차: 백엔드 라우터/서빙 책임 분리
- `#20` 3차: worktree/스크립트/문서 정리
- 책임 맵 문서 추가:
- [architecture/8081_SERVING_MAP.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/architecture/8081_SERVING_MAP.md)
- `/integrations/payment`, `/integrations/mh`의 실제 서빙 파일을 분리:
- [incoming-files/served/payment.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/incoming-files/served/payment.html)
- [incoming-files/served/mh.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/incoming-files/served/mh.html)
- 기존 [incoming-files/payment.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/incoming-files/payment.html), [incoming-files/mh.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/incoming-files/mh.html)은 비교/복구용 복사본으로 당분간 유지
- backend 서빙 경로는 [backend/app/main.py](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/backend/app/main.py)에서 `incoming-files/served/*`를 보도록 정리 시작
## Current Actual Serving Map
- `/`:
- [frontend/public/index.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/index.html)
- `/styles.css`:
- [frontend/public/styles.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/styles.css)
- `/styles-8081-design.css`:
- [frontend/public/styles-8081-design.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/styles-8081-design.css)
- `/legacy/organization`:
- [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/mh`:
- [incoming-files/served/mh.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/incoming-files/served/mh.html)
## Cross Checks Last Confirmed
- `8080`: `curl http://localhost:8080/api/health` 정상
- `8081` dev 컨테이너: proxy/backend/frontend/db `healthy`
- `8081` backend 내부 확인:
- `/api/health` 200
- `/legacy/organization` 200
- `/integrations/payment` 200
- `/integrations/mh` 200
- `incoming-files/served` 내 실제 서빙 파일 존재 확인
주의:
- Codex 터미널 세션에서는 `curl http://localhost:8081`가 간헐적으로 실패할 수 있다.
- 이 경우 브라우저 확인 또는 컨테이너 내부 라우트 확인을 기준으로 판단한다.
## Open Issues Relevant Now
- `#14` 누적된 임시 로직 정리 및 중복 코드 제거
- `#16` 사업관리대장 메인 연동 및 기본 원본 DB화
- `#17` 8081 분리 worktree 기동 절차와 로컬 디자인 자산 복제 고정
- `#18` 8081 파일 책임 맵 정리 및 프런트 서빙 경로 정돈
- `#19` 8081 백엔드 라우터/서빙 책임 분리
- `#20` 8081 worktree 준비 스크립트·문서·운영 규칙 정리
## Recommended Next Work Order
1. `#18` 범위에서 실제 서빙 파일과 비교용 파일 경계를 더 명확히 정리
2. 사업관리대장 탭 기능 추가 전에 수정 대상 파일을 고정
3. 그 다음 `#19`로 backend 라우터/서빙 책임 분리
4. 마지막으로 `#20`에서 스크립트/문서/운영 규칙 정리
## Quick Resume Prompt
다음 세션 시작 시 아래 기준으로 이어가면 된다.
- `8080` 기준은 `total`
- `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`를 먼저 확인

View File

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

159
docs/TEAM_GUIDE.md Normal file
View File

@@ -0,0 +1,159 @@
# Team Guide
이 문서는 이 저장소에서 작업할 때 가장 먼저 읽는 기준 문서다.
목표는 세 가지다.
- 어디를 수정해야 하는지 바로 알 수 있게 하기
- `8080``8081`을 헷갈리지 않게 하기
- 작업 순서와 검증 기준을 하나의 문서로 시작하게 하기
## 1. 먼저 이해할 구조
- `frontend/apps/*`
- 화면별 source-of-truth
- `incoming-files/served/*`
- integration 화면의 실제 런타임 파일
- `legacy/static/*`
- 조직현황 레거시 런타임 파일
- `incoming-files/reference/*`
- 원본 참고 자산
- `backend/app/routes/*`
- API 엔드포인트 등록
- `backend/app/services/*`
- 비즈니스 로직
- `backend/app/repositories/*`
- DB 읽기 쿼리
원칙:
- source를 먼저 수정하고 runtime은 publish로 반영한다.
- reference 파일은 비교/복구용이지 직접 수정 기준이 아니다.
## 2. 환경 원칙
- `8080`
- 공개 기준 환경
- 기준 데이터가 있는 쪽
- `8081`
- 개발/검증 환경
- 먼저 수정하고 검증하는 쪽
중요:
- 새 작업은 항상 `.dev-worktree-8081` 기준으로 시작한다.
- `8081`에서 검증되지 않은 변경을 바로 `8080`에 올리지 않는다.
- `8081` DB는 독립 정본이 아니라 검증용 복제본처럼 다룬다.
자세한 DB 운영 원칙은 [DEV_PROD_DB_PROTOCOL.md](DEV_PROD_DB_PROTOCOL.md)를 따른다.
## 3. 작업 시작 순서
1. 현재 브랜치와 변경 파일을 확인한다.
2. 연결된 이슈 또는 작업 목적을 확인한다.
3. 이번 작업의 source 파일과 runtime 파일을 구분한다.
4. 필요한 경우 `8081` 개발 환경을 띄운다.
5. 필요한 DB 동기화 범위를 결정한다.
6. 수정 후 관련 시나리오를 검증한다.
핵심 질문:
- 지금 고치는 파일이 실제 source-of-truth가 맞는가?
- 이 작업은 `8081`에서 먼저 검증해야 하는가?
- DB 차이 때문에 생긴 문제는 아닌가?
## 4. 수정 원칙
- 한 작업은 한 기능 또는 한 버그 단위로 작게 나눈다.
- 완료 기능은 관련 이슈 없이 함부로 건드리지 않는다.
- 임시 우회 로직은 이유와 제거 계획이 있어야 한다.
- 구조를 정리하더라도 기존 동작을 바꾸면 안 된다.
고위험 영역:
- `members`
- `seat_maps`
- `seat_slots`
- `seat_positions`
- `auth.*`
- 동기화 스크립트
- 스키마 변경
이 영역은 변경 이유, 영향 범위, 검증 결과를 반드시 남긴다.
## 5. 화면별 수정 기준
### 조직현황
- source: `frontend/apps/organization`
- runtime: `DashBoard-organization.html`, `legacy/static/*`
- publish: `./scripts/publish_organization_app.sh`
### 프로젝트별 분석
- source: `frontend/apps/payment/index.html`
- runtime: `incoming-files/served/payment.html`
- publish: `./scripts/publish_payment_app.sh`
### 팀/개인별 분석
- source: `frontend/apps/team/index.html`
- runtime: `incoming-files/served/mh.html`
- publish: `./scripts/publish_team_app.sh`
### 사업관리대장
- source: `frontend/apps/ledger/*`
- runtime: `incoming-files/served/ledger/*`
- publish: `./scripts/publish_ledger_app.sh`
### DB 상태
- source: `frontend/apps/db-status/index.html`
- runtime: `incoming-files/served/db-status/index.html`
- publish: `./scripts/publish_db_status_app.sh`
실제 서빙 책임은 [architecture/8081_SERVING_MAP.md](architecture/8081_SERVING_MAP.md)에서 확인한다.
## 6. 디자인 수정 원칙
- 먼저 `frontend/public/design-tokens.css`
- 다음 `frontend/public/design-patterns.css`
- 그 다음 [architecture/DESIGN_SSOT.md](architecture/DESIGN_SSOT.md)
- 마지막으로 화면별 파일
금지:
- reference 파일부터 수정하기
- 토큰/패턴으로 해결 가능한 것을 화면별 하드코딩으로 처리하기
- 예전 색 체계를 새 기본값으로 다시 넣기
## 7. 검증 원칙
- 완료 기준은 “코드를 썼다”가 아니라 “실제 동작을 검증했다”이다.
- 구조 정리나 라우트 분리 후에는 `./scripts/check_8081_smoke.sh`를 먼저 본다.
- 기능 수정 후에는 관련 화면만 보지 말고 주변 연동까지 확인한다.
검증 세부 항목은 [REGRESSION_CHECKLIST.md](REGRESSION_CHECKLIST.md)를 따른다.
자주 쓰는 DB 동기화:
- 조직현황/멤버/자리배치: `./scripts/sync_prod_db_to_dev.sh minimal`
- 분석 화면: `./scripts/sync_prod_db_to_dev.sh analysis`
- 전체 재검증: `./scripts/sync_prod_db_to_dev.sh full`
## 8. 커밋과 PR
- 커밋은 한 주제만 담는다.
- PR 본문에는 작업 목적, 변경 범위, 검증 방법, DB 영향 여부를 적는다.
- 공용 구조 파일을 수정했으면 영향 화면을 명시한다.
자세한 팀 작업 규칙은 [../CONTRIBUTING.md](../CONTRIBUTING.md)를 따른다.
## 9. 이 문서 다음에 읽을 것
- 협업 방식: [../CONTRIBUTING.md](../CONTRIBUTING.md)
- DB 운영 원칙: [DEV_PROD_DB_PROTOCOL.md](DEV_PROD_DB_PROTOCOL.md)
- 회귀 검증: [REGRESSION_CHECKLIST.md](REGRESSION_CHECKLIST.md)
- 실제 서빙 책임: [architecture/8081_SERVING_MAP.md](architecture/8081_SERVING_MAP.md)
- 디자인 기준: [architecture/DESIGN_SSOT.md](architecture/DESIGN_SSOT.md)

View File

@@ -1,143 +0,0 @@
# Today Work Prep - 2026-03-30
## Current Local State
- working branch: `total`
- HEAD: `24852d4` (`Fix seatmap slot matching and update member modal layout`)
- remote tracking: `origin/total`
- status: local branch is `ahead 2`
- open PRs: none
untracked files:
- `docs/HISTORY_ASOF_DB_PLAN.md`
- `incoming-files/6f.html`
- `incoming-files/7f.html`
- `incoming-files/center.html`
주의:
- `docs/NEXT_SESSION_CHECKPOINT.md` 의 최신 checked commit 은 아직 `1d15cf9` 로 남아 있다.
- 실제 최신 작업 판단은 아래 최근 2개 로컬 커밋 기준으로 보는 것이 맞다.
## What Was Added After `origin/total`
### Commit `d666141`
- 3개 고정 오피스 자리배치도 반영
- `technical-development-center`
- `hanmac-building-6f`
- `hanmac-building-7f`
- 백엔드 `office_key` 기반 active viewer/layout 조회 지원
- 프런트 자리배치도 탭에서 3개 오피스 선택 지원
- `scripts/sync_prod_db_to_dev.sh` 추가
- `docs/DEV_PROD_DB_PROTOCOL.md` 추가
### Commit `24852d4`
- slot 기반 자리 저장 시 slot matching 보정
- 멤버 상세 모달 / 조직도 seat preview 레이아웃 조정
- 회귀 점검용 `docs/REGRESSION_CHECKLIST.md` 추가
- dev/prod sync script 후속 보정
## Remote Branch / Issue Snapshot
remote branches:
- `total` -> `1d15cf9`
- `hyunho` -> `8efb5da`
- `main` -> `7a0bd54`
open issues:
- `#11` `[P0] [버그] 자리배치도 회귀 오류`
- `#12` `[P1] [DB] 공개용/작업용 seat_positions 스키마 불일치 정리`
- `#13` `[P1] [인프라] 작업용 DB 동기화 절차 안정화 및 자동화`
- `#14` `[P2] [리팩터링] 누적된 임시 로직 정리 및 중복 코드 제거`
- `#10` `[P1] [분석] 1~2월 원본 정합성 보정 및 팀/개인별 검색 범위 개선 작업 정리`
- `#9` `[P1] [이력관리] as-of date / 버전 누적 저장`
- `#8` `[P2] [자리배치도] 좌석 클릭 시 개인 상위 조직 트리 표시`
- `#7` `[P2] [자리배치도] 팀별 색상 오버레이 표시`
- `#5` `[P2] [인증] 권한 제어 마무리 및 mock login 정리`
- `#3` `[P1] [기능] 사무실 좌석 배치도 조회 및 관리자 편집 기능 고도화`
- `#2` `[P0] [인프라] 백엔드 영속 저장 구조 운영 마무리`
현재 관계 해석:
- `#11` 은 최근 2개 커밋이 직접 겨냥한 회귀 묶음이다.
- `#12`, `#13``#11` 재발 방지용 운영 과제에 가깝다.
- `#3` 은 다중 오피스 도면 반영으로 많이 진척됐지만, 공개용 기준 회귀 검증 전에는 완료 처리하면 안 된다.
- `#2` 는 단순 구현보다 dev/prod 데이터 운영 기준 정리가 핵심으로 바뀌었다.
- `#5` 는 로그인 구현보다 권한 경계와 `/api/mock-login` 정리가 남은 상태다.
## Best Starting Point Today
오늘 첫 작업은 새 기능 추가보다, 최근 자리배치도/DB 동기화 작업을 검증 가능한 상태로 굳히는 쪽이 우선이다.
우선순위:
1. `#13` 프로토콜대로 작업용 DB를 `minimal` 범위로 동기화
2. `docs/REGRESSION_CHECKLIST.md` 기준으로 자리배치도 회귀 확인
3. 최근 2개 로컬 커밋을 `origin/total` 에 올릴지 결정
4. 회귀가 남아 있으면 `#11` 계속, 없으면 `#5` 또는 `#12/#13` 후속 정리로 이동
이 순서가 맞는 이유:
- 현재 가장 최근 변경이 seatmap + DB sync 쪽에 몰려 있다.
- 원격 `total` 은 아직 해당 수정들을 포함하지 않는다.
- 검증 없이 다른 기능으로 넘어가면 회귀 원인과 신규 작업이 다시 섞인다.
## Concrete Start Checklist
세션 시작 즉시:
1. `docs/DEV_PROD_DB_PROTOCOL.md` 다시 확인
2. 필요 시 `./scripts/sync_prod_db_to_dev.sh minimal`
3. 로그인 상태 확인
4. 아래 3개를 오피스별로 확인
- 관리자 DnD 배치 저장
- 조직도 상세 seat preview
- 비관리자 seatmap 진입 / 표시
필수 확인 오피스:
- `기술개발센터`
- `한맥빌딩 6층`
- `한맥빌딩 7층`
## Recommended Decision Tree
### Case A. 회귀가 남아 있음
- 바로 `#11` 우선
- 동시에 원인 범주를 분리
- DB sync 실패
- `seat_positions` 스키마 차이
- 프런트 fallback 오류
- 저장 API 로직 오류
### Case B. 회귀가 해소됨
- 최근 2개 커밋 푸시
- Gitea `#11`, `#3`, `#2` 코멘트 상태 업데이트
- 다음 메인 작업을 아래 중 하나로 선택
- `#5` 권한 제어 / mock login 제거
- `#12`, `#13` DB sync 안정화 마무리
- `#9` history / as-of 구조 착수
## Suggested Main Task After Verification
가장 자연스러운 다음 메인 작업은 `#5` 보다 `#12`, `#13` 마무리다.
이유:
- 지금 이 코드베이스에서 자리배치도/조직도 검증은 DB 상태에 크게 좌우된다.
- 권한 작업을 시작해도 검증 기반이 흔들리면 다시 혼선이 생긴다.
- 반대로 sync 절차와 스키마 호환을 먼저 고정하면 이후 `#5`, `#9`, `#8`, `#7` 진행이 쉬워진다.
## Short Summary
- 코드 최신 상태는 로컬 `total@24852d4`
- 원격 `total` 은 아직 최신 seatmap/sync 수정 전 상태
- 오늘 첫 목표는 `#11` 관련 회귀 검증과 `#12/#13` 기반 정리
- 검증 완료 전에는 새 기능보다 seatmap + DB 운영 안정화를 우선하는 것이 맞다

View File

@@ -1,269 +0,0 @@
# Work Execution Flow
## 목적
이 문서는 앞으로 이 프로젝트에서 작업을 어떤 순서로 진행해야 하는지 아주 쉽게 고정하기 위한 문서다.
세미나에서 들은 흐름을 이 프로젝트 기준으로 다시 쓰면 아래 순서다.
1. `SSOT` 먼저 확인
2. 이슈 생성 또는 연결
3. 완료조건 먼저 적기
4. 실행 계획 적기
5. 필요한 동기화 먼저 하기
6. 코드 수정 / 화면 작업 수행
7. 가드레일 테스트
8. 기록 남기기
이 순서를 지키는 이유는 하나다.
- 작업 도중 기준이 바뀌지 않게 하기
- 임시 연결이 누적되지 않게 하기
- 나중에 봐도 왜 이렇게 했는지 알 수 있게 하기
- `8081` 작업이 `8080`을 망가뜨리지 않게 하기
## 1. SSOT 먼저 확인
`SSOT`는 Single Source Of Truth 의 줄임말이다.
쉬운 말로:
- "무엇을 기준 진실로 볼 것인가"
이걸 먼저 정하지 않으면 작업 중간에 기준이 계속 바뀌어서 코드가 꼬인다.
이 프로젝트에서 자주 쓰는 SSOT:
- 공개용 코드 기준: `/home/hyunho/projects/mh-dashboard-organization`
- 작업용 코드 기준: `/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081`
- 데이터 정본 기준: `8080` DB
- 기능 검증 기준: `8081`
- 사업관리대장 디자인 기준: `MH 통합 대시보드_260320.html`
- 허브 공통 시각 언어 기준: `sample style.css`
- 런타임 디자인 토큰 기준: `frontend/public/design-tokens.css`
- 런타임 디자인 패턴 기준: `frontend/public/design-patterns.css`
- 현재 작업 지시 기준: 연결된 Gitea 이슈
작업 시작 전에 먼저 정해야 하는 질문:
- 이번 작업의 코드 기준은 어디인가?
- 이번 작업의 데이터 기준은 어디인가?
- 이번 화면의 디자인 기준 파일은 무엇인가?
- 지금 바꾸려는 화면이 실제로 어떤 파일에서 렌더링되는가?
이걸 모르고 코드를 건드리면 높은 확률로 엉뚱한 파일을 수정하게 된다.
디자인 작업 추가 규칙:
- 디자인 수정은 항상 `design-tokens.css``design-patterns.css`를 먼저 확인한다.
- 색/패널/버튼/테이블/팝업이 공통 규칙으로 해결 가능한지 먼저 본다.
- 해결 가능하면 화면별 파일을 고치지 않고 토큰/패턴 파일에서 수정한다.
- 화면별 실제 서빙 파일은 마지막 단계에서만 조정한다.
- 원본/reference 파일은 비교용이지 직접 수정 우선 대상이 아니다.
## 2. 이슈 생성 또는 연결
작업은 이슈 없이 하지 않는다.
이유:
- 왜 하는 작업인지 남기기 위해
- 중간에 범위가 커지는 걸 막기 위해
- 다음 세션에서 바로 이어가기 위해
좋은 이슈는 아래 4개가 있어야 한다.
1. 배경
2. 목표
3. 현재 상태
4. 남은 작업
이슈는 길게 쓸 필요는 없다.
하지만 최소한 아래는 있어야 한다.
- 왜 이 작업을 하는지
- 어디까지가 이번 범위인지
- 무엇을 완료로 볼지
## 3. 완료조건 먼저 적기
이 단계가 중요하다.
완료조건이 없으면 "대충 된 것 같음" 상태에서 끝나기 쉽다.
좋은 완료조건 예시:
- `8081``.dev-worktree-8081`를 실제로 마운트한다
- `사업관리대장` 탭이 원본 기준 레이아웃으로 열린다
- `8080`은 영향 없이 유지된다
- 관련 회귀 검증을 통과한다
나쁜 완료조건 예시:
- 화면이 좀 괜찮아 보인다
- 아마 될 것 같다
- 코드 정리함
완료조건은 반드시 확인 가능한 문장이어야 한다.
즉:
- "봤을 때 예쁨"이 아니라
- "어떤 URL에서 어떤 동작이 확인됨"이어야 한다
## 4. 실행 계획 적기
계획은 길 필요 없다.
이 프로젝트에서는 보통 아래 정도면 충분하다.
1. 기준 파일과 현재 연결 구조 확인
2. `8081` worktree 기준으로만 수정
3. 필요한 데이터 동기화
4. 화면/기능 수정
5. 회귀 검증
6. 이슈 코멘트와 체크포인트 기록
핵심은:
- 수정 전에 먼저 구조를 파악하고
- 범위를 정하고
- 검증까지 포함해서 끝내는 것
## 5. 실행 전 동기화
이 프로젝트는 코드만 맞아도 안 되고, 데이터도 맞아야 한다.
그래서 실행 전에 동기화가 필요할 수 있다.
무슨 뜻이냐면:
- `8081`에서 기능 확인을 하더라도
- 데이터가 `8080`과 다르면 검증 결과를 신뢰하면 안 된다
자주 쓰는 규칙:
- 조직도 / 멤버 / 자리배치 검증 전
- `./scripts/sync_prod_db_to_dev.sh minimal`
- 분석 화면까지 공개용 기준으로 맞춰야 할 때
- `./scripts/sync_prod_db_to_dev.sh full`
또 코드 동기화도 중요하다.
- `8081`은 메인 workspace에서 직접 띄우지 않는다
- 먼저 `./scripts/prepare_dev_worktree.sh`
- 그 다음 `.dev-worktree-8081`에서 실행
즉 이 프로젝트의 동기화는 두 종류다.
- DB 동기화
- 코드/worktree 동기화
## 6. 실제 실행
이 단계가 코드를 고치는 단계다.
하지만 여기서도 규칙이 있다.
- `8081`에서 먼저 작업
- 기준 파일이 아닌 곳은 건드리지 않기
- 임시 우회 연결을 만들었으면 반드시 기록 남기기
- 연결 구조가 난잡해지면 바로 이슈에 `코드 정리 필요`를 남기기
특히 이 프로젝트는 아래가 자주 꼬인다.
- `frontend/public`
- `legacy/static`
- `incoming-files`
- 정적 HTML
- iframe 연결
- 버전 쿼리스트링
그래서 실행 중 계속 확인해야 한다.
- 지금 내가 고친 파일이 실제 서빙 파일이 맞는가?
- 지금 수정이 `8081` 전용인가, `8080` 공통인가?
- 이 연결은 임시인가, 기준 구조인가?
## 7. 가드레일 테스트
가드레일 테스트는 쉬운 말로:
- "이 수정 때문에 같이 망가지면 안 되는 것들을 확인하는 테스트"
즉 핵심 기능만 보는 게 아니라, 같이 깨지기 쉬운 주변 기능까지 확인하는 것이다.
이 프로젝트에서 가드레일 테스트 예시:
- `8081` 디자인 수정 후
- `8080`은 그대로인지 확인
- 조직현황 수정 후
- 조직도 iframe, 모달, 리스트뷰, seat preview 확인
- 자리배치 수정 후
- 관리자 저장
- 비관리자 조회
- 조직도 상세 seat preview
- 분석 화면 수정 후
- 기간 필터
- 프로젝트/팀 전환
- 빈 데이터 상태
- 스타일 깨짐 여부
가드레일 테스트는 "다 테스트한다"가 아니다.
이번 수정 때문에 같이 깨질 가능성이 높은 것만 빠르게 확인하는 것이다.
## 8. 기록 남기기
작업은 기록까지 남겨야 끝난다.
남겨야 하는 것:
- 무엇을 바꿨는지
- 무엇을 기준으로 했는지
- 무엇을 검증했는지
- 무엇이 아직 안 끝났는지
- 다음에 어디서 이어야 하는지
남길 위치:
- Gitea 이슈 코멘트
- 체크포인트 문서
- 필요하면 룰북/프로토콜 문서
## 이 프로젝트용 한 줄 버전
앞으로는 아래 순서로 생각하면 된다.
1. 기준 진실부터 정한다
2. 이슈에 작업 목적과 완료조건을 적는다
3. 실행 전에 코드/DB 동기화를 맞춘다
4. `8081`에서만 수정한다
5. 같이 깨지면 안 되는 것까지 확인한다
6. 결과를 기록한다
## 시작할 때 바로 쓰는 짧은 템플릿
작업 시작 전에 아래 6줄만 적어도 된다.
- SSOT:
- 코드 기준:
- 데이터 기준:
- 디자인 기준:
- 이슈:
- 완료조건:
- 계획:
- 필요한 동기화:
- 가드레일 테스트:
예시:
- SSOT:
- 코드 기준: `/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081`
- 데이터 기준: `8080` DB를 sync한 `8081`
- 디자인 기준: `MH 통합 대시보드_260320.html`
- 이슈: `#16`
- 완료조건: `8081`에서 사업관리대장 메인이 원본 톤으로 열리고 `8080`은 안 바뀜
- 계획: 연결 확인 → worktree 수정 → 검증 → 이슈 기록
- 필요한 동기화: `minimal`
- 가드레일 테스트: `8080 유지`, `조직현황 탭`, `프로젝트/팀 탭`

View File

@@ -1,285 +0,0 @@
# Work Rulebook
## Purpose
이 문서는 이 프로젝트에서 매일 작업을 시작하고 마무리할 때 반드시 따를 운영 규칙을 고정하기 위한 룰북이다.
목표는 아래 4가지다.
- 완료된 기능의 회귀 방지
- 코드 문제와 DB 문제의 혼선 방지
- 작업 기록 누락 방지
- 매일 같은 기준으로 안정적으로 이어서 작업
## Rule 0. Morning Start Mandatory Check
이 규칙은 강제 규칙이다.
매일 아침 또는 그날의 첫 작업을 시작할 때는, 코드를 수정하기 전에 반드시 아래 순서를 먼저 수행한다.
1. Gitea 브랜치 상태 확인
2. 열린 이슈 확인
3. 이 문서 `WORK_RULEBOOK.md` 확인
4. 최신 체크포인트 문서 확인
5. 현재 워크트리의 미푸시 커밋, 변경 파일, 미추적 파일 확인
위 5단계를 확인하기 전에는 새 코드 작성, 기존 코드 수정, 임의 테스트 진행을 시작하지 않는다.
즉:
- "오늘 첫 작업"의 시작점은 코드 수정이 아니라 상태 확인이다.
- 이 절차를 건너뛰고 바로 수정 작업에 들어가는 것은 금지한다.
추가 기준:
- 실제 작업 순서는 [WORK_EXECUTION_FLOW.md](/home/hyunho/projects/mh-dashboard-organization/docs/WORK_EXECUTION_FLOW.md) 를 따른다.
- 특히 `SSOT → 이슈 → 완료조건 → 계획 → 동기화 → 실행 → 가드레일 테스트 → 기록` 순서를 기본 운영 흐름으로 본다.
## Rule 1. Completed Feature Protection
완료 판정된 작업물의 기능과 코드는 함부로 건드리지 않는다.
세부 규칙:
- 직접 관련된 이슈가 없으면 완료 기능을 수정하지 않는다.
- 완료 기능 수정이 필요하면 먼저 이유와 영향 범위를 이슈 또는 코멘트에 남긴다.
- 단순 편의상 구조를 바꾸거나 정리하는 리팩터링으로 완료 기능 동작을 바꾸지 않는다.
- 완료 기능을 수정한 경우에는 관련 회귀 검증까지 완료해야 한다.
핵심 원칙:
- "고치는 김에 같이 정리"를 금지한다.
- 수정 범위는 현재 작업 목적에 필요한 최소 범위로 제한한다.
## Rule 2. Work Must Be Tied To An Issue
원칙적으로 이슈 없는 작업은 하지 않는다.
세부 규칙:
- 모든 작업은 기존 이슈에 연결하거나 새 이슈/작업 메모를 만든 뒤 시작한다.
- 왜 하는 작업인지 한 줄로라도 남긴다.
- 임시 대응도 예외가 아니다.
## Rule 3. Branch And Workspace Awareness
작업 전에 현재 브랜치와 워크트리 상태를 먼저 확인한다.
반드시 확인할 항목:
- 현재 브랜치
- 원격 대비 ahead / behind 상태
- 미푸시 커밋
- 수정된 파일
- 미추적 파일
금지:
- 로컬에서만 있는 상태를 기준 진실처럼 가정하기
- 미정리 변경사항을 모른 채 새 작업을 덧붙이기
## Rule 4. DB Before Code Assumption
조직도, 멤버, 자리배치도, 권한 문제는 코드보다 DB 상태 영향을 먼저 의심한다.
세부 규칙:
- dev DB와 prod DB가 다른데 코드 버그로 단정하지 않는다.
- 공개용 기준 데이터가 필요한 검증은 먼저 동기화 상태를 확인한다.
- DB 차이를 무시한 검증 결과를 신뢰하지 않는다.
## Rule 5. Dev / Prod Protocol Is Mandatory
`docs/DEV_PROD_DB_PROTOCOL.md` 의 규칙은 권고가 아니라 작업 기준이다.
핵심 원칙:
- 코드 선행은 `8081`
- 데이터 정본은 `8080`
- `8081` DB는 독립 정본이 아니라 검증용 복제본처럼 다룬다
조직도/자리배치도/멤버 검증 전에는 필요 시 아래를 먼저 수행한다.
- `./scripts/sync_prod_db_to_dev.sh minimal`
분석 화면까지 공개용 기준으로 맞출 필요가 있으면 아래를 사용한다.
- `./scripts/sync_prod_db_to_dev.sh full`
## Rule 6. Validation Before Completion
완료 기준은 "코드를 썼다"가 아니라 "실제 동작을 검증했다"이다.
세부 규칙:
- 검증 없이 완료로 판단하지 않는다.
- 감으로 확인하지 않고 체크리스트 기준으로 확인한다.
- 회귀 가능성이 있는 수정은 관련 기능까지 같이 확인한다.
검증 기준 문서:
- `docs/REGRESSION_CHECKLIST.md`
## Rule 7. Seat Map Work Is High Risk
자리배치도 관련 작업은 항상 고위험 작업으로 취급한다.
작업 시 최소 확인 항목:
1. 관리자 DnD 배치 / 저장
2. 조직도 상세의 seat preview
3. 비관리자 seatmap 진입 / 표시
오피스가 여러 개면 아래 모두 확인한다.
- `기술개발센터`
- `한맥빌딩 6층`
- `한맥빌딩 7층`
기술개발센터만 보고 완료 처리하지 않는다.
## Rule 8. Auth / Schema / Sync Changes Are High Risk
아래 영역은 일반 기능 수정처럼 다루지 않는다.
- `auth.*`
- `members`
- `seat_maps`
- `seat_slots`
- `seat_positions`
- 동기화 스크립트
- 스키마 변경
이 작업은 반드시:
- 변경 이유 명시
- 영향 범위 확인
- 관련 검증 수행
- 결과 기록
까지 포함해야 한다.
## Rule 9. Temporary Logic Must Be Tracked
mock, fallback, hotfix, 임시 우회 로직은 허용할 수 있다.
하지만 반드시 추적 가능해야 한다.
세부 규칙:
- 왜 임시인지 기록한다.
- 제거 또는 정식화할 이슈를 연결한다.
- 운영 기준 로직처럼 장기 방치하지 않는다.
## Rule 10. End-Of-Day Closing Record
작업 종료 시 아래를 반드시 남긴다.
- 무엇을 했는지
- 무엇을 검증했는지
- 무엇이 아직 남았는지
- 다음에 어디서 이어야 하는지
남길 위치:
- Gitea 이슈 코멘트
- 또는 체크포인트 문서
둘 다 가능하면 둘 다 남긴다.
## Rule 11. Commit And Push Need Explicit User Instruction
커밋과 푸시는 자동으로 하지 않는다.
세부 규칙:
- 코드 수정, 문서 수정, 검증 작업은 커밋 없이 계속 진행할 수 있다.
- `git commit` 은 사용자가 명시적으로 지시한 경우에만 수행한다.
- `git push` 도 사용자가 명시적으로 지시한 경우에만 수행한다.
- 작업 중간 상태는 워크트리에 남겨둘 수 있으며, 임의로 잘라서 자주 커밋하지 않는다.
- 커밋이 필요하다고 판단되면 먼저 상태와 이유를 공유하고, 지시를 받은 뒤 진행한다.
## Rule 12. Promote 8081 To 8080 By Reviewed File Diff Only
`8081` 작업용에서 검증된 변경을 `8080` 공개용으로 가져갈 때는 전체 workspace 를 통째로 덮지 않는다.
세부 규칙:
- 먼저 `8081` 작업용의 변경 파일 목록과 diff 를 확인한다.
- 공개용에 필요한 파일만 선택해서 메인 workspace 로 반영한다.
- 반영 후에는 메인 workspace 기준으로 최소 회귀 검증을 다시 수행한다.
- `8081` DB 기준으로만 맞는 수정인지, `8080` 기준 데이터에서도 맞는지 다시 확인한다.
- 검증이 끝나기 전에는 공개용 완료로 판단하지 않는다.
금지:
- `8081` 작업 디렉터리를 통째로 복사해서 `8080`에 덮어쓰기
- diff 확인 없이 일괄 반영
- `8081`에서 됐으니 `8080`도 같을 것이라고 가정하기
## Rule 13. 8081 Must Start From The Isolated Worktree
`8081` 작업은 항상 `.dev-worktree-8081` 기준으로 시작한다.
세부 규칙:
- 디자인 작업도 예외가 아니다.
- 허브/조직현황/프로젝트별 분석/사업관리대장 수정 전에 현재 실제 서빙 파일과 SSOT 파일을 먼저 확인한다.
디자인 작업 강제 우선순위:
1. `frontend/public/design-tokens.css`
2. `frontend/public/design-patterns.css`
3. `docs/architecture/DESIGN_SSOT.md`
4. 그 다음 화면별 실제 서빙 파일
금지:
- reference/original 파일을 먼저 수정하기
- 예전 파란톤/indigo/slate 계열을 새 기본값으로 다시 넣기
- 토큰/패턴으로 해결 가능한 문제를 화면별 임시 하드코딩으로 처리하기
`8081` 작업용은 포트만 다른 복제 서버가 아니라, 코드 소스까지 분리된 전용 worktree여야 한다.
세부 규칙:
- `8081`은 항상 `.dev-worktree-8081`에서 띄운다.
- 기동 전 `./scripts/prepare_dev_worktree.sh`를 먼저 실행한다.
- 재부팅 후 빠른 기동은 `./scripts/start_8081.sh` 또는 `./scripts/start_local_dashboards.sh`를 사용한다.
- `.env`와 로컬 전용 디자인 자산은 준비 스크립트가 복사한 것을 기준으로 사용한다.
- 기동 후 `docker inspect mh-dashboard-organization-dev-backend-1`로 마운트 소스를 확인한다.
금지:
- 현재 메인 workspace를 직접 마운트한 상태로 `8081`을 띄우기
- `8080``8081`이 같은 `frontend/public`, `legacy/static`, `incoming-files`를 동시에 보게 두기
- `8081`에서 보이던 디자인을 `8080` 공통 소스에 바로 덮어쓰기
## Daily Start Checklist
매일 첫 작업 시작 전 체크:
- 현재 브랜치 확인
- 원격 대비 커밋 상태 확인
- 열린 이슈 확인
- `WORK_RULEBOOK.md` 확인
- 최신 체크포인트 확인
- 미추적 / 수정 파일 확인
- 현재 작업은 커밋 없이 진행하고, 커밋/푸시는 지시받을 때만 한다는 규칙 확인
- 오늘 작업이 코드 문제인지 DB 문제인지 먼저 구분
- 공개용 기준 데이터 검증이 필요한지 판단
## Daily End Checklist
매일 작업 종료 전 체크:
- 오늘 변경 파일 정리
- 검증 결과 정리
- 미완료 항목 정리
- 관련 이슈 코멘트 또는 문서 업데이트
- 다음 시작 지점 명시
## One-Line Operating Principle
이 프로젝트의 작업 기준은 아래 한 줄로 요약한다.
- 상태를 먼저 확인하고, 완료 기능은 보호하며, DB와 검증을 무시하지 않고, 기록을 남기면서 작업한다.

View File

@@ -1,34 +0,0 @@
# WSL 작업 기준 가이드
## 1. 왜 WSL 기준으로 작업하나
- 현재 이 프로젝트는 Ubuntu 24.04 기반 Docker 환경에서 실행되고 있습니다.
- Windows 폴더에서 바로 작업하면 실행 경로와 편집 경로가 달라질 수 있습니다.
- 그래서 앞으로는 `WSL Ubuntu 내부 경로`를 기준 작업공간으로 사용하는 것을 권장합니다.
## 2. 기준 작업 경로
- 사용자: `hyunho`
- 프로젝트 경로: `/home/hyunho/projects/mh-dashboard-organization`
## 3. VS Code에서 여는 방법
1. VS Code 명령 팔레트를 엽니다.
2. `Remote-WSL: Reopen Folder in WSL` 를 실행합니다.
3. 아래 경로를 엽니다.
- `/home/hyunho/projects/mh-dashboard-organization`
4. 좌측 아래 상태 표시줄에 `WSL: Ubuntu-24.04` 가 보이면 정상입니다.
## 4. 앞으로의 작업 규칙
- 코드 수정은 가능하면 WSL 경로 기준으로 진행합니다.
- Docker 실행, Python 실행, 배포 테스트도 WSL 안에서 진행합니다.
- Windows 경로는 참고용 또는 백업용으로만 보고, 실행 기준으로 사용하지 않는 것이 좋습니다.
## 5. 자주 쓰는 명령
```bash
cd /home/hyunho/projects/mh-dashboard-organization
docker compose ps
docker compose logs backend
docker compose up -d
```
## 6. 현재 확인 가능한 주소
- 메인 화면: `http://localhost:8080`
- API 상태 확인: `http://localhost:8080/api/health`

View File

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

View File

@@ -0,0 +1,205 @@
# DB Table Catalog
## Purpose
이 문서는 현재 PostgreSQL 테이블을 역할별로 분류한 운영 기준 문서다.
핵심 원칙:
- 테이블 수가 많다고 바로 줄이지 않는다.
- 먼저 `유지 / 주의 / 원본·추적 / 정리 후보`로 나눈다.
- 실제 운영 화면과 저장 흐름에 필요한 것은 유지한다.
- 의미가 불분명하거나 중복 역할만 하는 것은 후보로 남겨두고, 실제 삭제는 별도 검증 후 진행한다.
## 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

@@ -2,9 +2,9 @@
## Source of truth ## Source of truth
- Primary visual source: [incoming-files/sample style.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/incoming-files/sample%20style.css) - Primary visual source: [incoming-files/sample style.css](../../incoming-files/sample%20style.css)
- Runtime token file: [design-tokens.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/design-tokens.css) - Runtime token file: [design-tokens.css](../../frontend/public/design-tokens.css)
- Runtime pattern file: [design-patterns.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/design-patterns.css) - Runtime pattern file: [design-patterns.css](../../frontend/public/design-patterns.css)
`sample style.css` defines the intended MH visual language. `design-tokens.css` is the token-level SSOT, and `design-patterns.css` is the component-level SSOT that packages those tokens into reusable runtime patterns. `sample style.css` defines the intended MH visual language. `design-tokens.css` is the token-level SSOT, and `design-patterns.css` is the component-level SSOT that packages those tokens into reusable runtime patterns.

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,661 @@
(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 bgNormalizeText(value) {
return String(value || "").replace(/\s+/g, " ").trim();
}
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 normalizedCategory(row) {
var category = bgNormalizeText(row && row.cat);
if (category.indexOf("가족사") >= 0) return "가족사";
var corp = bgNormalizeText(row && row.corp);
if (corp && corp !== "바론") return "가족사";
return "바론";
}
function isSupportServiceRow(row) {
return bgNormalizeText(row && row.name).indexOf("경영 및 기술지원 서비스") >= 0;
}
function projectTypeLabel(row) {
if (isSupportServiceRow(row)) return "기술지원서비스";
return normalizedCategory(row);
}
function projectTypeRank(row) {
var label = projectTypeLabel(row);
if (label === "바론") return 0;
if (label === "가족사") return 1;
return 2;
}
function normalizeStatusLabel(status) {
var value = bgNormalizeText(status);
if (!value) return "-";
if (value === "완료") return "준공";
if (value === "진행") return "과업진행중";
if (value === "대기") return "계약대기";
if (value === "중지") return "과업중지";
return value;
}
function rowStatusLabel(row) {
return normalizeStatusLabel(row && row.status);
}
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 rowStatusLabel(row) === "과업진행중";
}
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 rowStatusLabel(row) === "준공" && 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 isBaronProjectRow(row) {
return projectTypeLabel(row) === "바론";
}
function isSoftwareProjectRow(row) {
var name = bgNormalizeText(row && row.name).toLowerCase();
if (!name) return false;
return [
"프로그램",
"소프트웨어",
"software",
" sw",
"sw ",
"erp",
"tova",
"ipipe",
"eg-bim",
"cad"
].some(function (keyword) {
return name.indexOf(keyword) >= 0;
});
}
function shouldSinkProjectName(row) {
var name = bgNormalizeText(row && row.name);
return name.indexOf("프로그램") >= 0 || name.indexOf("사용") >= 0;
}
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 = activeRows.filter(isSupportServiceRow);
var baronActiveRows = activeRows.filter(isBaronProjectRow);
return {
targetYear: targetYear,
activeRows: activeRows,
newProjectRows: newProjectRows,
completedRows: completedRows,
managementRows: managementRows,
managementTotals: bgTotals(managementRows),
baronActiveRows: baronActiveRows,
baronProjectTotals: bgTotals(baronActiveRows),
baronSoftwareCount: baronActiveRows.filter(isSoftwareProjectRow).length
};
}
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 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 startYear = Number(projectYear(row) || 0);
if (!startYear) return 9998;
return startYear;
}
function tableGroupLabel(row) {
var startYear = projectYear(row);
if (/^20\d{2}$/.test(startYear)) return startYear + " " + projectTypeLabel(row);
return "미지정 " + projectTypeLabel(row);
}
function compareDashboardRows(a, b) {
var typeRankDiff = projectTypeRank(a) - projectTypeRank(b);
if (typeRankDiff !== 0) return typeRankDiff;
var groupDiff = groupSortRank(a) - groupSortRank(b);
if (groupDiff !== 0) return groupDiff;
var sinkDiff = Number(shouldSinkProjectName(a)) - Number(shouldSinkProjectName(b));
if (sinkDiff !== 0) return sinkDiff;
return bgNormalizeText(a && a.name).localeCompare(bgNormalizeText(b && b.name), "ko");
}
function filterCategoryLabel(row) {
return projectTypeLabel(row);
}
function filterClientLabel(row) {
if (typeof normalizeClientDisplay === "function") {
return normalizeClientDisplay(row && row.client);
}
return bgNormalizeText(row && row.client) || "-";
}
function filterOrderLabel(row) {
return bgNormalizeText(row && row.order) || "-";
}
function receivableFilterLabel(row) {
var amount = Number((row && row.recv) || 0);
if (amount <= 0) return "미수 없음";
if (amount < 10000000) return "1천만 미만";
if (amount < 100000000) return "1천만 이상";
return "1억 이상";
}
function refreshFilterDom() {
E.filterButtons = Object.fromEntries(Array.from(document.querySelectorAll(".th-trigger")).map(function (el) {
return [el.dataset.filter, el];
}));
E.filterMenus = Object.fromEntries(Array.from(document.querySelectorAll(".th-menu")).map(function (el) {
return [el.dataset.filter, el];
}));
}
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(compareDashboardRows);
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 ' + (rowStatusLabel(r) === "준공" ? 'ok' : '') + '">' + esc(rowStatusLabel(r)) + '</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("");
refreshFilterDom();
if (typeof syncColumnFilters === "function") syncColumnFilters(S.all);
}
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 totals = summary.baronProjectTotals;
var totalRate = totals.c > 0 ? (totals.col / totals.c) * 100 : 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: summary.baronActiveRows.length.toLocaleString("ko-KR") + "건 (" + summary.baronSoftwareCount.toLocaleString("ko-KR") + "건)", note: "바론 수행중 프로젝트 / SW" },
{ label: "계약금 (VAT별도)", 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);
});
S.rows.sort(compareDashboardRows);
render();
};
filterDefinitions = function () {
return [
{ key: "cat", map: filterCategoryLabel },
{ key: "code", map: function (r) { return r.code || "-"; } },
{ key: "name", map: function (r) { return r.name || "-"; } },
{ key: "client", map: filterClientLabel },
{ key: "order", map: filterOrderLabel },
{ key: "status", map: rowStatusLabel },
{ key: "amount", map: amountFilterLabel },
{ key: "outsource", map: outsourceFilterLabel },
{ key: "receivable", map: receivableFilterLabel },
{ key: "collected", map: collectedFilterLabel },
{ key: "rate", map: rateFilterLabel }
];
};
updateFilterButtons = function () {
Object.keys(E.filterButtons || {}).forEach(function (key) {
var btn = E.filterButtons[key];
if (!btn) return;
var active = !!S.filters[key];
btn.classList.toggle("active", active);
btn.title = active ? ((btn.dataset.label || "") + ": " + S.filters[key]) : (btn.dataset.label || "");
var mark = btn.querySelector(".th-mark");
if (mark) mark.textContent = active ? "•" : "";
});
};
syncColumnFilters = function (rows) {
filterDefinitions().forEach(function (def) {
var 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();
};
matchesColumnFilters = function (r) {
if (S.filters.cat && filterCategoryLabel(r) !== S.filters.cat) return false;
if (S.filters.code && (r.code || "-") !== S.filters.code) return false;
if (S.filters.name && (r.name || "-") !== S.filters.name) return false;
if (S.filters.client && filterClientLabel(r) !== S.filters.client) return false;
if (S.filters.order && filterOrderLabel(r) !== S.filters.order) return false;
if (S.filters.status && rowStatusLabel(r) !== S.filters.status) return false;
if (S.filters.amount && amountFilterLabel(r) !== S.filters.amount) return false;
if (S.filters.outsource && outsourceFilterLabel(r) !== S.filters.outsource) return false;
if (S.filters.receivable && receivableFilterLabel(r) !== S.filters.receivable) 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;
};
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);
});
}
var panel = document.querySelector(".panel");
if (panel && !panel.dataset.ledgerFilterBound) {
panel.dataset.ledgerFilterBound = "true";
panel.addEventListener("click", function (event) {
var trigger = event.target && event.target.closest ? event.target.closest(".th-trigger") : null;
if (trigger) {
refreshFilterDom();
event.stopPropagation();
toggleFilterMenu(trigger.dataset.filter);
return;
}
var option = event.target && event.target.closest ? event.target.closest("button[data-filter-value]") : null;
var menu = event.target && event.target.closest ? event.target.closest(".th-menu") : null;
if (option && menu) {
event.stopPropagation();
setFilterValue(menu.dataset.filter, option.getAttribute("data-filter-value") || "");
}
});
}
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

@@ -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,16 @@
# Organization App
조직현황 탭 전용 소스 디렉터리다.
- 편집 원본:
- `index.html`
- `assets/common.css`
- `assets/organization.css`
- `assets/organization.js`
- 실제 서빙 대상:
- `DashBoard-organization.html`
- `legacy/static/common.css`
- `legacy/static/organization.css`
- `legacy/static/organization.js`
수정 후에는 `scripts/publish_organization_app.sh`를 실행해 서빙 파일로 반영한다.

View File

@@ -0,0 +1,110 @@
@import url("/design-tokens.css?v=20260401-01");
@import url("/design-patterns.css?v=20260401-01");
:root {
--font-sans: var(--ds-font-sans);
--color-bg: var(--ds-bg);
--color-bg-soft: var(--ds-bg-soft);
--color-surface: var(--ds-panel);
--color-surface-soft: var(--ds-panel-soft);
--color-surface-strong: var(--ds-panel-strong);
--color-text: var(--ds-ink);
--color-text-soft: var(--ds-text-soft);
--color-text-muted: var(--ds-text-muted);
--color-border: var(--ds-line);
--color-border-soft: var(--ds-line-soft);
--color-header: var(--ds-brand);
--color-header-soft: var(--ds-brand-soft);
--color-accent: var(--ds-accent);
--color-accent-soft: var(--ds-accent-soft);
--color-accent-strong: var(--ds-accent-strong);
--radius-sm: var(--ds-radius-sm);
--radius-md: var(--ds-radius-md);
--radius-lg: var(--ds-radius-lg);
--radius-xl: var(--ds-radius-xl);
--radius-pill: var(--ds-radius-pill);
--shadow-soft: var(--ds-shadow-soft);
--shadow-card: var(--ds-shadow-card);
--shadow-float: var(--ds-shadow-float);
--space-1: var(--ds-space-1);
--space-2: var(--ds-space-2);
--space-3: var(--ds-space-3);
--space-4: var(--ds-space-4);
--space-5: var(--ds-space-5);
--space-6: var(--ds-space-6);
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
height: 100%;
min-height: 100%;
font-family: var(--font-sans);
color: var(--color-text);
background: var(--ds-bg-gradient);
}
body {
min-height: 100vh;
overflow: hidden;
background: var(--ds-bg-gradient);
}
button,
input,
select,
textarea,
a {
font: inherit;
}
.ui-card {
background: var(--color-surface-soft);
border: 1px solid var(--color-border-soft);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-soft);
backdrop-filter: blur(10px);
}
.ui-pill {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 38px;
padding: 0 14px;
border-radius: var(--radius-pill);
}
.ui-button-primary {
border: none;
color: #fff;
background: var(--color-accent);
box-shadow: var(--shadow-float);
}
.ui-button-secondary {
border: 1px solid var(--color-border-soft);
color: var(--color-text);
background: var(--ds-surface-tint);
}
.ui-input {
border: 1px solid var(--color-border-soft);
border-radius: var(--radius-pill);
background: var(--ds-surface-tint-strong);
color: var(--color-text);
outline: none;
}
.ui-input:focus {
border-color: rgba(47, 153, 115, 0.45);
box-shadow: 0 0 0 4px rgba(47, 153, 115, 0.1);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,65 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MH 조직현황 관리</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;900&display=swap" rel="stylesheet" />
<script src="https://cdn.tailwindcss.com"></script>
<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" />
<div class="search-section">
<div class="flex flex-col w-full">
<div class="relative flex items-center w-full">
<span class="search-icon">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
</span>
<input type="text" id="search-input" placeholder="이름 또는 조직 검색" class="search-input" />
</div>
<div id="dept-tabs" class="dept-tabs-container"></div>
</div>
</div>
<div id="stats-area" class="stats-section" style="padding: 10px 15px;">
<div class="flex justify-between items-center mb-0 cursor-pointer p-0" id="stats-header">
<h2 class="stats-title text-xs font-black text-slate-800 flex items-center gap-2">인원 현황 통계 <span id="total-count-badge" class="bg-indigo-100 text-indigo-600 text-[10px] px-2 py-0.5 rounded-full">0명</span></h2>
<span id="stats-toggle-icon" class="text-slate-400 text-xs transition-transform duration-200" style="transform: rotate(-90deg);"></span>
</div>
<div id="stats-table-container" class="mt-3 overflow-hidden transition-all duration-300" style="display: none;"></div>
</div>
<div id="tree-root" class="org-canvas">
<div class="text-slate-400 font-bold mt-20 text-xs text-center">서버에서 조직 데이터를 불러오는 중입니다.</div>
</div>
<button id="admin-mode-btn" class="admin-mode-btn" data-label="관리자 모드 전환">🔐</button>
<div id="last-updated" class="fixed bottom-4 left-5 text-[10px] text-slate-400 font-bold z-[4000] pointer-events-none" style="letter-spacing: 0.02em; opacity: 0.8;"></div>
<div class="fab-container" id="fab-container">
<button class="fab-main text-white" id="fab-main">+</button>
<div class="fab-menu" id="fab-menu"></div>
</div>
<div id="modal">
<div class="modal-content">
<div id="modal-header-area">
<h2 id="modal-title" class="text-xl font-black mb-6 text-slate-800 border-b pb-4">구성원 정보 수정</h2>
</div>
<div id="modal-fields" class="grid grid-cols-2 gap-x-8 gap-y-5"></div>
<div id="modal-footer-area" class="flex gap-4 mt-10">
<button id="modal-cancel-btn" class="flex-1 bg-slate-100 py-3.5 rounded-xl font-bold text-sm">취소</button>
<button id="btn-save" class="flex-1 bg-indigo-600 text-white py-3.5 rounded-xl font-bold text-sm">저장</button>
</div>
</div>
</div>
<script src="/legacy/static/organization.js?v=20260402-02"></script>
</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 한다.

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 한다.

View File

@@ -23,6 +23,8 @@ const projectFrame = document.getElementById("project-frame");
const projectStage = document.getElementById("project-stage"); const projectStage = document.getElementById("project-stage");
const teamFrame = document.getElementById("team-frame"); const teamFrame = document.getElementById("team-frame");
const teamStage = document.getElementById("team-stage"); 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 seatMapAdminStage = document.getElementById("seatmap-admin-stage");
const seatMapReadonlyStage = document.getElementById("seatmap-readonly-stage"); const seatMapReadonlyStage = document.getElementById("seatmap-readonly-stage");
const emptyStage = document.getElementById("empty-stage"); const emptyStage = document.getElementById("empty-stage");
@@ -48,6 +50,7 @@ const seatMapDom = {
formGap: null, formGap: null,
formImage: document.getElementById("seatmap-admin-form-image"), formImage: document.getElementById("seatmap-admin-form-image"),
search: document.getElementById("seatmap-admin-search"), search: document.getElementById("seatmap-admin-search"),
context: document.getElementById("seatmap-admin-context"),
unassigned: document.getElementById("seatmap-admin-unassigned"), unassigned: document.getElementById("seatmap-admin-unassigned"),
officeTabs: document.getElementById("seatmap-admin-office-tabs"), officeTabs: document.getElementById("seatmap-admin-office-tabs"),
sidebarTitle: document.getElementById("seatmap-admin-sidebar-title"), sidebarTitle: document.getElementById("seatmap-admin-sidebar-title"),
@@ -73,6 +76,7 @@ const seatMapDom = {
formGap: null, formGap: null,
formImage: null, formImage: null,
search: document.getElementById("seatmap-readonly-search"), search: document.getElementById("seatmap-readonly-search"),
context: document.getElementById("seatmap-readonly-context"),
unassigned: document.getElementById("seatmap-readonly-unassigned"), unassigned: document.getElementById("seatmap-readonly-unassigned"),
officeTabs: document.getElementById("seatmap-readonly-office-tabs"), officeTabs: document.getElementById("seatmap-readonly-office-tabs"),
sidebarTitle: document.getElementById("seatmap-readonly-sidebar-title"), sidebarTitle: document.getElementById("seatmap-readonly-sidebar-title"),
@@ -98,6 +102,7 @@ let seatMapFormCols = seatMapDom.admin.formCols;
let seatMapFormGap = seatMapDom.admin.formGap; let seatMapFormGap = seatMapDom.admin.formGap;
let seatMapFormImage = seatMapDom.admin.formImage; let seatMapFormImage = seatMapDom.admin.formImage;
let seatMapSearch = seatMapDom.admin.search; let seatMapSearch = seatMapDom.admin.search;
let seatMapContext = seatMapDom.admin.context;
let seatMapUnassigned = seatMapDom.admin.unassigned; let seatMapUnassigned = seatMapDom.admin.unassigned;
let seatMapOfficeTabs = seatMapDom.admin.officeTabs; let seatMapOfficeTabs = seatMapDom.admin.officeTabs;
let seatMapSidebarTitle = seatMapDom.admin.sidebarTitle; let seatMapSidebarTitle = seatMapDom.admin.sidebarTitle;
@@ -115,6 +120,7 @@ const viewLabels = {
project: "프로젝트별 분석", project: "프로젝트별 분석",
team: "팀/개인별 분석", team: "팀/개인별 분석",
organization: "조직 현황", organization: "조직 현황",
"db-status": "DB 상태",
"seatmap-admin": "자리배치도", "seatmap-admin": "자리배치도",
"seatmap-readonly": "자리배치도", "seatmap-readonly": "자리배치도",
}; };
@@ -132,6 +138,7 @@ const seatMapState = {
search: "", search: "",
status: "", status: "",
statusTone: "info", statusTone: "info",
selectedMemberId: null,
draggingMemberId: null, draggingMemberId: null,
zoom: 1, zoom: 1,
panning: false, panning: false,
@@ -488,6 +495,7 @@ function syncSeatMapDomRefs() {
seatMapFormGap = dom.formGap; seatMapFormGap = dom.formGap;
seatMapFormImage = dom.formImage; seatMapFormImage = dom.formImage;
seatMapSearch = dom.search; seatMapSearch = dom.search;
seatMapContext = dom.context;
seatMapUnassigned = dom.unassigned; seatMapUnassigned = dom.unassigned;
seatMapOfficeTabs = dom.officeTabs; seatMapOfficeTabs = dom.officeTabs;
seatMapSidebarTitle = dom.sidebarTitle; seatMapSidebarTitle = dom.sidebarTitle;
@@ -834,6 +842,98 @@ function getMemberMap() {
return new Map(seatMapState.members.map((member) => [Number(member.id), member])); 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) { function getPlacementForMember(memberId) {
return getPlacementSource().find((item) => Number(item.member_id) === Number(memberId)) || null; return getPlacementSource().find((item) => Number(item.member_id) === Number(memberId)) || null;
} }
@@ -868,9 +968,13 @@ function getSidebarMembers() {
return members.filter(memberMatchesSeatMapSearch); return members.filter(memberMatchesSeatMapSearch);
} }
function focusSeatMapMember(memberId) { function focusSeatMapMember(memberId, options = {}) {
const showContext = options.showContext !== false;
const placement = getPlacementForMember(memberId); const placement = getPlacementForMember(memberId);
const member = getMemberMap().get(Number(memberId)); const member = getMemberMap().get(Number(memberId));
if (showContext) {
renderSeatMapMemberContext(memberId);
}
if (!placement) { if (!placement) {
setSeatMapStatus("해당 인원은 아직 배치되지 않았습니다.", "info"); setSeatMapStatus("해당 인원은 아직 배치되지 않았습니다.", "info");
return; return;
@@ -940,6 +1044,46 @@ function getSlotPlacementMap() {
return slotMap; 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) { function upsertDraftPlacement(memberId, rowIndex, colIndex) {
const cellMap = getCellPlacementMap(); const cellMap = getCellPlacementMap();
const existing = cellMap.get(`${rowIndex}:${colIndex}`); 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"><img src="${photoUrl}" alt="${escapeHtml(member.name)}"></span>`
: `<span class="seatmap-member-avatar seatmap-member-avatar-fallback">${escapeHtml(getInitials(member.name))}</span>`; : `<span class="seatmap-member-avatar seatmap-member-avatar-fallback">${escapeHtml(getInitials(member.name))}</span>`;
return ` 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} ${avatar}
<span class="seatmap-member-text"> <span class="seatmap-member-text">
<strong>${escapeHtml(member.name || "-")}</strong> <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> </span>
</div> </div>
`; `;
@@ -1012,11 +1157,12 @@ function renderMemberCard(member, draggable) {
function renderUnassignedMemberCard(member, draggable) { function renderUnassignedMemberCard(member, draggable) {
return ` 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"> <span class="seatmap-member-text seatmap-member-text-inline">
<strong>${escapeHtml(member.name || "-")}</strong> <strong>${escapeHtml(member.name || "-")}</strong>
<em>${escapeHtml(member.rank || "-")}</em> <em>${escapeHtml(member.rank || "-")}</em>
</span> </span>
${renderSeatMapTeamChip(member)}
</div> </div>
`; `;
} }
@@ -1024,14 +1170,13 @@ function renderUnassignedMemberCard(member, draggable) {
function renderSeatMapSearchCard(member) { function renderSeatMapSearchCard(member) {
const placement = getPlacementForMember(Number(member.id)); const placement = getPlacementForMember(Number(member.id));
if (!placement) return ""; if (!placement) return "";
const badge = `<span class="seatmap-member-badge occupied">${escapeHtml(placement.seat_label || "배치완료")}</span>`;
return ` return `
<button class="seatmap-member-search-card" type="button" data-member-id="${Number(member.id)}"> <button class="seatmap-member-search-card" type="button" data-member-id="${Number(member.id)}">
<span class="seatmap-member-text seatmap-member-text-inline"> <span class="seatmap-member-text seatmap-member-text-inline">
<strong>${escapeHtml(member.name || "-")}</strong> <strong>${escapeHtml(member.name || "-")}</strong>
<em>${escapeHtml(member.rank || member.department || "-")}</em> <em>${escapeHtml(member.rank || "-")}</em>
</span> </span>
${badge} ${renderSeatMapTeamChip(member)}
</button> </button>
`; `;
} }
@@ -1051,14 +1196,16 @@ function renderSeatMapBoard() {
const gap = Number(seatMapState.seatMap.cell_gap || 0); const gap = Number(seatMapState.seatMap.cell_gap || 0);
const editable = seatMapState.editMode && isAdmin(); const editable = seatMapState.editMode && isAdmin();
const cells = []; const cells = [];
const overlays = buildSeatMapGridTeamOverlays(rows, cols, placementMap, memberMap);
for (let rowIndex = 0; rowIndex < rows; rowIndex += 1) { for (let rowIndex = 0; rowIndex < rows; rowIndex += 1) {
for (let colIndex = 0; colIndex < cols; colIndex += 1) { for (let colIndex = 0; colIndex < cols; colIndex += 1) {
const key = `${rowIndex}:${colIndex}`; const key = `${rowIndex}:${colIndex}`;
const placement = placementMap.get(key); const placement = placementMap.get(key);
const member = placement ? memberMap.get(Number(placement.member_id)) : null; const member = placement ? memberMap.get(Number(placement.member_id)) : null;
const teamStyle = member ? ` style="${escapeHtml(getSeatMapTeamStyle(member))}"` : "";
cells.push(` 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> <span class="seatmap-cell-label">${escapeHtml(computeSeatLabel(rowIndex, colIndex))}</span>
${member ? renderMemberCard(member, editable) : ""} ${member ? renderMemberCard(member, editable) : ""}
</div> </div>
@@ -1069,7 +1216,7 @@ function renderSeatMapBoard() {
seatMapBoard.innerHTML = ` seatMapBoard.innerHTML = `
<div class="seatmap-canvas" style="--seatmap-rows:${rows}; --seatmap-cols:${cols}; --seatmap-gap:${gap}px;"> <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)}"> <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> </div>
`; `;
} }
@@ -1172,6 +1319,7 @@ function renderSeatMapActions() {
function updateSeatMapDraftUi() { function updateSeatMapDraftUi() {
renderSeatMapActions(); renderSeatMapActions();
renderUnassignedMembers(); renderUnassignedMembers();
renderSeatMapMemberContext(seatMapState.selectedMemberId);
syncSeatMapViewerFrame(); syncSeatMapViewerFrame();
} }
@@ -1391,6 +1539,9 @@ function handleEmbeddedNavigationMessage(event) {
updateSeatMapDraftUi(); updateSeatMapDraftUi();
} }
} }
if (data.type === "seatmap-member-selected") {
renderSeatMapMemberContext(Number(data.memberId || 0) || null);
}
} }
async function fetchJson(url, options) { async function fetchJson(url, options) {
@@ -1434,6 +1585,7 @@ async function loadSeatMapData(force = false) {
seatMapState.placements = clonePlacements(layoutPayload.placements || []); seatMapState.placements = clonePlacements(layoutPayload.placements || []);
seatMapState.zoom = 1; seatMapState.zoom = 1;
seatMapState.hoveredSlotId = null; seatMapState.hoveredSlotId = null;
seatMapState.selectedMemberId = null;
seatMapState.editMode = canEditSeatMap(); seatMapState.editMode = canEditSeatMap();
resetSeatMapDraft(); resetSeatMapDraft();
seatMapState.loaded = true; seatMapState.loaded = true;
@@ -1447,6 +1599,7 @@ async function loadSeatMapData(force = false) {
seatMapState.placements = []; seatMapState.placements = [];
seatMapState.zoom = 1; seatMapState.zoom = 1;
seatMapState.hoveredSlotId = null; seatMapState.hoveredSlotId = null;
seatMapState.selectedMemberId = null;
seatMapState.editMode = canEditSeatMap(); seatMapState.editMode = canEditSeatMap();
resetSeatMapDraft(); resetSeatMapDraft();
seatMapState.loaded = true; seatMapState.loaded = true;
@@ -1623,6 +1776,7 @@ function setActiveView(view) {
const isLedger = currentView === "ledger"; const isLedger = currentView === "ledger";
const isProject = currentView === "project"; const isProject = currentView === "project";
const isTeam = currentView === "team"; const isTeam = currentView === "team";
const isDbStatus = currentView === "db-status";
const isSeatMapAdmin = currentView === "seatmap-admin"; const isSeatMapAdmin = currentView === "seatmap-admin";
const isSeatMapReadonly = currentView === "seatmap-readonly"; const isSeatMapReadonly = currentView === "seatmap-readonly";
if (ledgerStage) { if (ledgerStage) {
@@ -1641,6 +1795,10 @@ function setActiveView(view) {
teamStage.hidden = !isTeam; teamStage.hidden = !isTeam;
teamStage.style.display = isTeam ? "flex" : "none"; teamStage.style.display = isTeam ? "flex" : "none";
} }
if (dbStatusStage) {
dbStatusStage.hidden = !isDbStatus;
dbStatusStage.style.display = isDbStatus ? "flex" : "none";
}
if (seatMapAdminStage) { if (seatMapAdminStage) {
seatMapAdminStage.hidden = !isSeatMapAdmin; seatMapAdminStage.hidden = !isSeatMapAdmin;
seatMapAdminStage.style.display = isSeatMapAdmin ? "flex" : "none"; seatMapAdminStage.style.display = isSeatMapAdmin ? "flex" : "none";
@@ -1650,7 +1808,7 @@ function setActiveView(view) {
seatMapReadonlyStage.style.display = isSeatMapReadonly ? "flex" : "none"; seatMapReadonlyStage.style.display = isSeatMapReadonly ? "flex" : "none";
} }
if (emptyStage) { if (emptyStage) {
const showEmpty = !isLedger && !isOrganization && !isProject && !isTeam && !isSeatMapAdmin && !isSeatMapReadonly; const showEmpty = !isLedger && !isOrganization && !isProject && !isTeam && !isDbStatus && !isSeatMapAdmin && !isSeatMapReadonly;
emptyStage.hidden = !showEmpty; emptyStage.hidden = !showEmpty;
emptyStage.style.display = showEmpty ? "flex" : "none"; emptyStage.style.display = showEmpty ? "flex" : "none";
} }
@@ -1677,8 +1835,12 @@ function setActiveView(view) {
} else if (isTeam) { } else if (isTeam) {
postGlobalDateRangeToFrame(teamFrame); postGlobalDateRangeToFrame(teamFrame);
} }
if (isDbStatus && previousView !== "db-status" && dbStatusFrame) {
const frameSrc = dbStatusFrame.dataset.src || dbStatusFrame.src;
dbStatusFrame.src = resolveAppUrl(frameSrc);
}
if (isSeatMapAdmin || isSeatMapReadonly) { if (isSeatMapAdmin || isSeatMapReadonly) {
loadSeatMapData(); loadSeatMapData(previousView !== currentView);
} }
notifyEmbeddedTabActivated(); notifyEmbeddedTabActivated();
} }
@@ -1874,14 +2036,15 @@ Object.values(seatMapDom).forEach((dom) => {
}); });
dom.unassigned?.addEventListener("click", (event) => { dom.unassigned?.addEventListener("click", (event) => {
const button = event.target.closest("[data-member-id]"); const target = event.target.closest("[data-member-id]");
if (!button) return; if (!target) return;
if (button.classList.contains("seatmap-member-search-card")) { if (target.classList.contains("seatmap-member-search-card")) {
focusSeatMapMember(Number(button.dataset.memberId)); focusSeatMapMember(Number(target.dataset.memberId), { showContext: false });
return; return;
} }
renderSeatMapMemberContext(Number(target.dataset.memberId));
if (canEditSeatMap()) return; if (canEditSeatMap()) return;
focusSeatMapMember(Number(button.dataset.memberId)); focusSeatMapMember(Number(target.dataset.memberId));
}); });
dom.unassigned?.addEventListener("dragover", (event) => { dom.unassigned?.addEventListener("dragover", (event) => {
if (!seatMapState.editMode) return; if (!seatMapState.editMode) return;
@@ -1910,6 +2073,17 @@ Object.values(seatMapDom).forEach((dom) => {
if (!fitButton) return; if (!fitButton) return;
fitDxfSeatMapBoard(); 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) => { dom.board?.addEventListener("dragover", (event) => {
if (!seatMapState.editMode) return; if (!seatMapState.editMode) return;
const target = isSlotBasedSeatMap() 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="/design-patterns.css?v=20260401-01">
<link rel="stylesheet" href="/legacy/static/common.css"> <link rel="stylesheet" href="/legacy/static/common.css">
<!-- Keep login and common hub defaults aligned with 8080. --> <!-- 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. --> <!-- 8081-only hub overrides must not restyle the login screen. -->
<link rel="stylesheet" href="/styles-8081-design.css?v=20260401-01"> <link rel="stylesheet" href="/styles-8081-design.css?v=20260401-01">
</head> </head>
@@ -79,6 +79,7 @@
<button class="nav-pill" type="button" data-view="project">프로젝트별 분석</button> <button class="nav-pill" type="button" data-view="project">프로젝트별 분석</button>
<button class="nav-pill" type="button" data-view="team">팀/개인별 분석</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 active" type="button" data-view="organization">조직 현황</button>
<button class="nav-pill" type="button" data-view="db-status">DB 상태</button>
</div> </div>
<div class="header-actions"> <div class="header-actions">
@@ -104,7 +105,7 @@
<section id="organization-stage" class="main-stage"> <section id="organization-stage" class="main-stage">
<div class="stage-frame"> <div class="stage-frame">
<!-- Legacy organization keeps its own CSS/JS responsibility under /legacy/static. --> <!-- 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> </div>
</section> </section>
<section id="project-stage" class="main-stage" hidden> <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> <iframe id="team-frame" src="/integrations/mh" data-src="/integrations/mh" title="팀/개인별 분석 화면"></iframe>
</div> </div>
</section> </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> <section id="seatmap-admin-stage" class="main-stage" hidden>
<div class="seatmap-layout"> <div class="seatmap-layout">
<div class="seatmap-topbar"> <div class="seatmap-topbar">
@@ -175,6 +181,7 @@
<span class="hidden">구성원 검색</span> <span class="hidden">구성원 검색</span>
<input id="seatmap-admin-search" type="search" placeholder="이름 또는 부서 검색"> <input id="seatmap-admin-search" type="search" placeholder="이름 또는 부서 검색">
</label> </label>
<div id="seatmap-admin-context" class="seatmap-context-panel hidden"></div>
<div id="seatmap-admin-unassigned" class="seatmap-member-list"></div> <div id="seatmap-admin-unassigned" class="seatmap-member-list"></div>
</section> </section>
</aside> </aside>
@@ -214,6 +221,7 @@
<span class="hidden">구성원 검색</span> <span class="hidden">구성원 검색</span>
<input id="seatmap-readonly-search" type="search" placeholder="이름 또는 부서 검색"> <input id="seatmap-readonly-search" type="search" placeholder="이름 또는 부서 검색">
</label> </label>
<div id="seatmap-readonly-context" class="seatmap-context-panel hidden"></div>
<div id="seatmap-readonly-unassigned" class="seatmap-member-list"></div> <div id="seatmap-readonly-unassigned" class="seatmap-member-list"></div>
</section> </section>
</aside> </aside>

View File

@@ -344,6 +344,12 @@ body {
padding-right: 8px; padding-right: 8px;
} }
.header-date-field select option {
background: var(--color-surface);
color: var(--color-text);
font-weight: 700;
}
.header-date-sep { .header-date-sep {
color: var(--color-text-muted); color: var(--color-text-muted);
font-size: 12px; font-size: 12px;
@@ -913,6 +919,39 @@ body {
padding: var(--seatmap-gap); 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 { .seatmap-cell {
position: relative; position: relative;
min-width: 0; min-width: 0;
@@ -920,6 +959,7 @@ body {
border: 1px dashed rgba(15, 23, 42, 0.14); border: 1px dashed rgba(15, 23, 42, 0.14);
background: rgba(255, 255, 255, 0.12); background: rgba(255, 255, 255, 0.12);
transition: border-color 0.18s ease, background 0.18s ease; transition: border-color 0.18s ease, background 0.18s ease;
z-index: 1;
} }
.seatmap-cell.editable:hover { .seatmap-cell.editable:hover {
@@ -931,6 +971,13 @@ body {
background: rgba(255, 255, 255, 0.18); 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 { .seatmap-cell-label {
position: absolute; position: absolute;
top: 4px; top: 4px;
@@ -959,6 +1006,12 @@ body {
overflow: hidden; 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 { .seatmap-member-card.draggable {
cursor: grab; cursor: grab;
} }
@@ -1008,11 +1061,26 @@ body {
} }
.seatmap-member-text em { .seatmap-member-text em {
color: rgba(226, 232, 240, 0.84); color: rgba(255, 247, 237, 0.96);
font-size: 10px; font-size: 10px;
font-weight: 800;
font-style: normal; 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 { .seatmap-sidebar {
height: 100%; height: 100%;
min-height: 0; min-height: 0;
@@ -1195,6 +1263,11 @@ body {
text-align: left; 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 { .seatmap-member-badge {
flex: 0 0 auto; flex: 0 0 auto;
display: inline-flex; display: inline-flex;
@@ -1219,8 +1292,8 @@ body {
min-height: 42px; min-height: 42px;
padding: 10px 12px; padding: 10px 12px;
border-radius: 12px; border-radius: 12px;
background: #3f4658; background: transparent;
border: 1px solid rgba(148, 163, 184, 0.14); border: 1px solid rgba(194, 170, 134, 0.34);
box-shadow: none; box-shadow: none;
} }
@@ -1232,12 +1305,14 @@ body {
.seatmap-member-text-inline strong { .seatmap-member-text-inline strong {
font-size: 13px; font-size: 13px;
color: #10251d;
} }
.seatmap-member-text-inline em { .seatmap-member-text-inline em {
display: inline; display: inline;
color: rgba(226, 232, 240, 0.8); color: #6f5b3e;
font-size: 11px; font-size: 11px;
font-weight: 800;
} }
.seatmap-slot .seatmap-member-card { .seatmap-slot .seatmap-member-card {
@@ -1266,6 +1341,81 @@ body {
display: none; 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 { .seatmap-dxf-stage {
cursor: grab; cursor: grab;
} }

View File

@@ -8,11 +8,14 @@
- 현재 사용 파일: - 현재 사용 파일:
- `served/payment.html` - `served/payment.html`
- `served/mh.html` - `served/mh.html`
- `served/ledger/index.html`
주의: 주의:
- backend `/integrations/payment`, `/integrations/mh`는 위 `served/*`만 읽는다. - 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 ## Reference
@@ -26,9 +29,6 @@
- 샘플 스타일 파일 - 샘플 스타일 파일
- 원본/백업 HTML - 원본/백업 HTML
- 디자인 비교용 파일 - 디자인 비교용 파일
- `reference/ledger/MH 통합 대시보드_260320.html`
## Temporary Comparison Copies - `reference/ledger/MH 통합 대시보드_260320.css`
- `reference/ledger/사업관리대장-1.xlsx`
- 현재 루트의 `payment.html`, `mh.html`은 당장 삭제하지 않는다.
- 이 두 파일은 기존 recovery 작업본과 현재 `served/*`를 비교하거나 되돌릴 때만 본다.
- 다음 차수에서 안전성이 확보되면 `reference/` 하위로 재배치 여부를 검토한다.

View File

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

View File

@@ -0,0 +1,22 @@
# reference/ledger
이 디렉터리는 `사업관리대장` 원본 참고 자산만 둔다.
원칙:
- 직접 서빙하지 않는다.
- 비교, 복구, 기준 확인이 필요할 때만 본다.
- 실제 수정 원본은 `frontend/apps/ledger/*`다.
- 실제 runtime 응답은 `incoming-files/served/ledger/*`다.
현재 포함:
- 원본 HTML/CSS
- 원본 XLSX
- 과거 override 참고 파일
- 단일 canonical reference set만 유지
주의:
- `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

@@ -6,9 +6,18 @@
- `payment.html` - `payment.html`
- `mh.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/payment` 는 이 디렉터리의 `payment.html`을 읽는다.
- `/integrations/mh` 는 이 디렉터리의 `mh.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](../../../frontend/apps/ledger)
- 반영 스크립트: [scripts/publish_ledger_app.sh](../../../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,661 @@
(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 bgNormalizeText(value) {
return String(value || "").replace(/\s+/g, " ").trim();
}
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 normalizedCategory(row) {
var category = bgNormalizeText(row && row.cat);
if (category.indexOf("가족사") >= 0) return "가족사";
var corp = bgNormalizeText(row && row.corp);
if (corp && corp !== "바론") return "가족사";
return "바론";
}
function isSupportServiceRow(row) {
return bgNormalizeText(row && row.name).indexOf("경영 및 기술지원 서비스") >= 0;
}
function projectTypeLabel(row) {
if (isSupportServiceRow(row)) return "기술지원서비스";
return normalizedCategory(row);
}
function projectTypeRank(row) {
var label = projectTypeLabel(row);
if (label === "바론") return 0;
if (label === "가족사") return 1;
return 2;
}
function normalizeStatusLabel(status) {
var value = bgNormalizeText(status);
if (!value) return "-";
if (value === "완료") return "준공";
if (value === "진행") return "과업진행중";
if (value === "대기") return "계약대기";
if (value === "중지") return "과업중지";
return value;
}
function rowStatusLabel(row) {
return normalizeStatusLabel(row && row.status);
}
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 rowStatusLabel(row) === "과업진행중";
}
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 rowStatusLabel(row) === "준공" && 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 isBaronProjectRow(row) {
return projectTypeLabel(row) === "바론";
}
function isSoftwareProjectRow(row) {
var name = bgNormalizeText(row && row.name).toLowerCase();
if (!name) return false;
return [
"프로그램",
"소프트웨어",
"software",
" sw",
"sw ",
"erp",
"tova",
"ipipe",
"eg-bim",
"cad"
].some(function (keyword) {
return name.indexOf(keyword) >= 0;
});
}
function shouldSinkProjectName(row) {
var name = bgNormalizeText(row && row.name);
return name.indexOf("프로그램") >= 0 || name.indexOf("사용") >= 0;
}
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 = activeRows.filter(isSupportServiceRow);
var baronActiveRows = activeRows.filter(isBaronProjectRow);
return {
targetYear: targetYear,
activeRows: activeRows,
newProjectRows: newProjectRows,
completedRows: completedRows,
managementRows: managementRows,
managementTotals: bgTotals(managementRows),
baronActiveRows: baronActiveRows,
baronProjectTotals: bgTotals(baronActiveRows),
baronSoftwareCount: baronActiveRows.filter(isSoftwareProjectRow).length
};
}
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 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 startYear = Number(projectYear(row) || 0);
if (!startYear) return 9998;
return startYear;
}
function tableGroupLabel(row) {
var startYear = projectYear(row);
if (/^20\d{2}$/.test(startYear)) return startYear + " " + projectTypeLabel(row);
return "미지정 " + projectTypeLabel(row);
}
function compareDashboardRows(a, b) {
var typeRankDiff = projectTypeRank(a) - projectTypeRank(b);
if (typeRankDiff !== 0) return typeRankDiff;
var groupDiff = groupSortRank(a) - groupSortRank(b);
if (groupDiff !== 0) return groupDiff;
var sinkDiff = Number(shouldSinkProjectName(a)) - Number(shouldSinkProjectName(b));
if (sinkDiff !== 0) return sinkDiff;
return bgNormalizeText(a && a.name).localeCompare(bgNormalizeText(b && b.name), "ko");
}
function filterCategoryLabel(row) {
return projectTypeLabel(row);
}
function filterClientLabel(row) {
if (typeof normalizeClientDisplay === "function") {
return normalizeClientDisplay(row && row.client);
}
return bgNormalizeText(row && row.client) || "-";
}
function filterOrderLabel(row) {
return bgNormalizeText(row && row.order) || "-";
}
function receivableFilterLabel(row) {
var amount = Number((row && row.recv) || 0);
if (amount <= 0) return "미수 없음";
if (amount < 10000000) return "1천만 미만";
if (amount < 100000000) return "1천만 이상";
return "1억 이상";
}
function refreshFilterDom() {
E.filterButtons = Object.fromEntries(Array.from(document.querySelectorAll(".th-trigger")).map(function (el) {
return [el.dataset.filter, el];
}));
E.filterMenus = Object.fromEntries(Array.from(document.querySelectorAll(".th-menu")).map(function (el) {
return [el.dataset.filter, el];
}));
}
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(compareDashboardRows);
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 ' + (rowStatusLabel(r) === "준공" ? 'ok' : '') + '">' + esc(rowStatusLabel(r)) + '</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("");
refreshFilterDom();
if (typeof syncColumnFilters === "function") syncColumnFilters(S.all);
}
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 totals = summary.baronProjectTotals;
var totalRate = totals.c > 0 ? (totals.col / totals.c) * 100 : 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: summary.baronActiveRows.length.toLocaleString("ko-KR") + "건 (" + summary.baronSoftwareCount.toLocaleString("ko-KR") + "건)", note: "바론 수행중 프로젝트 / SW" },
{ label: "계약금 (VAT별도)", 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);
});
S.rows.sort(compareDashboardRows);
render();
};
filterDefinitions = function () {
return [
{ key: "cat", map: filterCategoryLabel },
{ key: "code", map: function (r) { return r.code || "-"; } },
{ key: "name", map: function (r) { return r.name || "-"; } },
{ key: "client", map: filterClientLabel },
{ key: "order", map: filterOrderLabel },
{ key: "status", map: rowStatusLabel },
{ key: "amount", map: amountFilterLabel },
{ key: "outsource", map: outsourceFilterLabel },
{ key: "receivable", map: receivableFilterLabel },
{ key: "collected", map: collectedFilterLabel },
{ key: "rate", map: rateFilterLabel }
];
};
updateFilterButtons = function () {
Object.keys(E.filterButtons || {}).forEach(function (key) {
var btn = E.filterButtons[key];
if (!btn) return;
var active = !!S.filters[key];
btn.classList.toggle("active", active);
btn.title = active ? ((btn.dataset.label || "") + ": " + S.filters[key]) : (btn.dataset.label || "");
var mark = btn.querySelector(".th-mark");
if (mark) mark.textContent = active ? "•" : "";
});
};
syncColumnFilters = function (rows) {
filterDefinitions().forEach(function (def) {
var 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();
};
matchesColumnFilters = function (r) {
if (S.filters.cat && filterCategoryLabel(r) !== S.filters.cat) return false;
if (S.filters.code && (r.code || "-") !== S.filters.code) return false;
if (S.filters.name && (r.name || "-") !== S.filters.name) return false;
if (S.filters.client && filterClientLabel(r) !== S.filters.client) return false;
if (S.filters.order && filterOrderLabel(r) !== S.filters.order) return false;
if (S.filters.status && rowStatusLabel(r) !== S.filters.status) return false;
if (S.filters.amount && amountFilterLabel(r) !== S.filters.amount) return false;
if (S.filters.outsource && outsourceFilterLabel(r) !== S.filters.outsource) return false;
if (S.filters.receivable && receivableFilterLabel(r) !== S.filters.receivable) 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;
};
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);
});
}
var panel = document.querySelector(".panel");
if (panel && !panel.dataset.ledgerFilterBound) {
panel.dataset.ledgerFilterBound = "true";
panel.addEventListener("click", function (event) {
var trigger = event.target && event.target.closest ? event.target.closest(".th-trigger") : null;
if (trigger) {
refreshFilterDom();
event.stopPropagation();
toggleFilterMenu(trigger.dataset.filter);
return;
}
var option = event.target && event.target.closest ? event.target.closest("button[data-filter-value]") : null;
var menu = event.target && event.target.closest ? event.target.closest(".th-menu") : null;
if (option && menu) {
event.stopPropagation();
setFilterValue(menu.dataset.filter, option.getAttribute("data-filter-value") || "");
}
});
}
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);
});
})();

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -4,6 +4,7 @@ let selectedDept = '전체';
let editingMembers = []; let editingMembers = [];
let collapsedUnits = new Set(); let collapsedUnits = new Set();
let isListMode = false; let isListMode = false;
let isListDetailModal = false;
let emptyStateMessage = '서버에 조직 데이터가 없습니다. 상단의 업로드 버튼으로 초기 데이터를 넣어주세요.'; let emptyStateMessage = '서버에 조직 데이터가 없습니다. 상단의 업로드 버튼으로 초기 데이터를 넣어주세요.';
let photoPreviewObjectUrl = null; let photoPreviewObjectUrl = null;
let seatMapLayoutCache = null; let seatMapLayoutCache = null;
@@ -485,6 +486,24 @@ function createNodeDOM(node, parentId) {
return nodeItem; 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() { function drawLines() {
const container = document.getElementById('tree-root'); const container = document.getElementById('tree-root');
const svg = document.getElementById('svg-canvas'); const svg = document.getElementById('svg-canvas');
@@ -628,6 +647,11 @@ function render() {
deptBox.id = deptId; deptBox.id = deptId;
deptBox.className = 'dept-box'; deptBox.className = 'dept-box';
deptBox.setAttribute('data-level', '부서'); 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>`; deptBox.innerHTML = `<div class="dept-header ${isAdmin ? 'clickable-title' : ''} ${hasMembers ? 'has-members' : ''}" ${isAdmin ? `onclick="openOrgEditModal('부서', '${jsString(deptName)}')"` : ''}>${deptName} (${totalCount})</div>`;
if (hasMembers) { if (hasMembers) {
@@ -827,6 +851,53 @@ function openAddModal(event) {
openModal(null); 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() { function updateParentList() {
const type = document.getElementById('new-unit-type').value; const type = document.getElementById('new-unit-type').value;
const parentSelect = document.getElementById('new-unit-parent'); const parentSelect = document.getElementById('new-unit-parent');
@@ -1159,7 +1230,8 @@ function openModal(id) {
modal.querySelector('.modal-content').classList.add('wide'); modal.querySelector('.modal-content').classList.add('wide');
fieldsArea.className = 'flex flex-col w-full'; fieldsArea.className = 'flex flex-col w-full';
fieldsArea.style.maxHeight = 'none'; fieldsArea.style.maxHeight = 'none';
fieldsArea.style.overflowY = 'visible'; fieldsArea.style.overflowY = 'auto';
isListDetailModal = isListMode;
const sourceValues = isListMode ? editingMembers : members; const sourceValues = isListMode ? editingMembers : members;
let orgFields = '<div id="modal-sec-org" class="hidden grid grid-cols-2 gap-3 modal-form-grid">'; 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() { function closeModal() {
if (isListMode && isListDetailModal) {
returnToListViewModal();
return;
}
resetPhotoPreviewObjectUrl(); resetPhotoPreviewObjectUrl();
document.getElementById('modal').style.display = 'none'; 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').className = 'grid grid-cols-2 gap-x-8 gap-y-5';
document.getElementById('modal-fields').style.maxHeight = 'none'; document.getElementById('modal-fields').style.maxHeight = 'none';
document.getElementById('modal-fields').style.overflowY = 'visible';
document.querySelector('.modal-content').classList.remove('wide'); document.querySelector('.modal-content').classList.remove('wide');
isListDetailModal = false;
isListMode = false; isListMode = false;
} }
@@ -1337,8 +1415,7 @@ async function saveMember() {
if (!id) { if (!id) {
targetList.push(member); targetList.push(member);
} }
renderListViewTable(); returnToListViewModal();
closeModal();
return; return;
} }
@@ -1367,11 +1444,16 @@ async function deleteMember(id) {
} }
if (isListMode) { if (isListMode) {
const shouldReturnToList = isListDetailModal;
const idx = editingMembers.findIndex((member) => member._id === id); const idx = editingMembers.findIndex((member) => member._id === id);
if (idx !== -1) { if (idx !== -1) {
editingMembers.splice(idx, 1); editingMembers.splice(idx, 1);
} }
if (shouldReturnToList) {
returnToListViewModal();
} else {
renderListViewTable(); renderListViewTable();
}
return; return;
} }
@@ -1396,44 +1478,11 @@ function openListViewModal(event) {
listViewState.compareToDate = defaultDate; listViewState.compareToDate = defaultDate;
listViewState.snapshotMembers = []; listViewState.snapshotMembers = [];
listViewState.compareItems = []; 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; isListMode = true;
isListDetailModal = false;
editingMembers = cloneMembers(members); editingMembers = cloneMembers(members);
fieldsArea.innerHTML = ` renderListViewShell(defaultDate);
<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>
`;
renderListViewModalContent(); renderListViewModalContent();
modal.style.display = 'flex';
} }
async function applyListViewChanges() { async function applyListViewChanges() {
@@ -1697,12 +1746,29 @@ function toggleUnitCollapse(level, name) {
let draggedGroup = null; let draggedGroup = null;
function handleListGroupDragStart(event, level, name) { function handleListGroupDragStart(event, level, name) {
draggedIdx = null;
draggedGroup = { level, name }; draggedGroup = { level, name };
event.dataTransfer.effectAllowed = 'move'; event.dataTransfer.effectAllowed = 'move';
} }
function handleListGroupDrop(event, targetLevel, targetName) { function handleListGroupDrop(event, targetLevel, targetName) {
event.preventDefault(); 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)) { if (!draggedGroup || (draggedGroup.level === targetLevel && draggedGroup.name === targetName)) {
return; return;
} }
@@ -1743,6 +1809,7 @@ function handleListSearch(value) {
let draggedIdx = null; let draggedIdx = null;
function handleListDragStart(event, index) { function handleListDragStart(event, index) {
draggedGroup = null;
draggedIdx = index; draggedIdx = index;
event.dataTransfer.effectAllowed = 'move'; event.dataTransfer.effectAllowed = 'move';
event.target.classList.add('dragging'); event.target.classList.add('dragging');
@@ -1753,7 +1820,11 @@ function handleListDrop(event, targetIdx) {
if (draggedIdx === null || draggedIdx === targetIdx) { if (draggedIdx === null || draggedIdx === targetIdx) {
return; return;
} }
const targetMember = editingMembers[targetIdx] || null;
const moved = editingMembers.splice(draggedIdx, 1)[0]; 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); editingMembers.splice(targetIdx, 0, moved);
draggedIdx = null; draggedIdx = null;
renderListViewTable(); renderListViewTable();
@@ -1804,13 +1875,10 @@ async function handleDrop(event, targetLevel, targetName) {
} }
const nextMembers = cloneMembers(members); const nextMembers = cloneMembers(members);
const moved = nextMembers[memberIndex]; const moved = nextMembers[memberIndex];
for (let index = 0; index <= targetLevelIndex; index += 1) { if (targetLevelIndex === -1) {
moved[levelOrder[index]] = targetMember ? targetMember[levelOrder[index]] : targetName; return;
} }
for (let index = targetLevelIndex + 1; index < levelOrder.length; index += 1) { applyMemberPlacementFromTarget(moved, targetLevel, targetName, targetMember);
moved[levelOrder[index]] = '';
}
rebuildMemberPath(moved);
nextMembers.splice(memberIndex, 1); nextMembers.splice(memberIndex, 1);
nextMembers.push(moved); nextMembers.push(moved);
await syncMembers(nextMembers); await syncMembers(nextMembers);
@@ -1859,10 +1927,7 @@ async function handleDropMember(event, targetId) {
} }
const moved = nextMembers[movingIdx]; const moved = nextMembers[movingIdx];
const target = nextMembers[targetIdx]; const target = nextMembers[targetIdx];
levelOrder.forEach((level) => { applyMemberPlacementFromTarget(moved, levelOrder[levelOrder.length - 1], target[levelOrder[levelOrder.length - 1]] || '', target);
moved[level] = target[level];
});
rebuildMemberPath(moved);
nextMembers.splice(movingIdx, 1); nextMembers.splice(movingIdx, 1);
targetIdx = nextMembers.findIndex((member) => member._id === targetId); targetIdx = nextMembers.findIndex((member) => member._id === targetId);
nextMembers.splice(insertAfter ? targetIdx + 1 : targetIdx, 0, moved); nextMembers.splice(insertAfter ? targetIdx + 1 : targetIdx, 0, moved);

View File

@@ -32,6 +32,13 @@ server {
proxy_set_header X-Forwarded-Proto $scheme; 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 / { location / {
proxy_pass http://frontend:80; proxy_pass http://frontend:80;
proxy_set_header Host $host; proxy_set_header Host $host;

View File

@@ -0,0 +1,69 @@
#!/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
import json
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}")
try:
with urllib.request.urlopen("http://127.0.0.1:8000/api/integration/summary", timeout=8) as response:
payload = json.loads(response.read().decode())
counts = payload.get("counts") or {}
work_logs = int(counts.get("work_logs") or 0)
vouchers = int(counts.get("vouchers") or 0)
if work_logs <= 0:
failed.append(f"analysis-summary: work_logs is {work_logs}")
if vouchers <= 0:
failed.append(f"analysis-summary: vouchers is {vouchers}")
if work_logs > 0 and vouchers > 0:
print(f"[ok] analysis-summary -> work_logs={work_logs}, vouchers={vouchers}")
except Exception as exc:
failed.append(f"analysis-summary: {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/260320.html"
copy_optional_path "incoming-files/sample style.css" 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/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 "[6/6] Dev worktree ready"
echo "Path: ${DEV_DIR}" 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}"

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/organization"
cp "${APP_DIR}/index.html" "${ROOT_DIR}/DashBoard-organization.html"
cp "${APP_DIR}/assets/common.css" "${ROOT_DIR}/legacy/static/common.css"
cp "${APP_DIR}/assets/organization.css" "${ROOT_DIR}/legacy/static/organization.css"
cp "${APP_DIR}/assets/organization.js" "${ROOT_DIR}/legacy/static/organization.js"
echo "Published organization app source to legacy runtime files"

11
scripts/publish_payment_app.sh Executable file
View File

@@ -0,0 +1,11 @@
#!/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"
cp "${APP_DIR}/index.html" "${TARGET_FILE}"
echo "Published payment app source to ${TARGET_FILE}"

11
scripts/publish_team_app.sh Executable file
View File

@@ -0,0 +1,11 @@
#!/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"
cp "${APP_DIR}/index.html" "${TARGET_FILE}"
echo "Published team app source to ${TARGET_FILE}"

View File

@@ -9,6 +9,28 @@ DEV_PROJECT_NAME="${DEV_PROJECT_NAME:-mh-dashboard-organization-dev}"
DEV_COMPOSE_FILE="${DEV_COMPOSE_FILE:-${DEV_DIR}/docker-compose.8081.yml}" DEV_COMPOSE_FILE="${DEV_COMPOSE_FILE:-${DEV_DIR}/docker-compose.8081.yml}"
SCOPE="${1:-minimal}" SCOPE="${1:-minimal}"
ANALYSIS_TABLES=(
integration_import_batches
integration_raw_organization_rows
integration_raw_mh_rows
integration_raw_mh_pm_rows
integration_raw_payment_rows
integration_project_aliases
integration_project_category_mappings
integration_project_pm_assignments
integration_projects
integration_work_logs
integration_work_log_segments
integration_vouchers
)
MINIMAL_PRESERVE_TABLES=(
integration_project_pm_assignments
integration_work_logs
integration_work_log_segments
integration_vouchers
)
if [[ ! -f "${PROD_DIR}/docker-compose.yml" ]]; then if [[ ! -f "${PROD_DIR}/docker-compose.yml" ]]; then
echo "Production workspace not found: ${PROD_DIR}" >&2 echo "Production workspace not found: ${PROD_DIR}" >&2
exit 1 exit 1
@@ -38,35 +60,11 @@ case "${SCOPE}" in
) )
;; ;;
analysis) analysis)
TABLES=( TABLES=("${ANALYSIS_TABLES[@]}")
integration_import_batches
integration_raw_organization_rows
integration_raw_mh_rows
integration_raw_mh_pm_rows
integration_raw_payment_rows
integration_project_aliases
integration_project_category_mappings
integration_project_pm_assignments
integration_projects
integration_work_logs
integration_work_log_segments
integration_vouchers
)
;; ;;
full) full)
TABLES=( TABLES=(
integration_import_batches "${ANALYSIS_TABLES[@]}"
integration_raw_organization_rows
integration_raw_mh_rows
integration_raw_mh_pm_rows
integration_raw_payment_rows
integration_project_aliases
integration_project_category_mappings
integration_project_pm_assignments
integration_projects
integration_work_logs
integration_work_log_segments
integration_vouchers
member_aliases member_aliases
member_overrides member_overrides
member_retirements member_retirements
@@ -81,6 +79,16 @@ case "${SCOPE}" in
;; ;;
esac esac
PRESERVE_TABLES=()
if [[ "${SCOPE}" == "minimal" ]]; then
PRESERVE_TABLES=("${MINIMAL_PRESERVE_TABLES[@]}")
fi
DUMP_TABLES=("${TABLES[@]}")
if [[ ${#PRESERVE_TABLES[@]} -gt 0 ]]; then
DUMP_TABLES+=("${PRESERVE_TABLES[@]}")
fi
PROD_COMPOSE=(docker compose --project-directory "${PROD_DIR}") PROD_COMPOSE=(docker compose --project-directory "${PROD_DIR}")
DEV_COMPOSE=(docker compose -p "${DEV_PROJECT_NAME}" --env-file "${DEV_DIR}/.env" -f "${DEV_COMPOSE_FILE}") DEV_COMPOSE=(docker compose -p "${DEV_PROJECT_NAME}" --env-file "${DEV_DIR}/.env" -f "${DEV_COMPOSE_FILE}")
@@ -129,7 +137,7 @@ echo "[4/8] Building truncate script for ${SCOPE} scope"
echo "[5/8] Dumping ${SCOPE} data from 8080 source DB" echo "[5/8] Dumping ${SCOPE} data from 8080 source DB"
TABLE_ARGS=() TABLE_ARGS=()
for table in "${TABLES[@]}"; do for table in "${DUMP_TABLES[@]}"; do
TABLE_ARGS+=(-t "public.${table}") TABLE_ARGS+=(-t "public.${table}")
done done
run_compose "${PROD_DIR}" "${PROD_COMPOSE[@]}" exec -T db \ run_compose "${PROD_DIR}" "${PROD_COMPOSE[@]}" exec -T db \
@@ -193,7 +201,7 @@ echo "[7.8/8] Resetting serial sequences"
echo "SELECT setval(pg_get_serial_sequence('public.member_retirements', 'id'), COALESCE((SELECT MAX(id) FROM public.member_retirements), 1), true);" echo "SELECT setval(pg_get_serial_sequence('public.member_retirements', 'id'), COALESCE((SELECT MAX(id) FROM public.member_retirements), 1), true);"
echo "SELECT setval(pg_get_serial_sequence('public.seat_maps', 'id'), COALESCE((SELECT MAX(id) FROM public.seat_maps), 1), true);" echo "SELECT setval(pg_get_serial_sequence('public.seat_maps', 'id'), COALESCE((SELECT MAX(id) FROM public.seat_maps), 1), true);"
echo "SELECT setval(pg_get_serial_sequence('public.seat_slots', 'id'), COALESCE((SELECT MAX(id) FROM public.seat_slots), 1), true);" echo "SELECT setval(pg_get_serial_sequence('public.seat_slots', 'id'), COALESCE((SELECT MAX(id) FROM public.seat_slots), 1), true);"
if [[ "${SCOPE}" == "analysis" || "${SCOPE}" == "full" ]]; then if [[ "${SCOPE}" == "analysis" || "${SCOPE}" == "full" || "${#PRESERVE_TABLES[@]}" -gt 0 ]]; then
echo "SELECT setval(pg_get_serial_sequence('public.integration_import_batches', 'id'), COALESCE((SELECT MAX(id) FROM public.integration_import_batches), 1), true);" echo "SELECT setval(pg_get_serial_sequence('public.integration_import_batches', 'id'), COALESCE((SELECT MAX(id) FROM public.integration_import_batches), 1), true);"
echo "SELECT setval(pg_get_serial_sequence('public.integration_raw_organization_rows', 'id'), COALESCE((SELECT MAX(id) FROM public.integration_raw_organization_rows), 1), true);" echo "SELECT setval(pg_get_serial_sequence('public.integration_raw_organization_rows', 'id'), COALESCE((SELECT MAX(id) FROM public.integration_raw_organization_rows), 1), true);"
echo "SELECT setval(pg_get_serial_sequence('public.integration_raw_mh_rows', 'id'), COALESCE((SELECT MAX(id) FROM public.integration_raw_mh_rows), 1), true);" echo "SELECT setval(pg_get_serial_sequence('public.integration_raw_mh_rows', 'id'), COALESCE((SELECT MAX(id) FROM public.integration_raw_mh_rows), 1), true);"
@@ -236,7 +244,7 @@ UNION ALL
SELECT 'auth_users', COUNT(*)::text FROM auth.users SELECT 'auth_users', COUNT(*)::text FROM auth.users
ORDER BY table_name; ORDER BY table_name;
SQL SQL
if [[ "${SCOPE}" == "analysis" || "${SCOPE}" == "full" ]]; then if [[ "${SCOPE}" == "analysis" || "${SCOPE}" == "full" || "${#PRESERVE_TABLES[@]}" -gt 0 ]]; then
cat <<'SQL' cat <<'SQL'
SELECT 'integration_work_logs', COUNT(*)::text FROM public.integration_work_logs SELECT 'integration_work_logs', COUNT(*)::text FROM public.integration_work_logs
UNION ALL UNION ALL