18 Commits

Author SHA1 Message Date
hyunho
4c52fa53e8 Clarify branch and environment rules 2026-04-02 11:29:41 +09:00
hyunho
f5c68ada80 Polish onboarding and reference asset layout 2026-04-02 11:22:52 +09:00
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
hyunho
fb5b0f00c2 feat: unify 8081 dashboard design system and views 2026-04-01 14:02:05 +09:00
hyunho
637b390024 backup: snapshot local design source assets 2026-03-31 17:52:27 +09:00
hyunho
4b4ffafbd2 docs: persist isolated 8081 startup workflow 2026-03-31 17:47:39 +09:00
hyunho
1cd0f21a36 docs: codify isolated 8081 worktree workflow 2026-03-31 17:34:13 +09:00
hyunho
f77be3f482 feat: promote seatmap and organization updates 2026-03-30 16:40:07 +09:00
hyunho
2e8c79bb43 docs: require explicit instruction for commits 2026-03-30 10:28:49 +09:00
102 changed files with 34447 additions and 2502 deletions

1
.gitignore vendored
View File

@@ -16,3 +16,4 @@ incoming-files/~$*
incoming-files/6f.html
incoming-files/7f.html
incoming-files/center.html
.dev-worktree-8081/

59
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,59 @@
# Contributing
## 기본 규칙
- `main`은 팀 기준 브랜치로 사용합니다.
- `dev`는 팀 개발 통합 브랜치로 사용합니다.
- `main`, `dev`는 브랜치이고 `8080`, `8081`은 실행 환경입니다.
- 권장 운영은 `main -> 8080`, `dev 또는 작업 브랜치 -> 8081`입니다.
- 기능 개발과 버그 수정은 각자 작업 브랜치에서 진행합니다.
- 직접 `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. `.env.example``.env`로 복사합니다.
4. 필요한 경우 `./scripts/prepare_dev_worktree.sh`로 격리된 개발 워크스페이스를 준비합니다.
5. `8081`에서 수정과 검증을 진행합니다.
6. 관련 publish 스크립트가 있는 화면은 publish 후 실제 런타임 파일까지 확인합니다.
7. `docs/REGRESSION_CHECKLIST.md` 기준으로 필요한 시나리오를 점검합니다.
8. 커밋 후 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 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=20260327-03" />
<link rel="stylesheet" href="/legacy/static/organization.css?v=20260327-03" />
<link rel="stylesheet" href="/legacy/static/common.css?v=20260402-02" />
<link rel="stylesheet" href="/legacy/static/organization.css?v=20260402-02" />
</head>
<body>
<input type="file" id="upload-excel" class="hidden" accept=".xlsx, .csv" />
@@ -60,6 +60,6 @@
</div>
</div>
<script src="/legacy/static/organization.js?v=20260327-03"></script>
<script src="/legacy/static/organization.js?v=20260402-02"></script>
</body>
</html>

63
README.md Normal file
View File

@@ -0,0 +1,63 @@
# 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`은 검증 환경으로 다룹니다.
- `main`, `dev`는 Git 브랜치이고 `8080`, `8081`은 실행 환경입니다.
- 권장 운영은 `main -> 8080`, `dev 또는 작업 브랜치 -> 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
cp .env.example .env
docker compose up -d --build
```
격리된 `8081` 개발 환경:
```bash
cp .env.example .env
./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()
);
CREATE TABLE IF NOT EXISTS integration_binary_sources (
id BIGSERIAL PRIMARY KEY,
source_key TEXT NOT NULL UNIQUE,
source_name TEXT NOT NULL,
filename TEXT NOT NULL DEFAULT '',
mime_type TEXT NOT NULL DEFAULT 'application/octet-stream',
content BYTEA NOT NULL,
content_sha256 TEXT NOT NULL DEFAULT '',
meta_json JSONB NOT NULL DEFAULT '{}'::jsonb,
imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS history_revisions (
id BIGSERIAL PRIMARY KEY,
scope TEXT NOT NULL DEFAULT 'organization',
@@ -329,18 +342,6 @@ CREATE TABLE IF NOT EXISTS seat_assignment_versions (
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS entity_change_events (
id BIGSERIAL PRIMARY KEY,
entity_type TEXT NOT NULL,
entity_id BIGINT NOT NULL,
action_type TEXT NOT NULL,
revision_no BIGINT NOT NULL,
changed_by_user_id BIGINT,
changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
change_reason TEXT NOT NULL DEFAULT '',
patch_json JSONB NOT NULL DEFAULT '{}'::jsonb
);
CREATE SCHEMA IF NOT EXISTS auth;
CREATE TABLE IF NOT EXISTS auth.users (
@@ -534,6 +535,9 @@ ON integration_vouchers (project_code, project_name);
CREATE UNIQUE INDEX IF NOT EXISTS integration_project_category_mappings_key_idx
ON integration_project_category_mappings (source_key, normalized_project_key);
CREATE UNIQUE INDEX IF NOT EXISTS integration_binary_sources_source_key_idx
ON integration_binary_sources (source_key);
CREATE INDEX IF NOT EXISTS member_versions_member_time_idx
ON member_versions (member_id, valid_from, valid_to);
@@ -543,9 +547,6 @@ ON seat_assignment_versions (member_id, valid_from, valid_to);
CREATE INDEX IF NOT EXISTS history_revisions_scope_created_idx
ON history_revisions (scope, created_at DESC);
CREATE INDEX IF NOT EXISTS entity_change_events_entity_idx
ON entity_change_events (entity_type, entity_id, changed_at DESC);
DO $$
BEGIN
IF NOT EXISTS (
@@ -611,6 +612,9 @@ ALTER TABLE auth.login_audit_logs ADD COLUMN IF NOT EXISTS user_id BIGINT NULL R
ALTER TABLE auth.login_audit_logs ADD COLUMN IF NOT EXISTS failure_reason TEXT;
ALTER TABLE auth.login_audit_logs ADD COLUMN IF NOT EXISTS ip_address INET;
ALTER TABLE auth.login_audit_logs ADD COLUMN IF NOT EXISTS user_agent TEXT;
DROP INDEX IF EXISTS entity_change_events_entity_idx;
DROP TABLE IF EXISTS entity_change_events;
"""

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,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 로그인 제거.
변경 대상:
- [frontend/public/app.js](/home/hyunho/projects/mh-dashboard-organization/frontend/public/app.js)
- [backend/app/main.py](/home/hyunho/projects/mh-dashboard-organization/backend/app/main.py)
- [backend/app/config.py](/home/hyunho/projects/mh-dashboard-organization/backend/app/config.py)
- [frontend/public/app.js](../frontend/public/app.js)
- [backend/app/main.py](../backend/app/main.py)
- [backend/app/config.py](../backend/app/config.py)
### Phase 4

View File

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

View File

@@ -2,7 +2,7 @@
## 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. 인증 기본 구조 추가
@@ -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

View File

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

View File

@@ -5,7 +5,7 @@
- 2026-03-27 기준 `curl http://localhost:8080/api/health` 정상
- 2026-03-27 기준 `curl http://localhost:8080/api/members` 에서 `items` 비어 있지 않음
- 다른 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. 컨테이너 기동
- `docker compose build`

View File

@@ -1,190 +0,0 @@
# Next Session Checkpoint
## Current Base
- branch: `total`
- latest checked commit: `24852d4`
- main history doc: [DEVELOPMENT_HISTORY.md](/home/hyunho/projects/mh-dashboard-organization/docs/DEVELOPMENT_HISTORY.md)
- work rulebook: [WORK_RULEBOOK.md](/home/hyunho/projects/mh-dashboard-organization/docs/WORK_RULEBOOK.md)
- dev/prod protocol: [DEV_PROD_DB_PROTOCOL.md](/home/hyunho/projects/mh-dashboard-organization/docs/DEV_PROD_DB_PROTOCOL.md)
- regression checklist: [REGRESSION_CHECKLIST.md](/home/hyunho/projects/mh-dashboard-organization/docs/REGRESSION_CHECKLIST.md)
- today prep note: [TODAY_WORK_PREP_2026-03-30.md](/home/hyunho/projects/mh-dashboard-organization/docs/TODAY_WORK_PREP_2026-03-30.md)
## Mandatory Start Rule
매일 아침 또는 그날의 첫 작업을 시작할 때는 코드를 수정하기 전에 반드시 아래 순서를 먼저 수행해야 한다.
1. Gitea 브랜치 상태 확인
2. 열린 이슈 확인
3. [WORK_RULEBOOK.md](/home/hyunho/projects/mh-dashboard-organization/docs/WORK_RULEBOOK.md) 확인
4. 이 문서 확인
5. 현재 워크트리의 미푸시 커밋, 변경 파일, 미추적 파일 확인
주의:
- 위 절차를 확인하기 전에는 새 코드 작성이나 기존 코드 수정부터 시작하지 않는다.
## What Was Finished
### Dashboard Integration
- `조직 현황`, `프로젝트별 분석`, `팀/개인별 분석`, `자리배치도`를 하나의 허브에 통합
- `payment.html`, `mh.html`을 현재 프로젝트에 편입
- 공통 헤더, 탭, 로그인 정보, 공통 기간 제어 구성
### Integrated DB
- `organization.xlsx`, `MH.xlsx`, `payment.csv`, `ptj.csv` 기반 통합 DB 구성
- raw/staging/standard 성격의 구조를 PostgreSQL에 반영
- `members`, `seat_maps`, `seat_slots`, `seat_positions`
- `integration_raw_*`, `integration_work_logs`, `integration_work_log_segments`, `integration_vouchers`
- 프로젝트 카테고리 매핑 반영
### Team / Member Analysis
- `omh.html` 원본 기준으로 계산식/카테고리/디자인 복원
- DB raw MH 데이터를 원본 입력 구조처럼 다시 공급하는 방식으로 정리
### Project Analysis
- `opayment.html` 원본 기준으로 화면 복원
- `payment.csv` 분류 우선, `ptj.csv` fallback 적용
- 연장근무는 `연장근무 시간(가공)` 기준으로 반영
### Organization / Seat Map
- 조직도 상세 프로필에 `재석위치` preview 연결
- 관리자/비관리자 자리배치도 화면 분리
- 저장 후 조직도와 비관리자 열람에 반영되도록 seat save 흐름 정리
- seat persistence bug 수정
- 원인: `seat_positions_map_cell_idx`가 slot 기반 도면에도 적용됨
- 조치: `seat_slot_id IS NULL`인 grid map에만 적용되도록 수정
### Member Data Governance
- 이름 alias, 퇴사 제외, 조직 override를 DB 테이블 기반으로 전환
- 사용 테이블:
- `member_aliases`
- `member_retirements`
- `member_overrides`
### Auth Baseline
- 실제 로그인 API 연결 완료
- 프런트 로그인 화면이 `/api/auth/login` 사용
- 세션/로그아웃/세션 조회 API 구성 완료
- 사용 테이블:
- `auth.users`
- `auth.sessions`
- `auth.login_audit_logs`
- 현재 남은 범위:
- mock login 정리
- 역할별 권한 체크 적용
- 쓰기 API 보호 범위 정리
### External Access
- WSL 내부 8080 리슨 확인
- 현재 다른 PC에서 접속 확인
- 현재 기준 주소:
- `http://172.16.40.144:8080`
## Important Runtime Notes
### Dev / Prod Protocol
- 코드 선행은 `8081`, 공개 반영은 `8080`
- 데이터 정본은 `8080` DB
- `8081` DB는 독립 정본이 아니라 `8080` 기준 복제본처럼 관리해야 함
- 조직도, 멤버, 자리배치 검증 전에는 `DEV_PROD_DB_PROTOCOL.md`를 먼저 확인
- 기능 수정 후 완료 판단은 `REGRESSION_CHECKLIST.md`를 기준으로 해야 함
### Seat Map Save
- 저장이 안 되면 먼저 backend 로그에서 `PUT /api/seat-maps/{id}/layout` 상태코드 확인
- 과거 핵심 장애는 DB 인덱스 충돌이었다
- 현재 저장 구조는:
- `seat_positions`
- `members.seat_label`
둘 다 같이 갱신
### External Access
- Windows LAN IP가 바뀌면 접속 주소가 바뀔 수 있음
- WSL IP가 바뀌면 `portproxy connectaddress`를 다시 맞춰야 함
- 다음 확인 명령:
- Windows: `ipconfig`
- WSL: `hostname -I`
- Windows: `netsh interface portproxy show all`
## Open Issues
- `#2` 백엔드 영속 저장 구조 운영 마무리
- `#3` 사무실 좌석 배치도 조회 및 관리자 편집 기능 고도화
- `#5` 실제 인증 체계 전환
- `#7` 자리배치도 팀별 색상 오버레이 표시
- `#8` 자리배치도 좌석 클릭 시 개인 상위 조직 트리 표시
- `#9` 조직도·자리배치도 변경 이력 버전 누적 저장
현재 해석:
- `#6`은 코드 기준 사실상 완료 상태이며 Gitea 정리 대상
- `#5`는 "로그인 구현"보다 "권한 제어 마무리"가 핵심
- `#2`의 기존 "스냅샷 검증" 범위는 현재 코드와 불일치하므로 범위 재정의 필요
## Unfinished Ideas Discussed Today
### Seat Map UX
- 자리배치도 내 인원 등록 시 팀별 색상 표시
- 좌석 클릭 시 본인까지의 상위 조직 트리 표시
- 나머지 사무실 2개 도면 추가
- `한맥빌딩 7층`
- `한맥빌딩 6층`
- 비관리자 열람 화면 품질 추가 점검
### History / Versioning
- 조직도와 자리배치도 수정 이력을 버전 누적형으로 저장
- 원본 DB와 별도의 history/version 구조 설계
- `valid_from`, `valid_to` 기반 시점 조회(as-of date) 구조 적용
- 날짜 또는 revision label 기준으로 버전 묶음 관리
- 상세 설계 문서:
- [HISTORY_ASOF_DB_PLAN.md](/home/hyunho/projects/mh-dashboard-organization/docs/HISTORY_ASOF_DB_PLAN.md)
주의:
- 현재 코드에는 조직도/자리배치도 버전 이력 기능이 아직 없음
- 월간 스냅샷 방향은 범위에서 제외
### Project Analysis Accuracy
- 총합은 거의 맞았지만 일부 프로젝트 단위 소수점/분류 오차는 추가 정밀 보정 필요
- `opayment` 기준으로 특정 프로젝트 차이를 계속 줄여야 함
### Auth / Permission
- mock login을 개발용 fallback 수준으로 제한하거나 제거
- 역할별 접근 제어 정리
- 조직도/자리배치도/분석 화면 권한 경계 재정리
## Recommended Next Work Order
1. `#2` 범위를 현재 코드 기준으로 재정의하고 영속성 운영 검증 완료
2. `#5`에서 권한 체크, mock login 정리, 쓰기 API 보호 적용
3. `8081` DB를 `8080` 정본 기준으로 동기화하는 반복 가능한 절차 마련
4. `#9`를 as-of date 기반 history 구조로 설계 후 `members`, `seat_positions` 부터 이력화
5. 그 다음 `#8`, 나머지 도면 추가, `#7`, 프로젝트 분석 오차 보정 순으로 진행
## Quick Resume Prompt
다음 세션 시작 시 아래 기준으로 이어가면 된다.
- 브랜치 `total`에서 시작
- 최근 커밋 `1d15cf9` 확인
- `docs/DEVELOPMENT_HISTORY.md`
- `docs/NEXT_SESSION_CHECKPOINT.md`
- `docs/DEV_PROD_DB_PROTOCOL.md`
- `docs/REGRESSION_CHECKLIST.md`
- `docs/HISTORY_ASOF_DB_PLAN.md`
- Gitea 이슈 `#2`, `#5`, `#9`
그리고 먼저 현재 외부 접속, 자리배치 저장, 실제 로그인 동작을 확인한 뒤 다음 기능 개발로 넘어간다.

View File

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

172
docs/TEAM_GUIDE.md Normal file
View File

@@ -0,0 +1,172 @@
# 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. 환경 원칙
팀 규칙 문장:
- `main``dev`는 코드 브랜치다.
- `8080``8081`은 실행 환경이다.
- 브랜치 이름을 포트와 같은 뜻으로 쓰지 않는다.
- 기본 운영은 `main``8080` 기준 브랜치로 본다.
- 개발 검증은 `dev` 또는 작업 브랜치를 `8081`에서 먼저 확인한다.
- `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. 수정 후 관련 시나리오를 검증한다.
환경 준비:
- 최초 실행 전 `.env.example``.env`로 복사한다.
- `./scripts/prepare_dev_worktree.sh`는 dev worktree에 `.env`가 없으면 `.env.example`로 기본 파일을 만든다.
핵심 질문:
- 지금 고치는 파일이 실제 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,211 +0,0 @@
# Work Rulebook
## Purpose
이 문서는 이 프로젝트에서 매일 작업을 시작하고 마무리할 때 반드시 따를 운영 규칙을 고정하기 위한 룰북이다.
목표는 아래 4가지다.
- 완료된 기능의 회귀 방지
- 코드 문제와 DB 문제의 혼선 방지
- 작업 기록 누락 방지
- 매일 같은 기준으로 안정적으로 이어서 작업
## Rule 0. Morning Start Mandatory Check
이 규칙은 강제 규칙이다.
매일 아침 또는 그날의 첫 작업을 시작할 때는, 코드를 수정하기 전에 반드시 아래 순서를 먼저 수행한다.
1. Gitea 브랜치 상태 확인
2. 열린 이슈 확인
3. 이 문서 `WORK_RULEBOOK.md` 확인
4. 최신 체크포인트 문서 확인
5. 현재 워크트리의 미푸시 커밋, 변경 파일, 미추적 파일 확인
위 5단계를 확인하기 전에는 새 코드 작성, 기존 코드 수정, 임의 테스트 진행을 시작하지 않는다.
즉:
- "오늘 첫 작업"의 시작점은 코드 수정이 아니라 상태 확인이다.
- 이 절차를 건너뛰고 바로 수정 작업에 들어가는 것은 금지한다.
## 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 이슈 코멘트
- 또는 체크포인트 문서
둘 다 가능하면 둘 다 남긴다.
## 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

@@ -0,0 +1,118 @@
# 8081 Serving Map
## Purpose
이 문서는 `8081` 작업용에서 어떤 URL이 어떤 파일을 실제로 읽는지 고정하기 위한 책임 맵이다.
이번 1차 정리의 목표는 기능 변경이 아니라 `실제 서빙 파일`, `공통 기본 스타일`, `8081 전용 오버라이드`, `참고 원본 자산`의 경계를 분명히 하는 것이다.
## Runtime Entry Points
- 허브 엔트리: `/`
- 파일: `frontend/public/index.html`
- 허브 공통 스크립트:
- 파일: `frontend/public/app.js`
- 허브 공통 기본 스타일:
- 파일: `frontend/public/styles.css`
- 허브 8081 전용 디자인 오버라이드:
- 파일: `frontend/public/styles-8081-design.css`
## Login Rules
- 로그인 화면 기본 구조와 스타일은 `8080` 공통 기준을 따른다.
- 로그인 기본 스타일은 `frontend/public/styles.css`에서만 정의한다.
- `frontend/public/styles-8081-design.css`에는 로그인 관련 셀렉터를 넣지 않는다.
## Legacy Organization
- URL: `/legacy/organization`
- HTML 파일:
- `DashBoard-organization.html`
- 정적 자산:
- `legacy/static/common.css`
- `legacy/static/organization.css`
- `legacy/static/organization.js`
## Integration Screens
- URL: `/integrations/payment`
- 현재 실제 서빙 파일: `incoming-files/served/payment.html`
- 앱 소스 기준: `frontend/apps/payment/index.html`
- publish 규칙: `scripts/publish_payment_app.sh`
- URL: `/integrations/ledger`
- 현재 실제 서빙 파일: `incoming-files/served/ledger/index.html`
- 현재 실제 runtime asset 경로: `incoming-files/served/ledger/*`
- 앱 소스 기준: `frontend/apps/ledger/*`
- publish 규칙: `frontend/apps/ledger/index.html` placeholder를 `scripts/publish_ledger_app.sh`가 runtime asset 경로로 치환
- URL: `/integrations/mh`
- 현재 실제 서빙 파일: `incoming-files/served/mh.html`
- 앱 소스 기준: `frontend/apps/team/index.html`
- publish 규칙: `scripts/publish_team_app.sh`
- URL: `/db-status.html`
- 현재 실제 서빙 파일: `incoming-files/served/db-status/index.html`
- 앱 소스 기준: `frontend/apps/db-status/index.html`
- publish 규칙: `scripts/publish_db_status_app.sh`
정리 원칙:
- `incoming-files` 아래에서는 `served/`를 실제 서빙 자산용으로 사용한다.
- `payment`, `mh`, `ledger`, `db-status`는 사람이 직접 `served/`를 먼저 수정하지 않는다.
- 이 4개 화면의 source-of-truth는 `frontend/apps/*`이고, publish 스크립트가 `served/`를 갱신한다.
- `reference/`는 원본 참고 파일, 복구 참고 파일, 비교용 자산만 둔다.
- 1차 정리에서는 기존 실제 서빙 파일을 `served/`에 복사하고, backend 서빙 경로를 먼저 `served/`로 갱신한다.
- `사업관리대장``#21`부터 wrapper decode 방식 대신 `served/ledger/index.html``served/ledger/*`를 직접 서빙한다.
- `사업관리대장` 수정 원본은 `#21` 다음 단계부터 `frontend/apps/ledger/*`를 먼저 보고, `scripts/publish_ledger_app.sh`로 runtime served 파일에 반영한다.
## Seat Map
- 허브 화면 구성:
- `frontend/public/index.html`
- `frontend/public/app.js`
- `frontend/public/styles.css`
- `frontend/public/styles-8081-design.css`
- API / viewer:
- `backend/app/main.py`
- `backend/app/db.py`
- `backend/app/center_chair_viewer_template.html`
## Incoming Files Classification
### Served
- 실제 URL에서 직접 읽는 파일
- 예:
- `served/payment.html`
- `served/mh.html`
### Reference
- 원본 HTML/CSS/XLSX/CSV
- 복구 비교용 자산
- 직접 서빙하지 않는 참고 파일
- 필요 시 다음 차수에서 `reference/` 하위로 단계적 재배치한다.
예:
- `260320.html`
- `sample style.css`
- `reference/opayment.html`
- `reference/omh.html`
- `reference/ledger/MH 통합 대시보드_260320.html`
- `reference/ledger/MH 통합 대시보드_260320.css`
- 원본 xlsx/csv
## Out Of Scope For Phase 1
- DB 스키마 의미 변경
- 계산식 변경
- 권한 로직 변경
- 신규 기능 추가
- backend 라우터 대분해
## Phase 1 Success Criteria
- 수정 대상 파일을 화면별로 즉시 찾을 수 있다.
- 로그인은 `styles.css`만 본다.
- 허브 8081 디자인은 `styles-8081-design.css`만 본다.
- `/integrations/payment`, `/integrations/mh`의 실제 서빙 파일 위치가 문서와 코드에서 일치한다.
- `/db-status.html`가 현재 DB 저장 구조와 import 상태를 화면에서 바로 보여준다.
- 기존 참고 자산을 지우지 않고도 실제 서빙 경로와 참고 경로를 구분할 수 있다.

View File

@@ -0,0 +1,205 @@
# DB Table Catalog
## Purpose
이 문서는 현재 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

@@ -0,0 +1,129 @@
# Design SSOT
## Source of truth
- Primary visual source: [incoming-files/sample style.css](../../incoming-files/sample%20style.css)
- Runtime token file: [design-tokens.css](../../frontend/public/design-tokens.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.
## Rules
- New UI must use `design-tokens.css` variables first.
- New UI must use `design-patterns.css` patterns before adding page-local variants.
- Direct hex values are exceptions, not defaults.
- Page files may define layout and composition, but color, panel, border, radius, and shadow values must come from tokens.
- Shared aliases in `legacy/static/common.css` and `frontend/public/styles.css` exist only to bridge older code to the SSOT.
- Reference files under `incoming-files/*` are not visual authority. Runtime visuals must follow `design-tokens.css` and `design-patterns.css`.
## Fixed vs Flexible
SSOT is not a pixel-locked screenshot spec. It is a design rule system with two layers.
### Fixed rules
These should be treated as stable defaults across screens.
- Brand color family and accent family
- Surface, border, text, and shadow tokens
- Radius scale
- Button, tab, input, panel, and card visual language
- Typography tone and hierarchy
- Background atmosphere and overall contrast direction
### Flexible rules
These must be interpreted per screen based on content density and interaction needs.
- KPI card width and number of columns
- Sidebar/content split ratios
- Table column widths
- Search/filter placement
- Card stacking and wrap behavior
- Desktop/mobile breakpoint behavior
Example:
- Wrong SSOT: `KPI width is 100px`
- Correct SSOT: `KPI cards use the shared panel, radius, spacing, and text hierarchy tokens, and their width adapts to content without collapsing readability`
## When SSOT does not define a component
If a screen needs a pattern that SSOT does not explicitly define yet, do not fall back to arbitrary legacy styling.
Use this order:
1. Reuse existing tokens and the nearest shared pattern
2. Design the missing component in the same visual grammar
3. If the pattern is likely to repeat, document and promote it into SSOT
This applies to examples such as:
- A table pattern that does not exist in the current SSOT
- A KPI strip that needs a different density than the sample
- A new modal layout for a data-heavy screen
## Candidate and deprecated styles
Not every style already visible in the product is automatically part of SSOT.
- `SSOT`
- Approved and repeatable patterns
- Token-backed visual rules
- `candidate`
- Screen-local styles that look usable but do not yet have a documented basis
- Can be promoted later if they prove reusable
- `deprecated`
- Old blue/slate/indigo defaults
- Temporary hardcoded fixes
- Styles that conflict with the sample-based MH visual language
When a screen has a design with no clear basis, classify it as `candidate` first. Promote it only after it has been checked for reuse and consistency.
## Token groups
- Surface: `--ds-bg`, `--ds-panel`, `--ds-panel-soft`, `--ds-panel-strong`
- Text: `--ds-ink`, `--ds-text-soft`, `--ds-text-muted`
- Brand: `--ds-brand`, `--ds-brand-deep`, `--ds-brand-soft`, `--ds-accent`, `--ds-accent-soft`, `--ds-mint`
- Borders and shadows: `--ds-line`, `--ds-line-soft`, `--ds-shadow-*`
- Layout primitives: `--ds-radius-*`, `--ds-space-*`, `--ds-page-max-width`
## Promoted runtime patterns
These are now the official reusable patterns for current screens.
- Panels and heads: `.ds-panel`, `.ds-panel-head`
- KPI cards: `.ds-kpi-card`, `.ds-kpi-people`, `.ds-kpi-inverse`
- Filter surfaces and toggles: `.ds-filter-surface`, `.ds-filter-toggle`, `.ds-reset-button`
- Tables: `.ds-table-head`, `.ds-table-head-row`, `.ds-table-row`, `.ds-axis-cell`, `.ds-axis-cell-idle`, `.ds-axis-cell-active`
- Value emphasis: `.ds-project-cell`, `.ds-income`, `.ds-expense`, `.ds-subhead`, `.ds-empty`, `.ds-strong`, `.ds-muted`
- Breakdown/detail UI: `.ds-progress-track*`, `.ds-mode-chip`, `.ds-name-chip`, `.ds-mini-table-*`, `.ds-group-title`
- Position chips: `.ds-position-*` via `position-*` compatibility classes
- Business ledger popup/detail blocks: `.popup-*`, `.inline-card`, `.project-head-*`, `.summary-*`, `.ledger-*`, `.badge`, `.project-link`
- Organization modal forms/buttons: `.member-form-*`, `.modal-btn*`
- Seatmap action visibility: `.seatmap-actions .ghost-button`
These patterns may still have compatibility selectors for existing screen classes, but they should now be treated as the official design layer.
## Migration order
1. Token file and common aliases
2. Hub shell and shared controls
3. Team/Personal analysis and Organization
4. Project analysis
5. Business ledger detail cleanup
## Implementation guidance
- Prefer tokenized ranges over hardcoded single values when layout depends on data volume
- Prefer `design-patterns.css` component rules over one-off inline colors
- If a new pattern is introduced during implementation, update this document once the pattern is stable
- If a screen needs an exception, keep the exception local and explain why it cannot follow the shared pattern
## Anti-patterns
- Adding new `#4f46e5`, `#4338ca`, `bg-slate-*`, `text-indigo-*` style defaults
- Reintroducing separate page-level color systems
- Hardcoding “quick fix” brand colors in JS templates when a token/class can carry the same intent
- Letting reference/original files override runtime pattern files

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` 이후 `사업관리대장`을 화면별 앱 구조로 승격하기 위한 첫 단계다.
- 아직 프레임워크 앱은 아니고, 독립 관리되는 정식 화면 소스 디렉터리다.

File diff suppressed because it is too large Load Diff

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);
});
})();

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -15,12 +15,16 @@ const organizationHistoryControls = document.getElementById("organization-histor
const organizationMonthSelect = document.getElementById("organization-month-select");
const organizationCompareBtn = document.getElementById("organization-compare-btn");
const navButtons = Array.from(document.querySelectorAll(".header-center [data-view]"));
const ledgerFrame = document.getElementById("ledger-frame");
const ledgerStage = document.getElementById("ledger-stage");
const organizationFrame = document.getElementById("organization-frame");
const organizationStage = document.getElementById("organization-stage");
const projectFrame = document.getElementById("project-frame");
const projectStage = document.getElementById("project-stage");
const teamFrame = document.getElementById("team-frame");
const teamStage = document.getElementById("team-stage");
const dbStatusFrame = document.getElementById("db-status-frame");
const dbStatusStage = document.getElementById("db-status-stage");
const seatMapAdminStage = document.getElementById("seatmap-admin-stage");
const seatMapReadonlyStage = document.getElementById("seatmap-readonly-stage");
const emptyStage = document.getElementById("empty-stage");
@@ -46,6 +50,7 @@ const seatMapDom = {
formGap: null,
formImage: document.getElementById("seatmap-admin-form-image"),
search: document.getElementById("seatmap-admin-search"),
context: document.getElementById("seatmap-admin-context"),
unassigned: document.getElementById("seatmap-admin-unassigned"),
officeTabs: document.getElementById("seatmap-admin-office-tabs"),
sidebarTitle: document.getElementById("seatmap-admin-sidebar-title"),
@@ -71,6 +76,7 @@ const seatMapDom = {
formGap: null,
formImage: null,
search: document.getElementById("seatmap-readonly-search"),
context: document.getElementById("seatmap-readonly-context"),
unassigned: document.getElementById("seatmap-readonly-unassigned"),
officeTabs: document.getElementById("seatmap-readonly-office-tabs"),
sidebarTitle: document.getElementById("seatmap-readonly-sidebar-title"),
@@ -96,6 +102,7 @@ let seatMapFormCols = seatMapDom.admin.formCols;
let seatMapFormGap = seatMapDom.admin.formGap;
let seatMapFormImage = seatMapDom.admin.formImage;
let seatMapSearch = seatMapDom.admin.search;
let seatMapContext = seatMapDom.admin.context;
let seatMapUnassigned = seatMapDom.admin.unassigned;
let seatMapOfficeTabs = seatMapDom.admin.officeTabs;
let seatMapSidebarTitle = seatMapDom.admin.sidebarTitle;
@@ -113,6 +120,7 @@ const viewLabels = {
project: "프로젝트별 분석",
team: "팀/개인별 분석",
organization: "조직 현황",
"db-status": "DB 상태",
"seatmap-admin": "자리배치도",
"seatmap-readonly": "자리배치도",
};
@@ -130,6 +138,7 @@ const seatMapState = {
search: "",
status: "",
statusTone: "info",
selectedMemberId: null,
draggingMemberId: null,
zoom: 1,
panning: false,
@@ -151,7 +160,7 @@ const seatMapState = {
forceReadOnly: false,
};
let currentView = "project";
let currentView = "ledger";
const globalDateState = {
loaded: false,
startDate: "",
@@ -325,9 +334,7 @@ function buildAuthHeaders(headers) {
function shouldShowGlobalDateControls() {
return currentView === "ledger"
|| currentView === "project"
|| currentView === "team"
|| currentView === "seatmap-admin"
|| currentView === "seatmap-readonly";
|| currentView === "team";
}
function syncGlobalDateControlVisibility() {
@@ -361,7 +368,15 @@ function buildAsOfQuery() {
return `?as_of=${encodeURIComponent(asOf)}`;
}
function buildSeatMapAsOfQuery() {
return "";
}
function notifyEmbeddedTabActivated() {
if (currentView === "ledger" && ledgerFrame?.contentWindow) {
ledgerFrame.contentWindow.postMessage({ source: "total-control", type: "embedded-host" }, window.location.origin);
ledgerFrame.contentWindow.postMessage({ source: "total-control", type: "tab-activated", tab: "business" }, window.location.origin);
}
if (currentView === "project" && projectFrame?.contentWindow) {
projectFrame.contentWindow.postMessage({ source: "total-control", type: "tab-activated", tab: "project" }, window.location.origin);
}
@@ -370,6 +385,49 @@ function notifyEmbeddedTabActivated() {
}
}
let ledgerDefaultSourcePromise = null;
async function fetchDefaultLedgerSource() {
if (!ledgerDefaultSourcePromise) {
ledgerDefaultSourcePromise = fetch("/api/integration/business-ledger-default")
.then(async (response) => {
if (!response.ok) {
throw new Error("기본 사업관리대장 원본을 불러오지 못했습니다.");
}
const fileName = response.headers.get("x-source-filename") || "사업관리대장-1.xlsx";
const buffer = await response.arrayBuffer();
if (!buffer || !buffer.byteLength) {
throw new Error("기본 사업관리대장 원본 데이터가 비어 있습니다.");
}
return { fileName, buffer };
})
.catch((error) => {
ledgerDefaultSourcePromise = null;
throw error;
});
}
return ledgerDefaultSourcePromise;
}
async function pushDefaultLedgerSourceToFrame(force = false) {
if (!ledgerFrame?.contentWindow) return;
if (ledgerFrame.dataset.defaultLedgerLoaded === "true" && !force) return;
try {
const { fileName, buffer } = await fetchDefaultLedgerSource();
ledgerFrame.contentWindow.postMessage(
{ source: "total-control", type: "embedded-host" },
window.location.origin,
);
ledgerFrame.contentWindow.postMessage(
{ source: "total-upload", type: "business", fileName, buffer },
window.location.origin,
);
ledgerFrame.dataset.defaultLedgerLoaded = "true";
} catch (error) {
console.error("사업관리대장 기본 원본 전달에 실패했습니다.", error);
}
}
async function ensureGlobalDateRangeLoaded() {
if (globalDateState.loaded) return;
try {
@@ -437,6 +495,7 @@ function syncSeatMapDomRefs() {
seatMapFormGap = dom.formGap;
seatMapFormImage = dom.formImage;
seatMapSearch = dom.search;
seatMapContext = dom.context;
seatMapUnassigned = dom.unassigned;
seatMapOfficeTabs = dom.officeTabs;
seatMapSidebarTitle = dom.sidebarTitle;
@@ -783,10 +842,118 @@ function getMemberMap() {
return new Map(seatMapState.members.map((member) => [Number(member.id), member]));
}
function buildSeatMapTeamTone(teamName) {
const team = String(teamName || "").trim();
if (!team) {
return {
accent: "rgba(120, 132, 119, 0.86)",
soft: "rgba(120, 132, 119, 0.18)",
label: "미분류",
};
}
const palette = [
{ accent: "rgba(13, 148, 136, 0.9)", soft: "rgba(13, 148, 136, 0.18)" },
{ accent: "rgba(202, 138, 4, 0.9)", soft: "rgba(202, 138, 4, 0.18)" },
{ accent: "rgba(5, 150, 105, 0.9)", soft: "rgba(5, 150, 105, 0.18)" },
{ accent: "rgba(180, 83, 9, 0.9)", soft: "rgba(180, 83, 9, 0.18)" },
{ accent: "rgba(20, 83, 45, 0.9)", soft: "rgba(20, 83, 45, 0.16)" },
{ accent: "rgba(11, 110, 79, 0.9)", soft: "rgba(11, 110, 79, 0.18)" },
];
let hash = 0;
for (const char of team) {
hash = ((hash * 31) + char.charCodeAt(0)) % 2147483647;
}
const picked = palette[Math.abs(hash) % palette.length];
return { ...picked, label: team };
}
function getSeatMapTeamStyle(member) {
const tone = buildSeatMapTeamTone(getSeatMapOrgUnitLabel(member));
return `--seatmap-team-accent:${tone.accent}; --seatmap-team-soft:${tone.soft};`;
}
function renderSeatMapTeamChip(member) {
const label = getSeatMapOrgUnitLabel(member);
const tone = buildSeatMapTeamTone(label);
return `<span class="seatmap-team-chip" style="${escapeHtml(getSeatMapTeamStyle({ team: label }))}">${escapeHtml(label)}</span>`;
}
function getSeatMapOrgPath(member) {
const values = [
member?.department,
member?.grp,
member?.division,
member?.team,
member?.cell,
]
.map((value) => String(value || "").trim())
.filter(Boolean);
return values.filter((value, index) => values.indexOf(value) === index);
}
function getSeatMapOrgUnitLabel(member) {
return String(
member?.team
|| member?.division
|| member?.grp
|| member?.department
|| member?.cell
|| "미분류"
).trim() || "미분류";
}
function getSeatMapOverlayTeamLabel(member) {
return String(member?.team || "").trim();
}
function renderSeatMapMemberContext(memberId) {
if (!seatMapContext) return;
const member = memberId ? getMemberMap().get(Number(memberId)) : null;
seatMapState.selectedMemberId = member ? Number(member.id) : null;
if (!member) {
seatMapContext.classList.add("hidden");
seatMapContext.innerHTML = "";
return;
}
const tone = buildSeatMapTeamTone(member.team);
const orgPath = getSeatMapOrgPath(member);
seatMapContext.classList.remove("hidden");
seatMapContext.innerHTML = `
<div class="seatmap-context-head">
<div class="seatmap-context-title">
<strong>${escapeHtml(member.name || "-")}</strong>
<span>${escapeHtml(member.rank || member.role || "구성원")}</span>
</div>
<span class="seatmap-context-badge" style="${escapeHtml(getSeatMapTeamStyle(member))}">${escapeHtml(tone.label)}</span>
</div>
<div class="seatmap-context-tree">
${orgPath.length
? orgPath.map((item) => `<span class="seatmap-context-node">${escapeHtml(item)}</span>`).join("")
: '<div class="seatmap-context-empty">표시할 상위 조직 정보가 없습니다.</div>'}
</div>
`;
}
function getPlacementForMember(memberId) {
return getPlacementSource().find((item) => Number(item.member_id) === Number(memberId)) || null;
}
function isMemberAssignedAnywhere(member) {
const seatLabel = String(
member?.member_seat_label
|| member?.seat_label
|| ""
).trim();
return Boolean(seatLabel);
}
function shouldHideMemberFromSeatMap(member) {
if (Boolean(member?.is_retired)) return true;
const workStatus = String(member?.work_status || "").trim();
if (/(퇴사|퇴직)/u.test(workStatus)) return true;
return false;
}
function memberMatchesSeatMapSearch(member) {
const keyword = seatMapState.search.trim().toLowerCase();
if (!keyword) return true;
@@ -801,9 +968,13 @@ function getSidebarMembers() {
return members.filter(memberMatchesSeatMapSearch);
}
function focusSeatMapMember(memberId) {
function focusSeatMapMember(memberId, options = {}) {
const showContext = options.showContext !== false;
const placement = getPlacementForMember(memberId);
const member = getMemberMap().get(Number(memberId));
if (showContext) {
renderSeatMapMemberContext(memberId);
}
if (!placement) {
setSeatMapStatus("해당 인원은 아직 배치되지 않았습니다.", "info");
return;
@@ -839,7 +1010,9 @@ function renderSeatMapOfficeTabs() {
function getUnassignedMembers() {
const placedIds = new Set(getPlacementSource().map((item) => Number(item.member_id)));
return seatMapState.members.filter((member) => {
if (shouldHideMemberFromSeatMap(member)) return false;
if (placedIds.has(Number(member.id))) return false;
if (isMemberAssignedAnywhere(member)) return false;
return memberMatchesSeatMapSearch(member);
});
}
@@ -847,6 +1020,7 @@ function getUnassignedMembers() {
function getPlacedMembers() {
const placedIds = new Set(getPlacementSource().map((item) => Number(item.member_id)));
return seatMapState.members.filter((member) => {
if (shouldHideMemberFromSeatMap(member)) return false;
if (!placedIds.has(Number(member.id))) return false;
return memberMatchesSeatMapSearch(member);
});
@@ -870,6 +1044,46 @@ function getSlotPlacementMap() {
return slotMap;
}
function buildSeatMapGridTeamOverlays(rows, cols, placementMap, memberMap) {
const groups = new Map();
placementMap.forEach((placement) => {
const member = memberMap.get(Number(placement.member_id));
if (!member) return;
const label = getSeatMapOverlayTeamLabel(member);
if (!label) return;
const rowIndex = Number(placement.row_index);
const colIndex = Number(placement.col_index);
if (!Number.isFinite(rowIndex) || !Number.isFinite(colIndex)) return;
if (!groups.has(label)) {
groups.set(label, {
label,
toneMember: { team: label },
minRow: rowIndex,
maxRow: rowIndex,
minCol: colIndex,
maxCol: colIndex,
count: 1,
});
return;
}
const group = groups.get(label);
group.minRow = Math.min(group.minRow, rowIndex);
group.maxRow = Math.max(group.maxRow, rowIndex);
group.minCol = Math.min(group.minCol, colIndex);
group.maxCol = Math.max(group.maxCol, colIndex);
group.count += 1;
});
return Array.from(groups.values())
.filter((group) => group.count >= 2 && group.maxRow < rows && group.maxCol < cols)
.map((group) => `
<div
class="seatmap-team-overlay"
data-team-label="${escapeHtml(group.label)}"
style="${escapeHtml(getSeatMapTeamStyle(group.toneMember))}; grid-column:${group.minCol + 1} / ${group.maxCol + 2}; grid-row:${group.minRow + 1} / ${group.maxRow + 2};"
></div>
`);
}
function upsertDraftPlacement(memberId, rowIndex, colIndex) {
const cellMap = getCellPlacementMap();
const existing = cellMap.get(`${rowIndex}:${colIndex}`);
@@ -930,11 +1144,12 @@ function renderMemberCard(member, draggable) {
? `<span class="seatmap-member-avatar"><img src="${photoUrl}" alt="${escapeHtml(member.name)}"></span>`
: `<span class="seatmap-member-avatar seatmap-member-avatar-fallback">${escapeHtml(getInitials(member.name))}</span>`;
return `
<div class="seatmap-member-card${draggable ? " draggable" : ""}" draggable="${draggable}" data-member-id="${Number(member.id)}">
<div class="seatmap-member-card team-colored${draggable ? " draggable" : ""}" style="${escapeHtml(getSeatMapTeamStyle(member))}" draggable="${draggable}" data-member-id="${Number(member.id)}">
${avatar}
<span class="seatmap-member-text">
<strong>${escapeHtml(member.name || "-")}</strong>
<em>${escapeHtml(member.department || member.team || member.rank || "-")}</em>
<em>${escapeHtml(member.rank || "-")}</em>
<span class="seatmap-team-chip" style="${escapeHtml(getSeatMapTeamStyle({ team: getSeatMapOrgUnitLabel(member) }))}">${escapeHtml(getSeatMapOrgUnitLabel(member))}</span>
</span>
</div>
`;
@@ -942,11 +1157,12 @@ function renderMemberCard(member, draggable) {
function renderUnassignedMemberCard(member, draggable) {
return `
<div class="seatmap-member-card seatmap-member-card-compact${draggable ? " draggable" : ""}" draggable="${draggable}" data-member-id="${Number(member.id)}">
<div class="seatmap-member-card seatmap-member-card-compact${draggable ? " draggable" : ""}" style="${escapeHtml(getSeatMapTeamStyle(member))}" draggable="${draggable}" data-member-id="${Number(member.id)}">
<span class="seatmap-member-text seatmap-member-text-inline">
<strong>${escapeHtml(member.name || "-")}</strong>
<em>${escapeHtml(member.rank || "-")}</em>
</span>
${renderSeatMapTeamChip(member)}
</div>
`;
}
@@ -954,14 +1170,13 @@ function renderUnassignedMemberCard(member, draggable) {
function renderSeatMapSearchCard(member) {
const placement = getPlacementForMember(Number(member.id));
if (!placement) return "";
const badge = `<span class="seatmap-member-badge occupied">${escapeHtml(placement.seat_label || "배치완료")}</span>`;
return `
<button class="seatmap-member-search-card" type="button" data-member-id="${Number(member.id)}">
<span class="seatmap-member-text seatmap-member-text-inline">
<strong>${escapeHtml(member.name || "-")}</strong>
<em>${escapeHtml(member.rank || member.department || "-")}</em>
<em>${escapeHtml(member.rank || "-")}</em>
</span>
${badge}
${renderSeatMapTeamChip(member)}
</button>
`;
}
@@ -981,14 +1196,16 @@ function renderSeatMapBoard() {
const gap = Number(seatMapState.seatMap.cell_gap || 0);
const editable = seatMapState.editMode && isAdmin();
const cells = [];
const overlays = buildSeatMapGridTeamOverlays(rows, cols, placementMap, memberMap);
for (let rowIndex = 0; rowIndex < rows; rowIndex += 1) {
for (let colIndex = 0; colIndex < cols; colIndex += 1) {
const key = `${rowIndex}:${colIndex}`;
const placement = placementMap.get(key);
const member = placement ? memberMap.get(Number(placement.member_id)) : null;
const teamStyle = member ? ` style="${escapeHtml(getSeatMapTeamStyle(member))}"` : "";
cells.push(`
<div class="seatmap-cell${placement ? " occupied" : ""}${editable ? " editable" : ""}" data-row="${rowIndex}" data-col="${colIndex}">
<div class="seatmap-cell${placement ? " occupied" : ""}${member ? " team-colored" : ""}${editable ? " editable" : ""}" data-row="${rowIndex}" data-col="${colIndex}"${teamStyle}>
<span class="seatmap-cell-label">${escapeHtml(computeSeatLabel(rowIndex, colIndex))}</span>
${member ? renderMemberCard(member, editable) : ""}
</div>
@@ -999,7 +1216,7 @@ function renderSeatMapBoard() {
seatMapBoard.innerHTML = `
<div class="seatmap-canvas" style="--seatmap-rows:${rows}; --seatmap-cols:${cols}; --seatmap-gap:${gap}px;">
<img class="seatmap-image" src="${escapeHtml(seatMapState.seatMap.image_url)}" alt="${escapeHtml(seatMapState.seatMap.name)}">
<div class="seatmap-grid">${cells.join("")}</div>
<div class="seatmap-grid">${overlays.join("")}${cells.join("")}</div>
</div>
`;
}
@@ -1011,7 +1228,7 @@ function renderDxfSeatMapBoard() {
seatMapBoard.innerHTML = `<div class="seatmap-empty-card"><strong>DXF 뷰어 데이터를 준비하지 못했습니다.</strong></div>`;
return;
}
const viewerUrl = resolveAppUrl(`/api/seat-maps/${seatMapState.seatMap.id}/viewer${buildAsOfQuery()}`);
const viewerUrl = resolveAppUrl(`/api/seat-maps/${seatMapState.seatMap.id}/viewer${buildSeatMapAsOfQuery()}`);
seatMapBoard.innerHTML = `
<div class="seatmap-dxf-frame-shell">
<div class="seatmap-dxf-drop-overlay" data-seatmap-drop-overlay></div>
@@ -1102,6 +1319,7 @@ function renderSeatMapActions() {
function updateSeatMapDraftUi() {
renderSeatMapActions();
renderUnassignedMembers();
renderSeatMapMemberContext(seatMapState.selectedMemberId);
syncSeatMapViewerFrame();
}
@@ -1321,6 +1539,9 @@ function handleEmbeddedNavigationMessage(event) {
updateSeatMapDraftUi();
}
}
if (data.type === "seatmap-member-selected") {
renderSeatMapMemberContext(Number(data.memberId || 0) || null);
}
}
async function fetchJson(url, options) {
@@ -1354,7 +1575,7 @@ async function loadSeatMapData(force = false) {
const office = getCurrentSeatMapOffice();
const activePayload = await fetchJson(`/api/seat-maps/active?office_key=${encodeURIComponent(office.key)}`);
const activeSeatMap = activePayload.item;
const layoutPayload = await fetchJson(`/api/seat-maps/${activeSeatMap.id}/layout${buildAsOfQuery()}`);
const layoutPayload = await fetchJson(`/api/seat-maps/${activeSeatMap.id}/layout${buildSeatMapAsOfQuery()}`);
seatMapState.seatMap = {
...(layoutPayload.seat_map || {}),
viewer_data: layoutPayload.viewer_data || null,
@@ -1364,6 +1585,7 @@ async function loadSeatMapData(force = false) {
seatMapState.placements = clonePlacements(layoutPayload.placements || []);
seatMapState.zoom = 1;
seatMapState.hoveredSlotId = null;
seatMapState.selectedMemberId = null;
seatMapState.editMode = canEditSeatMap();
resetSeatMapDraft();
seatMapState.loaded = true;
@@ -1377,6 +1599,7 @@ async function loadSeatMapData(force = false) {
seatMapState.placements = [];
seatMapState.zoom = 1;
seatMapState.hoveredSlotId = null;
seatMapState.selectedMemberId = null;
seatMapState.editMode = canEditSeatMap();
resetSeatMapDraft();
seatMapState.loaded = true;
@@ -1550,10 +1773,16 @@ function setActiveView(view) {
});
const isOrganization = currentView === "organization";
const isLedger = currentView === "ledger";
const isProject = currentView === "project";
const isTeam = currentView === "team";
const isDbStatus = currentView === "db-status";
const isSeatMapAdmin = currentView === "seatmap-admin";
const isSeatMapReadonly = currentView === "seatmap-readonly";
if (ledgerStage) {
ledgerStage.hidden = !isLedger;
ledgerStage.style.display = isLedger ? "flex" : "none";
}
if (organizationStage) {
organizationStage.hidden = !isOrganization;
organizationStage.style.display = isOrganization ? "flex" : "none";
@@ -1566,6 +1795,10 @@ function setActiveView(view) {
teamStage.hidden = !isTeam;
teamStage.style.display = isTeam ? "flex" : "none";
}
if (dbStatusStage) {
dbStatusStage.hidden = !isDbStatus;
dbStatusStage.style.display = isDbStatus ? "flex" : "none";
}
if (seatMapAdminStage) {
seatMapAdminStage.hidden = !isSeatMapAdmin;
seatMapAdminStage.style.display = isSeatMapAdmin ? "flex" : "none";
@@ -1575,11 +1808,15 @@ function setActiveView(view) {
seatMapReadonlyStage.style.display = isSeatMapReadonly ? "flex" : "none";
}
if (emptyStage) {
const showEmpty = !isOrganization && !isProject && !isTeam && !isSeatMapAdmin && !isSeatMapReadonly;
const showEmpty = !isLedger && !isOrganization && !isProject && !isTeam && !isDbStatus && !isSeatMapAdmin && !isSeatMapReadonly;
emptyStage.hidden = !showEmpty;
emptyStage.style.display = showEmpty ? "flex" : "none";
}
if (isLedger && previousView !== "ledger" && ledgerFrame) {
const frameSrc = ledgerFrame.dataset.src || ledgerFrame.src;
ledgerFrame.src = resolveAppUrl(frameSrc);
}
if (isOrganization && previousView !== "organization" && organizationFrame) {
const frameSrc = organizationFrame.dataset.src || organizationFrame.src;
organizationFrame.src = resolveAppUrl(frameSrc);
@@ -1598,8 +1835,12 @@ function setActiveView(view) {
} else if (isTeam) {
postGlobalDateRangeToFrame(teamFrame);
}
if (isDbStatus && previousView !== "db-status" && dbStatusFrame) {
const frameSrc = dbStatusFrame.dataset.src || dbStatusFrame.src;
dbStatusFrame.src = resolveAppUrl(frameSrc);
}
if (isSeatMapAdmin || isSeatMapReadonly) {
loadSeatMapData();
loadSeatMapData(previousView !== currentView);
}
notifyEmbeddedTabActivated();
}
@@ -1650,7 +1891,7 @@ if (loginForm) {
body: formData,
});
setSession(payload);
setActiveView("project");
setActiveView("ledger");
loginForm.reset();
loginMessage.textContent = "";
renderAuth();
@@ -1707,6 +1948,13 @@ organizationFrame?.addEventListener("load", () => {
postOrganizationHistoryState();
});
ledgerFrame?.addEventListener("load", () => {
if (currentView === "ledger") {
notifyEmbeddedTabActivated();
}
void pushDefaultLedgerSourceToFrame(true);
});
projectFrame?.addEventListener("load", () => {
postGlobalDateRangeToFrame(projectFrame);
if (currentView === "project") {
@@ -1788,14 +2036,15 @@ Object.values(seatMapDom).forEach((dom) => {
});
dom.unassigned?.addEventListener("click", (event) => {
const button = event.target.closest("[data-member-id]");
if (!button) return;
if (button.classList.contains("seatmap-member-search-card")) {
focusSeatMapMember(Number(button.dataset.memberId));
const target = event.target.closest("[data-member-id]");
if (!target) return;
if (target.classList.contains("seatmap-member-search-card")) {
focusSeatMapMember(Number(target.dataset.memberId), { showContext: false });
return;
}
renderSeatMapMemberContext(Number(target.dataset.memberId));
if (canEditSeatMap()) return;
focusSeatMapMember(Number(button.dataset.memberId));
focusSeatMapMember(Number(target.dataset.memberId));
});
dom.unassigned?.addEventListener("dragover", (event) => {
if (!seatMapState.editMode) return;
@@ -1824,6 +2073,17 @@ Object.values(seatMapDom).forEach((dom) => {
if (!fitButton) return;
fitDxfSeatMapBoard();
});
dom.board?.addEventListener("click", (event) => {
const memberCard = event.target.closest(".seatmap-member-card[data-member-id]");
if (memberCard) {
renderSeatMapMemberContext(Number(memberCard.dataset.memberId));
return;
}
const cell = event.target.closest(".seatmap-cell[data-row][data-col]");
if (!cell) return;
const placement = getCellPlacementMap().get(`${Number(cell.dataset.row)}:${Number(cell.dataset.col)}`);
renderSeatMapMemberContext(placement ? Number(placement.member_id) : null);
});
dom.board?.addEventListener("dragover", (event) => {
if (!seatMapState.editMode) return;
const target = isSlotBasedSeatMap()

View File

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

View File

@@ -0,0 +1,730 @@
@import url("/design-tokens.css?v=20260401-01");
:root {
--ds-hero-text: #f7f0e4;
--ds-hero-border: rgba(242, 196, 132, 0.22);
--ds-hero-surface: rgba(255, 255, 255, 0.08);
--ds-hero-surface-strong: rgba(255, 255, 255, 0.1);
--ds-hero-text-muted: rgba(255, 244, 230, 0.72);
--ds-hero-text-soft: rgba(255, 244, 230, 0.56);
--ds-hero-line: rgba(242, 196, 132, 0.18);
--ds-danger-soft: rgba(169, 72, 50, 0.1);
--ds-danger-line: rgba(169, 72, 50, 0.22);
--ds-success-soft: rgba(47, 153, 115, 0.14);
--ds-success-line: rgba(47, 153, 115, 0.24);
--ds-brand-soft-surface: rgba(15, 58, 47, 0.1);
--ds-brand-soft-line: rgba(15, 58, 47, 0.18);
--ds-accent-soft-surface: rgba(242, 196, 132, 0.18);
--ds-accent-soft-line: rgba(214, 138, 58, 0.24);
}
.ds-panel,
.payment-panel {
background: rgba(255, 250, 243, 0.96);
border: 1px solid var(--ds-line-soft);
box-shadow: var(--ds-shadow-soft);
}
.ds-panel-head,
.payment-panel-head {
background: rgba(255, 250, 243, 0.92);
border-bottom: 1px solid var(--ds-line-soft);
}
.ds-kpi-card,
.payment-kpi-card {
border: 1px solid var(--ds-line-soft);
background: linear-gradient(180deg, rgba(255, 250, 243, 0.96) 0%, rgba(248, 242, 232, 0.96) 100%);
box-shadow: var(--ds-shadow-soft);
color: var(--ds-ink);
}
.ds-kpi-inverse,
.payment-kpi-inverse {
color: #fffaf3;
}
.ds-kpi-people,
.payment-kpi-people {
background: linear-gradient(135deg, var(--ds-brand) 0%, var(--ds-brand-soft) 100%);
border-color: rgba(15, 58, 47, 0.2);
}
.ds-subhead,
.payment-subhead {
color: var(--ds-text-muted);
}
.ds-empty,
.payment-empty {
color: #9b937f;
}
.ds-tooltip,
.payment-tooltip {
background: var(--ds-brand-deep);
color: #fffaf3;
}
.ds-filter-surface,
.payment-filter-bar {
background: rgba(246, 237, 221, 0.8);
border: 1px solid var(--ds-line);
}
.ds-filter-toggle,
.payment-filter-toggle {
background: var(--ds-brand);
border-color: rgba(15, 58, 47, 0.28);
color: #fffaf3;
}
.ds-reset-button,
.payment-reset-btn {
background: rgba(255, 250, 243, 0.96);
border: 1px solid var(--ds-line);
color: var(--ds-text-muted);
}
.ds-reset-button:hover,
.payment-reset-btn:hover {
color: var(--ds-brand-soft);
background: rgba(255, 255, 255, 0.98);
}
.ds-table-head,
.payment-table-head {
background: rgba(246, 237, 221, 0.82);
}
.ds-table-head-row,
.payment-table-head-row {
color: var(--ds-brand-deep);
border-bottom: 1px solid var(--ds-line);
}
.ds-table-row,
.payment-data-row {
border-color: #f0e5d2;
}
.ds-table-row:hover,
.payment-data-row:hover {
background: #f6eddd;
}
.ds-axis-cell,
.payment-axis-cell {
border-right: 1px solid var(--ds-line-soft);
}
.ds-axis-cell-idle,
.payment-axis-cell-idle {
background: rgba(255, 250, 243, 0.96);
color: var(--ds-ink);
}
.ds-axis-cell-idle:hover,
.payment-axis-cell-idle:hover {
background: rgba(234, 220, 196, 0.52);
color: var(--ds-brand-deep);
}
.ds-axis-cell-active,
.payment-axis-cell-active {
background: rgba(234, 220, 196, 0.78);
color: var(--ds-brand-deep);
}
.ds-project-cell,
.payment-project-cell {
color: var(--ds-brand-deep);
font-weight: 800;
}
.ds-project-cell:hover,
.payment-project-cell:hover {
background: #efe2ca;
color: #214634;
}
.ds-income,
.payment-income {
color: var(--ds-status-success);
}
.ds-expense,
.payment-expense {
color: var(--ds-status-danger);
}
.ds-progress-track,
.payment-progress-track {
background: rgba(217, 197, 168, 0.45);
}
.ds-progress-track-grand,
.payment-progress-track-grand {
background: rgba(75, 135, 179, 0.24);
}
.ds-progress-track-mid,
.payment-progress-track-mid {
background: rgba(214, 138, 58, 0.22);
}
.ds-mode-chip,
.payment-mode-chip {
color: var(--ds-brand-soft);
background: rgba(242, 196, 132, 0.22);
border: 1px solid rgba(214, 138, 58, 0.28);
}
.ds-name-chip,
.payment-name-chip {
background: rgba(246, 237, 221, 0.76);
border: 1px solid var(--ds-line-soft);
color: var(--ds-text-soft);
}
.ds-divider-top,
.payment-divider-top {
border-top: 1px solid var(--ds-line-soft);
}
.ds-divider-left,
.payment-divider-left {
border-left: 1px solid var(--ds-line-soft);
}
.ds-divider-mark,
.payment-divider-mark {
color: rgba(183, 170, 147, 0.92);
}
.ds-mini-table-shell,
.payment-mini-table-shell {
border: 1px solid var(--ds-line-soft);
}
.ds-mini-table-head,
.payment-mini-table-head {
background: rgba(246, 237, 221, 0.68);
color: var(--ds-text-muted);
}
.ds-mini-table-row,
.payment-mini-table-row {
border-top: 1px solid rgba(217, 197, 168, 0.36);
color: var(--ds-text-soft);
}
.ds-group-title,
.payment-group-title {
background: var(--ds-brand);
color: #fffaf3;
}
.ds-strong,
.payment-strong {
color: var(--ds-ink);
}
.ds-muted,
.payment-muted {
color: var(--ds-text-soft);
}
.ds-accent-text,
.payment-icon-accent {
color: var(--ds-brand-soft);
}
.ds-position-chip,
.position-chip {
background: rgba(246, 237, 221, 0.76);
}
.ds-position-text,
.position-text {
color: var(--ds-text-soft);
}
.ds-position-border,
.position-border {
border-color: rgba(217, 197, 168, 0.46);
}
.ds-position-dot,
.position-dot {
box-shadow: 0 0 0 2px rgba(255, 250, 243, 0.9);
}
.position-executive.position-chip { background: rgba(15, 58, 47, 0.1); }
.position-executive.position-text { color: var(--ds-brand); }
.position-executive.position-border { border-color: rgba(15, 58, 47, 0.22); }
.position-executive.position-dot { background: var(--ds-brand); }
.position-principal.position-chip { background: rgba(26, 86, 69, 0.1); }
.position-principal.position-text { color: var(--ds-brand-soft); }
.position-principal.position-border { border-color: rgba(26, 86, 69, 0.22); }
.position-principal.position-dot { background: var(--ds-brand-soft); }
.position-senior.position-chip { background: rgba(47, 153, 115, 0.12); }
.position-senior.position-text { color: var(--ds-mint); }
.position-senior.position-border { border-color: rgba(47, 153, 115, 0.26); }
.position-senior.position-dot { background: var(--ds-mint); }
.position-associate.position-chip { background: rgba(75, 135, 179, 0.12); }
.position-associate.position-text { color: var(--ds-info); }
.position-associate.position-border { border-color: rgba(75, 135, 179, 0.22); }
.position-associate.position-dot { background: var(--ds-info); }
.position-staff.position-chip { background: rgba(214, 138, 58, 0.12); }
.position-staff.position-text { color: var(--ds-status-warning); }
.position-staff.position-border { border-color: rgba(214, 138, 58, 0.24); }
.position-staff.position-dot { background: var(--ds-status-warning); }
.position-member.position-chip { background: rgba(102, 117, 109, 0.12); }
.position-member.position-text { color: var(--ds-text-soft); }
.position-member.position-border { border-color: rgba(102, 117, 109, 0.24); }
.position-member.position-dot { background: var(--ds-text-soft); }
.position-unset.position-chip { background: rgba(183, 170, 147, 0.18); }
.position-unset.position-text { color: #8b7e69; }
.position-unset.position-border { border-color: rgba(183, 170, 147, 0.3); }
.position-unset.position-dot { background: #b7aa93; }
.popup-wrap {
max-width: 1680px;
margin: 0 auto;
padding: 20px;
}
.popup-head {
margin-bottom: 14px;
padding: 18px 20px;
border: 1px solid rgba(217, 197, 168, 0.62);
border-radius: 24px;
background: linear-gradient(180deg, #fff8ee 0%, #f4e9d7 100%);
box-shadow: 0 18px 36px rgba(15, 58, 47, 0.08);
}
.popup-title {
font-size: 28px;
font-weight: 900;
line-height: 1.2;
color: var(--ds-ink);
}
.popup-sub {
margin-top: 6px;
font-size: 13px;
font-weight: 800;
color: var(--ds-text-muted);
}
.inline-panel {
padding: 0;
display: grid;
gap: 14px;
}
.project-head-grid {
display: grid;
grid-template-columns: minmax(0, 1.95fr) minmax(280px, 0.72fr);
gap: 10px;
align-items: start;
}
.project-head-main {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
}
.project-contact-stack {
display: grid;
grid-template-columns: 1fr;
gap: 8px;
width: 100%;
}
.inline-card,
.ledger-block,
.popup-wrap .ledger-block.collect {
background: rgba(255, 250, 243, 0.98) !important;
border: 1px solid rgba(217, 197, 168, 0.56) !important;
border-radius: 24px !important;
box-shadow: 0 16px 32px rgba(15, 58, 47, 0.08) !important;
}
.inline-card {
padding: 16px 18px;
}
.project-meta-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px 12px;
}
.kv {
padding: 12px 14px;
border-radius: 18px;
background: linear-gradient(180deg, #fffdf8 0%, #f4e9d7 100%);
border: 1px solid rgba(217, 197, 168, 0.46);
}
.kvk,
.summary-label {
font-size: 11px;
font-weight: 900;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #8a6b3d;
}
.kvv {
font-size: 15px;
font-weight: 900;
color: var(--ds-ink);
line-height: 1.35;
word-break: keep-all;
}
.summary-note {
margin-top: 6px;
font-size: 12px;
font-weight: 800;
color: var(--ds-text-muted);
line-height: 1.45;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.summary-card {
padding: 14px 16px;
border-radius: 18px;
background: linear-gradient(180deg, #fffdf8 0%, #f4e9d7 100%);
border: 1px solid rgba(217, 197, 168, 0.46);
box-shadow: none;
}
.summary-card.receivable {
background: linear-gradient(180deg, var(--ds-danger-soft) 0%, rgba(255, 248, 238, 0.98) 100%);
border-color: var(--ds-danger-line);
}
.summary-value {
margin-top: 8px;
font-size: 24px;
font-weight: 900;
color: var(--ds-ink);
line-height: 1.15;
}
.summary-card.receivable .summary-value {
color: var(--ds-status-danger);
}
.project-progress {
margin-top: 10px;
height: 12px;
border-radius: var(--ds-radius-pill);
overflow: hidden;
background: rgba(217, 197, 168, 0.48);
box-shadow: inset 0 1px 2px rgba(15, 58, 47, 0.08);
}
.progress .bar {
height: 100%;
border-radius: var(--ds-radius-pill);
background: linear-gradient(90deg, var(--ds-brand-soft) 0%, var(--ds-mint) 100%);
box-shadow: 0 8px 18px rgba(47, 153, 115, 0.18);
}
.ledger-stack {
display: grid;
gap: 12px;
}
.ledger-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 18px 18px 14px;
border-bottom: 1px solid rgba(217, 197, 168, 0.38) !important;
background: linear-gradient(180deg, #fffdf8 0%, #f4e9d7 100%) !important;
}
.ledger-head-left {
display: flex;
align-items: flex-start;
gap: 12px;
min-width: 0;
}
.ledger-icon {
width: 36px;
height: 36px;
border-radius: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
background: linear-gradient(180deg, #fff8ee 0%, #f2dec0 100%) !important;
color: var(--ds-accent-strong) !important;
font-weight: 900;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65);
}
.ledger-name {
font-size: 16px;
font-weight: 900;
color: var(--ds-ink) !important;
line-height: 1.2;
}
.ledger-sub {
margin-top: 4px;
font-size: 12px;
font-weight: 800;
color: var(--ds-text-muted) !important;
line-height: 1.45;
}
.ledger-pill {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 8px 12px;
border-radius: var(--ds-radius-pill);
background: var(--ds-brand-soft-surface) !important;
border: 1px solid var(--ds-brand-soft-line) !important;
color: var(--ds-brand-soft) !important;
font-size: 12px;
font-weight: 900;
white-space: nowrap;
}
.ledger-table-wrap {
padding: 0 16px 16px;
background: transparent !important;
}
.ledger-table {
width: 100%;
border-collapse: collapse;
background: transparent !important;
}
.ledger-table thead th {
padding: 12px 10px;
background: var(--ds-brand) !important;
color: #fff5e6 !important;
font-size: 12px;
font-weight: 900;
text-align: left;
border-right: 1px solid rgba(242, 196, 132, 0.18) !important;
}
.ledger-table thead th:last-child {
border-right: 0;
}
.ledger-table tbody td {
padding: 12px 10px;
border-bottom: 1px solid rgba(217, 197, 168, 0.34) !important;
vertical-align: top;
font-size: 13px;
font-weight: 800;
color: var(--ds-ink) !important;
background: rgba(255, 250, 243, 0.72) !important;
}
.ledger-table tbody tr:last-child td {
border-bottom: 0;
}
.ledger-main {
display: block;
color: var(--ds-ink) !important;
font-weight: 900;
}
.ledger-muted,
.ledger-note {
display: block;
margin-top: 4px;
color: var(--ds-text-muted) !important;
font-size: 12px;
font-weight: 800;
line-height: 1.45;
}
.ledger-amount {
font-weight: 900;
text-align: right;
}
.badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 30px;
padding: 0 12px;
border-radius: var(--ds-radius-pill);
border: 1px solid rgba(217, 197, 168, 0.5);
background: rgba(255, 250, 243, 0.96);
color: #17392f;
font-size: 12px;
font-weight: 900;
}
.badge.badge-baron {
background: var(--ds-brand-soft-surface);
border-color: var(--ds-brand-soft-line);
color: var(--ds-brand-soft);
}
.badge.badge-family {
background: var(--ds-accent-soft-surface);
border-color: var(--ds-accent-soft-line);
color: var(--ds-status-warning);
}
.badge.ok {
background: var(--ds-success-soft);
border-color: var(--ds-success-line);
color: var(--ds-brand-soft);
}
.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;
}
.project-link:hover {
color: #0f6a55;
}
.member-form-label {
color: var(--ds-text-soft);
font-size: 12px;
font-weight: 900;
letter-spacing: 0.04em;
}
.member-form-input,
.member-form-select,
.member-form-time {
border: 1px solid var(--ds-line-soft);
border-radius: 16px;
background: rgba(255, 250, 243, 0.92);
color: var(--ds-ink);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65);
}
.member-form-input:focus,
.member-form-select:focus,
.member-form-time:focus {
border-color: rgba(47, 153, 115, 0.45);
box-shadow: 0 0 0 4px rgba(47, 153, 115, 0.1);
}
.modal-btn {
min-height: 40px;
padding: 0 16px;
border-radius: var(--ds-radius-pill);
font-size: 12px;
font-weight: 900;
border: 1px solid transparent;
}
.modal-btn-cancel {
background: rgba(255, 250, 243, 0.96);
border-color: var(--ds-line);
color: var(--ds-text-soft);
}
.modal-btn-save {
background: var(--ds-brand-soft);
border-color: rgba(15, 58, 47, 0.22);
color: #fffaf3;
}
.modal-btn-delete {
background: rgba(169, 72, 50, 0.12);
border-color: rgba(169, 72, 50, 0.24);
color: var(--ds-status-danger);
}
.modal-btn-close {
background: rgba(242, 196, 132, 0.18);
border-color: rgba(214, 138, 58, 0.24);
color: var(--ds-status-warning);
}
.seatmap-actions .ghost-button {
min-height: 40px;
padding: 0 16px;
border-width: 1px;
border-style: solid;
border-radius: var(--ds-radius-pill);
font-size: 12px;
letter-spacing: -0.01em;
box-shadow: var(--ds-shadow-soft);
}
@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;
}
}

View File

@@ -0,0 +1,60 @@
:root {
--ds-font-sans: "Pretendard", "Malgun Gothic", sans-serif;
--ds-bg: #f1eadf;
--ds-bg-soft: #f4e9d7;
--ds-bg-gradient:
radial-gradient(circle at top left, rgba(214, 138, 58, 0.18), transparent 24%),
radial-gradient(circle at top right, rgba(47, 153, 115, 0.12), transparent 22%),
linear-gradient(180deg, #f6efe6 0%, #f1eadf 100%);
--ds-panel: #fffaf3;
--ds-panel-soft: rgba(255, 250, 243, 0.9);
--ds-panel-strong: #eadcc4;
--ds-ink: #10251d;
--ds-text-soft: #425148;
--ds-text-muted: #66756d;
--ds-line: #d9c5a8;
--ds-line-soft: rgba(217, 197, 168, 0.45);
--ds-brand: #0f3a2f;
--ds-brand-deep: #0a2a22;
--ds-brand-soft: #1a5645;
--ds-accent: #d68a3a;
--ds-accent-soft: #f2c484;
--ds-accent-strong: #b66e22;
--ds-mint: #2f9973;
--ds-info: #4b87b3;
--ds-status-success: #2f6b52;
--ds-status-warning: #9a6422;
--ds-status-danger: #a94832;
--ds-surface-tint: rgba(255, 255, 255, 0.72);
--ds-surface-tint-strong: rgba(255, 255, 255, 0.88);
--ds-glass-dark: rgba(20, 45, 37, 0.34);
--ds-glass-dark-soft: rgba(16, 37, 29, 0.16);
--ds-glass-line: rgba(255, 255, 255, 0.14);
--ds-shadow-soft: 0 10px 24px rgba(15, 58, 47, 0.08);
--ds-shadow-card: 0 22px 54px rgba(15, 58, 47, 0.12);
--ds-shadow-float: 0 18px 36px rgba(15, 58, 47, 0.16);
--ds-shadow-hero: 0 28px 70px rgba(15, 58, 47, 0.22);
--ds-radius-sm: 8px;
--ds-radius-md: 12px;
--ds-radius-lg: 18px;
--ds-radius-xl: 24px;
--ds-radius-pill: 999px;
--ds-space-1: 4px;
--ds-space-2: 8px;
--ds-space-3: 12px;
--ds-space-4: 16px;
--ds-space-5: 20px;
--ds-space-6: 24px;
--ds-page-max-width: 2000px;
}

View File

@@ -12,8 +12,13 @@
<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">
<link rel="stylesheet" href="/design-tokens.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="/styles.css?v=20260330-01">
<!-- Keep login and common hub defaults aligned with 8080. -->
<link rel="stylesheet" href="/styles.css?v=20260402-01">
<!-- 8081-only hub overrides must not restyle the login screen. -->
<link rel="stylesheet" href="/styles-8081-design.css?v=20260401-01">
</head>
<body>
<section id="login-panel" class="login-screen">
@@ -74,6 +79,7 @@
<button class="nav-pill" type="button" data-view="project">프로젝트별 분석</button>
<button class="nav-pill" type="button" data-view="team">팀/개인별 분석</button>
<button class="nav-pill active" type="button" data-view="organization">조직 현황</button>
<button class="nav-pill" type="button" data-view="db-status">DB 상태</button>
</div>
<div class="header-actions">
@@ -91,21 +97,34 @@
</header>
<main class="dashboard-main">
<section id="ledger-stage" class="main-stage" hidden>
<div class="stage-frame">
<iframe id="ledger-frame" src="/integrations/ledger?v=20260401-02" data-src="/integrations/ledger?v=20260401-02" title="사업관리대장 화면"></iframe>
</div>
</section>
<section id="organization-stage" class="main-stage">
<div class="stage-frame">
<iframe id="organization-frame" src="/legacy/organization?v=20260330-01" data-src="/legacy/organization?v=20260330-01" title="조직도 메인 화면"></iframe>
<!-- Legacy organization keeps its own CSS/JS responsibility under /legacy/static. -->
<iframe id="organization-frame" src="/legacy/organization?v=20260402-02" data-src="/legacy/organization?v=20260402-02" title="조직도 메인 화면"></iframe>
</div>
</section>
<section id="project-stage" class="main-stage" hidden>
<div class="stage-frame">
<!-- Integration HTML is served from incoming-files/served/payment.html. -->
<iframe id="project-frame" src="/integrations/payment" data-src="/integrations/payment" title="프로젝트별 분석 화면"></iframe>
</div>
</section>
<section id="team-stage" class="main-stage" hidden>
<div class="stage-frame">
<!-- Integration HTML is served from incoming-files/served/mh.html. -->
<iframe id="team-frame" src="/integrations/mh" data-src="/integrations/mh" title="팀/개인별 분석 화면"></iframe>
</div>
</section>
<section id="db-status-stage" class="main-stage" hidden>
<div class="stage-frame">
<iframe id="db-status-frame" src="/db-status.html?v=20260401-02" data-src="/db-status.html?v=20260401-02" title="DB 상태 화면"></iframe>
</div>
</section>
<section id="seatmap-admin-stage" class="main-stage" hidden>
<div class="seatmap-layout">
<div class="seatmap-topbar">
@@ -162,6 +181,7 @@
<span class="hidden">구성원 검색</span>
<input id="seatmap-admin-search" type="search" placeholder="이름 또는 부서 검색">
</label>
<div id="seatmap-admin-context" class="seatmap-context-panel hidden"></div>
<div id="seatmap-admin-unassigned" class="seatmap-member-list"></div>
</section>
</aside>
@@ -201,6 +221,7 @@
<span class="hidden">구성원 검색</span>
<input id="seatmap-readonly-search" type="search" placeholder="이름 또는 부서 검색">
</label>
<div id="seatmap-readonly-context" class="seatmap-context-panel hidden"></div>
<div id="seatmap-readonly-unassigned" class="seatmap-member-list"></div>
</section>
</aside>
@@ -213,6 +234,6 @@
</main>
</section>
<script src="/app.js?v=20260330-01"></script>
<script src="/app.js?v=20260401-02"></script>
</body>
</html>

View File

@@ -0,0 +1,100 @@
.dashboard-header {
min-height: 68px;
background:
radial-gradient(circle at 12% 18%, rgba(242, 196, 132, 0.16), transparent 24%),
linear-gradient(145deg, rgba(10, 42, 34, 0.96) 0%, rgba(15, 58, 47, 0.96) 52%, rgba(26, 86, 69, 0.96) 100%);
color: #f7f0e4;
border-bottom: 1px solid rgba(242, 196, 132, 0.22);
backdrop-filter: blur(16px);
box-shadow: var(--ds-shadow-float);
}
.dashboard-header .eyebrow {
color: rgba(242, 196, 132, 0.94);
}
.dashboard-header h2 {
color: #fff7ea;
}
.nav-pill {
min-height: 42px;
padding: 0 14px;
border-radius: 999px;
border: 1px solid rgba(242, 196, 132, 0.28);
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 244, 230, 0.78);
font-size: 14px;
font-weight: 800;
}
.nav-pill.active {
background: linear-gradient(180deg, rgba(255, 253, 248, 0.98), rgba(245, 235, 221, 0.94));
border-color: rgba(242, 196, 132, 0.34);
color: var(--ds-ink);
box-shadow: var(--ds-shadow-float);
}
.nav-pill.muted {
color: rgba(255, 244, 230, 0.48);
}
.nav-pill:hover {
color: #fff7ea;
border-color: rgba(242, 196, 132, 0.48);
}
.header-actions {
border-left: 1px solid rgba(242, 196, 132, 0.2);
}
.header-date-label {
color: rgba(255, 244, 230, 0.72);
}
.header-date-field {
border: 1px solid rgba(242, 196, 132, 0.22);
background: rgba(255, 255, 255, 0.1);
}
.header-date-field input,
.header-date-field select {
color: #fff7ea;
}
.header-date-sep {
color: rgba(255, 244, 230, 0.56);
}
.ghost-button {
border: 1px solid rgba(242, 196, 132, 0.22);
background: rgba(255, 255, 255, 0.08);
color: #fff7ea;
}
.icon-button {
background: rgba(255, 255, 255, 0.1);
}
.icon-button:hover {
background: rgba(242, 196, 132, 0.14);
border-color: rgba(242, 196, 132, 0.32);
color: #fff7ea;
}
.ghost-button-soft {
background: rgba(239, 228, 208, 0.92);
}
.seatmap-status[data-tone="error"] {
color: var(--ds-status-danger);
}
.seatmap-status[data-tone="success"] {
color: var(--ds-status-success);
}
.seatmap-board-wrap,
.seatmap-dxf-canvas {
background: var(--ds-panel);
}

View File

@@ -1,3 +1,30 @@
:root {
--color-bg: var(--ds-bg);
--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-brand: var(--ds-brand);
--color-brand-deep: var(--ds-brand-deep);
--color-brand-soft: var(--ds-brand-soft);
--color-accent: var(--ds-accent);
--color-accent-soft: var(--ds-accent-soft);
--color-success: var(--ds-status-success);
--color-danger: var(--ds-status-danger);
--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);
}
.dashboard-shell,
.dashboard-main,
.main-stage,
@@ -31,7 +58,7 @@ body {
min-height: 100vh;
padding: 24px;
background:
linear-gradient(135deg, rgba(15, 23, 42, 0.42), rgba(30, 41, 59, 0.18)),
linear-gradient(135deg, rgba(10, 42, 34, 0.42), rgba(26, 86, 69, 0.18)),
url("https://images.unsplash.com/photo-1486406146926-c627a92ad1ab?auto=format&fit=crop&w=1800&q=80")
center center / cover no-repeat;
}
@@ -54,10 +81,10 @@ body {
display: grid;
grid-template-columns: 1.3fr 0.7fr;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.14);
border: 1px solid var(--ds-glass-line);
border-radius: var(--radius-lg);
background: rgba(71, 85, 105, 0.34);
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.24);
background: var(--ds-glass-dark);
box-shadow: var(--ds-shadow-hero);
backdrop-filter: blur(14px);
}
@@ -68,8 +95,8 @@ body {
padding: 30px 30px;
border-right: 1px solid rgba(255, 255, 255, 0.08);
background:
linear-gradient(90deg, rgba(15, 23, 42, 0.08), rgba(255, 255, 255, 0.02)),
linear-gradient(180deg, rgba(255, 255, 255, 0.02), rgba(15, 23, 42, 0.08));
linear-gradient(90deg, rgba(10, 42, 34, 0.08), rgba(255, 255, 255, 0.02)),
linear-gradient(180deg, rgba(255, 255, 255, 0.02), rgba(10, 42, 34, 0.08));
}
.login-brand .eyebrow {
@@ -83,7 +110,7 @@ body {
font-size: clamp(1.7rem, 3.2vw, 2.5rem);
line-height: 0.96;
letter-spacing: -0.04em;
color: #f8fafc;
color: #f7f0e4;
}
.login-form-wrap {
@@ -91,7 +118,7 @@ body {
display: grid;
align-content: center;
gap: 10px;
background: rgba(15, 23, 42, 0.12);
background: var(--ds-glass-dark-soft);
}
.login-card label {
@@ -140,8 +167,8 @@ body {
margin-top: 2px;
border: none;
color: #fff;
background: rgba(31, 41, 55, 0.82);
box-shadow: 0 14px 30px rgba(15, 23, 42, 0.22);
background: rgba(10, 42, 34, 0.82);
box-shadow: var(--shadow-float);
min-height: 34px;
border-radius: 999px;
font-size: 11px;
@@ -167,9 +194,9 @@ body {
.dashboard-header {
min-height: 68px;
background: rgba(255, 255, 255, 0.94);
background: rgba(255, 250, 243, 0.94);
color: var(--color-text);
border-bottom: 1px solid #d7dee8;
border-bottom: 1px solid var(--color-border);
display: flex;
align-items: center;
justify-content: space-between;
@@ -241,7 +268,7 @@ body {
border: none;
border-bottom: 3px solid transparent;
background: transparent;
color: #64748b;
color: var(--color-text-muted);
font-size: 15px;
font-weight: 700;
cursor: pointer;
@@ -255,7 +282,7 @@ body {
}
.nav-pill.muted {
color: #94a3b8;
color: rgba(102, 117, 109, 0.64);
}
.nav-pill:hover {
@@ -269,7 +296,7 @@ body {
gap: 6px;
position: relative;
padding-left: 18px;
border-left: 1px solid #dbe2ea;
border-left: 1px solid var(--color-border);
}
.header-date-controls {
@@ -284,7 +311,7 @@ body {
.header-date-label {
font-size: 12px;
font-weight: 800;
color: #64748b;
color: var(--color-text-muted);
}
.header-date-field {
@@ -292,9 +319,9 @@ body {
align-items: center;
min-height: 36px;
padding: 0 10px;
border: 1px solid #dbe2ea;
border: 1px solid var(--color-border);
border-radius: 999px;
background: #fff;
background: var(--color-surface);
}
.header-date-field input {
@@ -317,16 +344,22 @@ body {
padding-right: 8px;
}
.header-date-field select option {
background: var(--color-surface);
color: var(--color-text);
font-weight: 700;
}
.header-date-sep {
color: #94a3b8;
color: var(--color-text-muted);
font-size: 12px;
font-weight: 800;
}
.ghost-button {
min-height: 34px;
border: 1px solid #dbe2ea;
background: #fff;
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text);
padding: 0 12px;
border-radius: 999px;
@@ -342,12 +375,12 @@ body {
display: inline-flex;
align-items: center;
justify-content: center;
background: #f8fafc;
background: var(--color-surface-soft);
}
.icon-button:hover {
background: #f1f5f9;
border-color: #cbd5e1;
background: var(--ds-bg-soft);
border-color: var(--color-border);
color: var(--color-accent);
transform: translateY(-1px);
}
@@ -363,7 +396,7 @@ body {
}
.ghost-button-soft {
background: #f8fafc;
background: var(--color-surface-soft);
}
.user-chip {
@@ -381,8 +414,8 @@ body {
width: 18px;
height: 18px;
border-radius: 50%;
background: #e2e8f0;
color: #475569;
background: var(--color-surface-strong);
color: var(--color-text-soft);
font-size: 10px;
font-weight: 900;
flex: 0 0 auto;
@@ -421,10 +454,10 @@ body {
right: 0;
min-width: 220px;
padding: 14px;
border: 1px solid #dbe2ea;
border: 1px solid var(--color-border);
border-radius: 16px;
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.14);
background: rgba(255, 250, 243, 0.96);
box-shadow: var(--shadow-float);
backdrop-filter: blur(12px);
z-index: 30;
}
@@ -440,7 +473,7 @@ body {
}
.user-popover-row + .user-popover-row {
border-top: 1px solid #eef2f7;
border-top: 1px solid rgba(217, 197, 168, 0.4);
}
.user-popover-label {
@@ -454,7 +487,7 @@ body {
min-height: 38px;
border: none;
border-radius: 12px;
background: #0f172a;
background: var(--color-brand);
color: #fff;
font-size: 11px;
font-weight: 800;
@@ -485,7 +518,7 @@ body {
width: 100%;
height: 100%;
border: 0;
background: #fff;
background: var(--color-surface);
}
.stage-empty {
@@ -502,9 +535,7 @@ body {
gap: 12px;
padding: 18px;
overflow: hidden;
background:
linear-gradient(180deg, rgba(248, 250, 252, 0.94), rgba(241, 245, 249, 0.92)),
radial-gradient(circle at top left, rgba(14, 165, 233, 0.1), transparent 32%);
background: var(--ds-bg-gradient);
}
.seatmap-topbar {
@@ -561,6 +592,54 @@ body {
display: none !important;
}
.seatmap-actions .ghost-button {
min-height: 40px;
padding: 0 16px;
border-width: 1px;
border-style: solid;
border-radius: var(--radius-pill);
font-size: 12px;
letter-spacing: -0.01em;
box-shadow: var(--shadow-soft);
}
#seatmap-admin-save-btn {
border-color: var(--color-brand-soft);
background: var(--color-brand-soft);
color: #fffaf3;
}
#seatmap-admin-save-btn:hover:not(:disabled) {
background: var(--color-brand);
border-color: var(--color-brand);
transform: translateY(-1px);
box-shadow: var(--shadow-float);
}
#seatmap-admin-save-btn:disabled {
opacity: 1;
cursor: not-allowed;
border-color: rgba(26, 86, 69, 0.24);
background: rgba(26, 86, 69, 0.18);
color: rgba(16, 37, 29, 0.72);
box-shadow: none;
}
#seatmap-admin-exit-btn,
#seatmap-readonly-exit-btn {
border-color: rgba(214, 138, 58, 0.48);
background: rgba(242, 196, 132, 0.22);
color: var(--color-brand-deep);
}
#seatmap-admin-exit-btn:hover,
#seatmap-readonly-exit-btn:hover {
background: rgba(242, 196, 132, 0.34);
border-color: rgba(182, 110, 34, 0.56);
color: var(--color-brand);
transform: translateY(-1px);
}
.seatmap-status {
min-height: 20px;
margin: 0;
@@ -840,6 +919,39 @@ body {
padding: var(--seatmap-gap);
}
.seatmap-team-overlay {
pointer-events: none;
align-self: stretch;
justify-self: stretch;
border-radius: 20px;
border: 2px solid color-mix(in srgb, var(--seatmap-team-accent, rgba(13, 148, 136, 0.3)) 62%, white);
background: linear-gradient(
180deg,
color-mix(in srgb, var(--seatmap-team-soft, rgba(13, 148, 136, 0.12)) 100%, transparent),
color-mix(in srgb, var(--seatmap-team-soft, rgba(13, 148, 136, 0.08)) 90%, transparent)
);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.22), 0 8px 18px rgba(16, 37, 29, 0.08);
margin: 4px;
z-index: 0;
}
.seatmap-team-overlay::before {
content: attr(data-team-label);
position: absolute;
top: 10px;
left: 12px;
display: inline-flex;
align-items: center;
min-height: 26px;
padding: 0 11px;
border-radius: 999px;
background: color-mix(in srgb, var(--seatmap-team-soft, rgba(13, 148, 136, 0.14)) 84%, white);
color: color-mix(in srgb, var(--seatmap-team-accent, #0f766e) 86%, #10251d);
font-size: 11px;
font-weight: 900;
letter-spacing: 0.02em;
}
.seatmap-cell {
position: relative;
min-width: 0;
@@ -847,6 +959,7 @@ body {
border: 1px dashed rgba(15, 23, 42, 0.14);
background: rgba(255, 255, 255, 0.12);
transition: border-color 0.18s ease, background 0.18s ease;
z-index: 1;
}
.seatmap-cell.editable:hover {
@@ -858,6 +971,13 @@ body {
background: rgba(255, 255, 255, 0.18);
}
.seatmap-cell.occupied.team-colored {
border-color: color-mix(in srgb, var(--seatmap-team-accent, rgba(15, 118, 110, 0.72)) 62%, white);
background:
linear-gradient(180deg, color-mix(in srgb, var(--seatmap-team-soft, rgba(15, 118, 110, 0.14)) 86%, transparent), rgba(255, 255, 255, 0.14)),
rgba(255, 255, 255, 0.18);
}
.seatmap-cell-label {
position: absolute;
top: 4px;
@@ -886,6 +1006,12 @@ body {
overflow: hidden;
}
.seatmap-member-card.team-colored {
border-color: color-mix(in srgb, var(--seatmap-team-accent, rgba(245, 158, 11, 0.95)) 42%, rgba(255,255,255,0.4));
background:
linear-gradient(180deg, color-mix(in srgb, var(--seatmap-team-soft, rgba(245, 158, 11, 0.18)) 68%, rgba(15, 23, 42, 0.84)) 0%, rgba(15, 23, 42, 0.84) 100%);
}
.seatmap-member-card.draggable {
cursor: grab;
}
@@ -935,11 +1061,26 @@ body {
}
.seatmap-member-text em {
color: rgba(226, 232, 240, 0.84);
color: rgba(255, 247, 237, 0.96);
font-size: 10px;
font-weight: 800;
font-style: normal;
}
.seatmap-team-chip {
display: inline-flex;
align-items: center;
max-width: 100%;
padding: 2px 6px;
border-radius: 999px;
background: color-mix(in srgb, var(--seatmap-team-soft, rgba(245, 158, 11, 0.18)) 88%, white);
color: color-mix(in srgb, var(--seatmap-team-accent, #b45309) 85%, #10251d);
font-size: 10px;
font-weight: 900;
line-height: 1;
letter-spacing: 0.01em;
}
.seatmap-sidebar {
height: 100%;
min-height: 0;
@@ -1122,6 +1263,11 @@ body {
text-align: left;
}
.seatmap-member-search-card.team-colored {
border-color: color-mix(in srgb, var(--seatmap-team-accent, rgba(245, 158, 11, 0.24)) 34%, rgba(226, 232, 240, 0.9));
background: linear-gradient(180deg, color-mix(in srgb, var(--seatmap-team-soft, rgba(245, 158, 11, 0.1)) 78%, white), #fff);
}
.seatmap-member-badge {
flex: 0 0 auto;
display: inline-flex;
@@ -1146,8 +1292,8 @@ body {
min-height: 42px;
padding: 10px 12px;
border-radius: 12px;
background: #3f4658;
border: 1px solid rgba(148, 163, 184, 0.14);
background: transparent;
border: 1px solid rgba(194, 170, 134, 0.34);
box-shadow: none;
}
@@ -1159,12 +1305,14 @@ body {
.seatmap-member-text-inline strong {
font-size: 13px;
color: #10251d;
}
.seatmap-member-text-inline em {
display: inline;
color: rgba(226, 232, 240, 0.8);
color: #6f5b3e;
font-size: 11px;
font-weight: 800;
}
.seatmap-slot .seatmap-member-card {
@@ -1193,6 +1341,81 @@ body {
display: none;
}
.seatmap-context-panel {
display: grid;
gap: 10px;
padding: 14px;
border: 1px solid rgba(194, 170, 134, 0.28);
border-radius: 18px;
background: linear-gradient(180deg, rgba(255, 252, 247, 0.96), rgba(246, 239, 227, 0.92));
box-shadow: 0 14px 28px rgba(16, 37, 29, 0.08);
}
.seatmap-context-panel.hidden {
display: none;
}
.seatmap-context-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.seatmap-context-title {
display: grid;
gap: 4px;
}
.seatmap-context-title strong {
color: #10251d;
font-size: 14px;
font-weight: 900;
}
.seatmap-context-title span {
color: #6b7280;
font-size: 11px;
font-weight: 700;
}
.seatmap-context-badge {
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 0 10px;
border-radius: 999px;
background: color-mix(in srgb, var(--seatmap-team-soft, rgba(245, 158, 11, 0.14)) 88%, white);
color: color-mix(in srgb, var(--seatmap-team-accent, #b45309) 84%, #10251d);
font-size: 11px;
font-weight: 900;
}
.seatmap-context-tree {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.seatmap-context-node {
display: inline-flex;
align-items: center;
min-height: 30px;
padding: 0 11px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.84);
border: 1px solid rgba(194, 170, 134, 0.28);
color: #30423a;
font-size: 11px;
font-weight: 800;
}
.seatmap-context-empty {
color: #7b7b6c;
font-size: 12px;
line-height: 1.5;
}
.seatmap-dxf-stage {
cursor: grab;
}

BIN
incoming-files/1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 748 KiB

2598
incoming-files/260320.html Normal file

File diff suppressed because one or more lines are too long

36
incoming-files/README.md Normal file
View File

@@ -0,0 +1,36 @@
# incoming-files Layout
`8081` 1차 구조 정리 기준으로 `incoming-files`는 아래처럼 해석한다.
## Served
- 실제 URL에서 직접 서빙되는 HTML
- 현재 사용 파일:
- `served/payment.html`
- `served/mh.html`
- `served/ledger/index.html`
주의:
- backend `/integrations/payment`, `/integrations/mh`는 위 `served/*`만 읽는다.
- backend `/integrations/ledger``/integrations/ledger-assets/*``served/ledger/*`만 읽는다.
- 다만 `payment`, `mh`, `ledger`, `db-status`는 이제 앱 소스가 따로 있으므로, 실제 수정은 `frontend/apps/*`에서 하고 publish 스크립트로 `served/`에 반영한다.
-`served/`는 runtime 기준이고, 사람이 직접 먼저 수정하는 source-of-truth는 아니다.
## Reference
- 원본 참고 자산
- 복구 비교용 자산
- 직접 서빙하지 않는 파일
예:
- 원본 `xlsx`, `csv`
- 샘플 스타일 파일
- 원본/백업 HTML
- 디자인 비교용 파일
- `reference/omh.html`
- `reference/opayment.html`
- `reference/ledger/MH 통합 대시보드_260320.html`
- `reference/ledger/MH 통합 대시보드_260320.css`
- `reference/ledger/사업관리대장-1.xlsx`

View File

@@ -0,0 +1,24 @@
# Reference Assets
이 디렉터리는 `8081`에서 직접 서빙하지 않는 참고 원본/복구 비교 자산을 모으는 공간이다.
`#21` 2차부터 실제 reference 재배치를 시작했다.
현재 포함:
- `ledger/`
- 사업관리대장 원본 wrapper/html/css/xlsx
- 이전 override 참고 파일
규칙:
- runtime은 이 디렉터리를 직접 서빙하지 않는다.
- 실제 서비스 수정은 `incoming-files/served/` 기준으로 먼저 반영한다.
- reference는 비교, 복구, 출처 확인이 필요할 때만 본다.
예상 대상:
- 원본 HTML/CSS 참고본
- 원본 xlsx/csv
- 복구 비교용 자산
- 디자인 레퍼런스 파일

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

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

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,931 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>center chair people map</title>
<style>
:root {
--ink: #152330;
--muted: #627286;
--paper: rgba(255,255,255,0.86);
--line: rgba(21,35,48,0.1);
--accent: #0f766e;
--bg: #edf2f6;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "IBM Plex Sans KR", "Pretendard", sans-serif;
color: var(--ink);
background:
radial-gradient(circle at top left, rgba(15,118,110,0.11), transparent 22%),
linear-gradient(180deg, #f5f8fb 0%, #e8eef3 100%);
}
.page {
min-height: 100vh;
padding: 0;
}
.shell {
min-height: 100vh;
}
.panel {
border-radius: 0;
border: none;
background: transparent;
backdrop-filter: none;
box-shadow: none;
}
.actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
button {
border: none;
border-radius: 999px;
padding: 10px 14px;
font: inherit;
font-weight: 700;
cursor: pointer;
color: white;
background: linear-gradient(135deg, #0f766e, #115e59);
box-shadow: 0 10px 22px rgba(15,118,110,0.18);
}
button.alt {
color: var(--ink);
background: rgba(255,255,255,0.9);
border: 1px solid var(--line);
box-shadow: none;
}
.viewer {
position: relative;
overflow: hidden;
min-height: 100vh;
}
.viewer-head {
position: absolute;
top: 16px;
left: 16px;
right: 16px;
z-index: 2;
display: flex;
justify-content: space-between;
gap: 12px;
pointer-events: none;
}
.chip {
padding: 10px 12px;
border-radius: 16px;
background: rgba(255,255,255,0.82);
border: 1px solid rgba(255,255,255,0.94);
color: var(--muted);
font-size: 13px;
font-weight: 700;
box-shadow: 0 8px 24px rgba(21,35,48,0.08);
}
.viewer-actions {
position: absolute;
left: 16px;
top: 64px;
z-index: 2;
display: flex;
gap: 8px;
}
.mapper {
position: absolute;
top: 76px;
left: 50%;
transform: translateX(-50%);
width: min(94vw, 1320px);
max-height: min(56vh, 560px);
overflow: hidden;
z-index: 4;
border-radius: 20px;
background: rgba(234, 239, 247, 0.95);
border: 1px solid rgba(101, 119, 146, 0.22);
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.2);
display: flex;
flex-direction: column;
backdrop-filter: blur(6px);
}
.hidden-off {
display: none !important;
}
.mapper-head {
padding: 10px 14px;
border-bottom: 1px solid rgba(101,119,146,0.18);
font-size: 12px;
color: #51607a;
font-weight: 700;
line-height: 1.35;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
background: rgba(255,255,255,0.6);
}
.mapper-head strong {
display: block;
color: #17243b;
font-size: 20px;
margin-bottom: 2px;
}
.mapper-head .alt {
padding: 8px 10px;
font-size: 12px;
white-space: nowrap;
}
.org-chart {
margin: 0;
padding: 14px;
overflow: auto;
display: grid;
gap: 12px;
}
.org-top {
margin: 0 auto;
width: min(100%, 420px);
border-radius: 14px;
overflow: hidden;
border: 1px solid rgba(67, 84, 118, 0.25);
background: #fff;
}
.org-top-title {
background: #1e2f4d;
color: #fff;
text-align: center;
font-size: 34px;
font-weight: 800;
line-height: 1.1;
padding: 16px 12px;
letter-spacing: -0.03em;
}
.org-top-members {
padding: 10px;
display: grid;
gap: 6px;
background: rgba(255,255,255,0.95);
}
.org-teams {
display: grid;
grid-template-columns: repeat(7, minmax(160px, 1fr));
gap: 10px;
align-items: start;
}
.org-team {
border: 1px solid rgba(110, 126, 152, 0.25);
border-radius: 10px;
overflow: hidden;
background: rgba(255,255,255,0.95);
min-width: 0;
}
.org-team h4 {
margin: 0;
padding: 9px 10px;
font-size: 14px;
color: #21324e;
font-weight: 800;
border-bottom: 1px solid rgba(110, 126, 152, 0.2);
background: rgba(240, 245, 252, 0.96);
}
.org-members {
padding: 7px;
display: grid;
gap: 6px;
}
.org-person {
border: 1px solid rgba(116, 133, 161, 0.25);
background: rgba(255,255,255,0.95);
border-radius: 8px;
padding: 6px 8px;
cursor: pointer;
transition: background 120ms ease, border-color 120ms ease;
min-width: 0;
}
.org-person.active {
border-color: rgba(15,118,110,0.6);
background: rgba(15,118,110,0.11);
}
.org-person.assigned {
border-color: rgba(37,99,235,0.5);
background: rgba(37,99,235,0.1);
}
.org-person strong {
display: block;
font-size: 13px;
line-height: 1.3;
color: #15233a;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.org-person small {
display: block;
color: #5a6a86;
font-size: 11px;
line-height: 1.25;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 980px) {
.mapper {
top: 72px;
width: min(96vw, 920px);
max-height: 58vh;
}
.viewer-actions {
top: 64px;
left: 12px;
right: 12px;
flex-wrap: wrap;
}
.mapper-head strong {
font-size: 16px;
}
.org-top-title {
font-size: 24px;
}
.org-teams {
grid-template-columns: repeat(3, minmax(150px, 1fr));
}
}
canvas {
width: 100%;
height: 100%;
display: block;
cursor: grab;
}
canvas.dragging { cursor: grabbing; }
.tooltip {
position: absolute;
min-width: 170px;
padding: 12px 14px;
border-radius: 16px;
background: rgba(17,24,39,0.94);
color: white;
pointer-events: none;
opacity: 0;
transform: translate(12px, 12px);
transition: opacity 120ms ease;
z-index: 3;
}
.tooltip.visible { opacity: 1; }
.tooltip strong { display: block; margin-bottom: 6px; font-size: 14px; }
.tooltip div { font-size: 12px; line-height: 1.45; color: rgba(255,255,255,0.82); }
</style>
</head>
<body>
<div class="page">
<div class="shell">
<main class="panel viewer">
<div class="viewer-head">
<div class="chip" id="scale-chip"></div>
<div class="chip" id="hover-chip">chair hover: none</div>
</div>
<div class="viewer-actions">
<button type="button" id="fit-btn">전체 맞춤</button>
<button type="button" class="alt" id="clear-btn">선택 지우기</button>
</div>
<aside class="mapper hidden-off">
<div class="mapper-head">
<div id="mapper-status">
<strong>조직 현황</strong>
<span>선택 인원 없음</span>
</div>
<button type="button" class="alt" id="clear-assign-btn">매칭 초기화</button>
</div>
<div class="org-chart" id="org-chart"></div>
</aside>
<canvas id="canvas"></canvas>
<div class="tooltip" id="tooltip"></div>
</main>
</div>
</div>
<script src="./center_chair_people_payload.js?v=20260330a"></script>
<script>
const DATA = window.CHAIR_MAP_DATA;
function decodeSegments(base64) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
return new Int32Array(bytes.buffer);
}
const bgTileRanges = DATA.bgTileRanges;
const bgSegValues = decodeSegments(DATA.bgSegsB64);
const chairSegValues = decodeSegments(DATA.chairSegsB64);
const chairs = DATA.chairs.map(([key, name, kind, start, count]) => ({
key, name, kind, start, count
}));
const meta = DATA.meta;
const world = meta.headerBounds;
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
const tooltip = document.getElementById("tooltip");
const scaleChip = document.getElementById("scale-chip");
const hoverChip = document.getElementById("hover-chip");
const STORAGE_KEY = "ptc-chair-selection";
const PEOPLE_STORAGE_KEY = "ptc-chair-people";
const ASSIGN_STORAGE_KEY = "ptc-chair-assignments";
const ACTIVE_PERSON_STORAGE_KEY = "ptc-chair-active-person";
const clearAssignBtn = document.getElementById("clear-assign-btn");
const orgChartEl = document.getElementById("org-chart");
const mapperStatus = document.getElementById("mapper-status");
// Prevent stale auto-highlights from previous sessions.
localStorage.removeItem(STORAGE_KEY);
localStorage.removeItem(ACTIVE_PERSON_STORAGE_KEY);
localStorage.removeItem(ASSIGN_STORAGE_KEY);
const placed = new Set();
let people = JSON.parse(localStorage.getItem(PEOPLE_STORAGE_KEY) || "[]");
let chairAssignments = {};
let activePersonId = null;
const ORG_TEMPLATE = {
top: {
name: "총괄기획실",
count: 53,
members: [
{ name: "장종찬", dept: "총괄기획실", title: "기획실장" },
{ name: "김원식", dept: "총괄기획실", title: "전무이사" },
],
},
teams: [
{ name: "경영기획팀", count: 6, members: ["김우진", "임민정", "국혜린", "최선아", "김윤재", "이미영"] },
{ name: "인재성장팀", count: 5, members: ["조태희", "최근혜", "류원준", "주안기", "정성호"] },
{ name: "ERP 기획팀", count: 5, members: ["류호성", "문형식", "최요제", "황대일", "이채봉"] },
{ name: "디자인기획팀", count: 17, members: ["신혜영", "정은혜", "김태식", "최예은", "채선영", "최영환", "윤봄이", "이예진", "허유나", "마희연", "김수현", "박지영", "권순호", "정두휘", "김정석", "정지윤", "양숙영"] },
{ name: "기술기획팀", count: 11, members: ["김원기", "홍아름", "이경민", "김혜인", "황동환", "최찬호", "이태훈", "김신지", "조찬영", "김용연", "한치영"] },
{ name: "협업증진팀", count: 3, members: ["성형일", "박주한", "한승민"] },
{ name: "솔루션통합팀", count: 4, members: ["권혁진", "염승호", "윤준수", "김지영"] },
],
};
const chairGeometry = chairs.map((chair) => {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
const path = new Path2D();
const hitSegments = new Float32Array(chair.count * 4);
let segCursor = 0;
for (let i = chair.start; i < chair.start + chair.count; i += 1) {
const offset = i * 4;
const x1 = chairSegValues[offset] / 10;
const y1 = chairSegValues[offset + 1] / 10;
const x2 = chairSegValues[offset + 2] / 10;
const y2 = chairSegValues[offset + 3] / 10;
path.moveTo(x1, y1);
path.lineTo(x2, y2);
hitSegments[segCursor] = x1;
hitSegments[segCursor + 1] = y1;
hitSegments[segCursor + 2] = x2;
hitSegments[segCursor + 3] = y2;
segCursor += 4;
minX = Math.min(minX, x1, x2);
minY = Math.min(minY, y1, y2);
maxX = Math.max(maxX, x1, x2);
maxY = Math.max(maxY, y1, y2);
}
return {
...chair,
minX,
minY,
maxX,
maxY,
area: Math.max(1, (maxX - minX) * (maxY - minY)),
path,
hitSegments,
};
});
function renumberChairKeys(chairItems) {
if (!chairItems.length) return;
const heights = chairItems
.map((chair) => Math.max(1, chair.maxY - chair.minY))
.sort((a, b) => a - b);
const medianHeight = heights[Math.floor(heights.length / 2)] || 1;
const rowTolerance = Math.max(40, medianHeight * 0.9);
const sorted = [...chairItems].sort((a, b) => {
const ay = (a.minY + a.maxY) * 0.5;
const by = (b.minY + b.maxY) * 0.5;
if (Math.abs(by - ay) > rowTolerance) return by - ay; // top -> bottom
const ax = (a.minX + a.maxX) * 0.5;
const bx = (b.minX + b.maxX) * 0.5;
return ax - bx; // left -> right
});
sorted.forEach((chair, index) => {
chair.key = String(index + 1);
chair.seatNo = index + 1;
});
}
renumberChairKeys(chairGeometry);
const PICK_GRID_SIZE = 1800;
const chairPickGrid = new Map();
function pickGridKey(gx, gy) {
return `${gx},${gy}`;
}
chairGeometry.forEach((chair, index) => {
const minGX = Math.floor(chair.minX / PICK_GRID_SIZE);
const maxGX = Math.floor(chair.maxX / PICK_GRID_SIZE);
const minGY = Math.floor(chair.minY / PICK_GRID_SIZE);
const maxGY = Math.floor(chair.maxY / PICK_GRID_SIZE);
for (let gx = minGX; gx <= maxGX; gx += 1) {
for (let gy = minGY; gy <= maxGY; gy += 1) {
const key = pickGridKey(gx, gy);
if (!chairPickGrid.has(key)) chairPickGrid.set(key, []);
chairPickGrid.get(key).push(index);
}
}
});
const camera = { scale: 1, offsetX: 0, offsetY: 0 };
let pixelRatio = window.devicePixelRatio || 1;
let pointer = { x: 0, y: 0 };
let dragging = false;
let dragStart = null;
let hovered = null;
let rafPending = false;
function normalizePeople(raw) {
return raw
.map((person, index) => {
if (!person || !person.name) return null;
return {
id: person.id || `person-${index + 1}`,
name: String(person.name).trim(),
dept: String(person.dept || "").trim(),
title: String(person.title || "").trim(),
};
})
.filter(Boolean);
}
function createTemplatePeople() {
const generated = [];
let seq = 1;
ORG_TEMPLATE.top.members.forEach((member) => {
generated.push({
id: `org-${seq++}`,
name: member.name,
dept: member.dept,
title: member.title,
});
});
ORG_TEMPLATE.teams.forEach((team) => {
team.members.forEach((name) => {
generated.push({
id: `org-${seq++}`,
name,
dept: team.name,
title: "선임",
});
});
});
return generated;
}
people = normalizePeople(people);
const templateReady = people.some((person) => person.name === "장종찬" && person.dept === "총괄기획실");
if (!templateReady) {
people = createTemplatePeople();
localStorage.setItem(PEOPLE_STORAGE_KEY, JSON.stringify(people));
}
const chairKeySet = new Set(chairGeometry.map((chair) => chair.key));
chairAssignments = Object.fromEntries(
Object.entries(chairAssignments).filter(([chairKey, personId]) => (
chairKeySet.has(chairKey) && people.some((person) => person.id === personId)
))
);
if (activePersonId && !people.some((person) => person.id === activePersonId)) activePersonId = null;
function persistPeople() {
localStorage.setItem(PEOPLE_STORAGE_KEY, JSON.stringify(people));
}
function persistAssignments() {
localStorage.setItem(ASSIGN_STORAGE_KEY, JSON.stringify(chairAssignments));
}
function persistActivePerson() {
if (!activePersonId) localStorage.removeItem(ACTIVE_PERSON_STORAGE_KEY);
else localStorage.setItem(ACTIVE_PERSON_STORAGE_KEY, activePersonId);
}
function assignmentCount() {
return Object.keys(chairAssignments).length;
}
function getPersonById(id) {
return people.find((person) => person.id === id) || null;
}
function getChairByPerson(personId) {
for (const [chairKey, assignedPersonId] of Object.entries(chairAssignments)) {
if (assignedPersonId === personId) return chairKey;
}
return null;
}
function renderPeopleList() {
const activePerson = getPersonById(activePersonId);
const countText = `${assignmentCount()} / ${people.length} 매칭`;
mapperStatus.innerHTML = `<strong>조직 현황</strong><span>${activePerson ? `${activePerson.name} 선택됨` : "선택 인원 없음"} · ${countText}</span>`;
const findPerson = (dept, name) => people.find((person) => person.dept === dept && person.name === name) || null;
const personCard = (person, roleText) => {
if (!person) return "";
const chairKey = getChairByPerson(person.id);
const assignedClass = chairKey ? " assigned" : "";
const activeClass = person.id === activePersonId ? " active" : "";
return `
<article class="org-person${assignedClass}${activeClass}" data-person-id="${person.id}">
<strong>${person.name}</strong>
<small>${person.title || roleText || "-"}</small>
<small>${chairKey ? `좌석 ${chairKey}` : "좌석 미지정"}</small>
</article>
`;
};
const topHtml = ORG_TEMPLATE.top.members
.map((member) => personCard(findPerson(member.dept, member.name), member.title))
.join("");
const teamsHtml = ORG_TEMPLATE.teams.map((team) => {
const membersHtml = team.members
.map((name) => personCard(findPerson(team.name, name), "선임"))
.join("");
return `
<section class="org-team">
<h4>${team.name} (${team.count})</h4>
<div class="org-members">${membersHtml}</div>
</section>
`;
}).join("");
orgChartEl.innerHTML = `
<section class="org-top">
<div class="org-top-title">${ORG_TEMPLATE.top.name} (${ORG_TEMPLATE.top.count})</div>
<div class="org-top-members">${topHtml}</div>
</section>
<section class="org-teams">${teamsHtml}</section>
`;
}
function worldToScreen(x, y) {
return {
x: x * camera.scale + camera.offsetX,
y: (world.maxY - y + world.minY) * camera.scale + camera.offsetY,
};
}
function screenToWorld(x, y) {
return {
x: (x - camera.offsetX) / camera.scale,
y: world.maxY + world.minY - (y - camera.offsetY) / camera.scale,
};
}
function resize() {
pixelRatio = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = Math.round(rect.width * pixelRatio);
canvas.height = Math.round(rect.height * pixelRatio);
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
fit();
}
function fit() {
const rect = canvas.getBoundingClientRect();
const width = world.maxX - world.minX;
const height = world.maxY - world.minY;
const pad = 36;
const scaleX = (rect.width - pad * 2) / width;
const scaleY = (rect.height - pad * 2) / height;
camera.scale = Math.min(scaleX, scaleY);
camera.offsetX = pad - world.minX * camera.scale + (rect.width - pad * 2 - width * camera.scale) / 2;
camera.offsetY = pad - world.minY * camera.scale + (rect.height - pad * 2 - height * camera.scale) / 2;
requestDraw();
}
function drawGrid(width, height) {
ctx.save();
ctx.strokeStyle = "rgba(21,35,48,0.05)";
ctx.lineWidth = 1;
for (let x = 120; x < width; x += 120) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
ctx.stroke();
}
for (let y = 120; y < height; y += 120) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(width, y);
ctx.stroke();
}
ctx.restore();
}
function pickChair(screenX, screenY) {
const threshold = 12;
const pointerWorld = screenToWorld(screenX, screenY);
const thresholdWorld = threshold / camera.scale;
const thresholdWorldSq = thresholdWorld * thresholdWorld;
const minGX = Math.floor((pointerWorld.x - thresholdWorld) / PICK_GRID_SIZE);
const maxGX = Math.floor((pointerWorld.x + thresholdWorld) / PICK_GRID_SIZE);
const minGY = Math.floor((pointerWorld.y - thresholdWorld) / PICK_GRID_SIZE);
const maxGY = Math.floor((pointerWorld.y + thresholdWorld) / PICK_GRID_SIZE);
const candidateIndexes = [];
const seen = new Set();
for (let gx = minGX; gx <= maxGX; gx += 1) {
for (let gy = minGY; gy <= maxGY; gy += 1) {
const candidates = chairPickGrid.get(pickGridKey(gx, gy));
if (!candidates) continue;
for (const index of candidates) {
if (seen.has(index)) continue;
seen.add(index);
candidateIndexes.push(index);
}
}
}
let best = null;
for (const index of candidateIndexes) {
const chair = chairGeometry[index];
if (
pointerWorld.x < chair.minX - thresholdWorld ||
pointerWorld.x > chair.maxX + thresholdWorld ||
pointerWorld.y < chair.minY - thresholdWorld ||
pointerWorld.y > chair.maxY + thresholdWorld
) continue;
let distSq = Infinity;
for (let i = 0; i < chair.hitSegments.length; i += 4) {
const x1 = chair.hitSegments[i];
const y1 = chair.hitSegments[i + 1];
const x2 = chair.hitSegments[i + 2];
const y2 = chair.hitSegments[i + 3];
const dx = x2 - x1;
const dy = y2 - y1;
const len2 = dx * dx + dy * dy;
let segDistSq;
if (len2 === 0) {
const px = pointerWorld.x - x1;
const py = pointerWorld.y - y1;
segDistSq = px * px + py * py;
} else {
let t = ((pointerWorld.x - x1) * dx + (pointerWorld.y - y1) * dy) / len2;
t = Math.max(0, Math.min(1, t));
const lx = x1 + t * dx;
const ly = y1 + t * dy;
const px = pointerWorld.x - lx;
const py = pointerWorld.y - ly;
segDistSq = px * px + py * py;
}
if (segDistSq < distSq) distSq = segDistSq;
if (distSq <= thresholdWorldSq * 0.3) break;
}
if (distSq > thresholdWorldSq) continue;
const dist = Math.sqrt(distSq) * camera.scale;
if (!best) {
best = { chair, dist };
continue;
}
const distGap = dist - best.dist;
if (distGap < -0.75) {
best = { chair, dist };
continue;
}
if (Math.abs(distGap) <= 2) {
const areaGap = chair.area - best.chair.area;
if (areaGap < -1) {
best = { chair, dist };
continue;
}
if (
Math.abs(areaGap) <= 1 &&
chair.kind === "block" &&
best.chair.kind !== "block"
) {
best = { chair, dist };
}
}
}
return best ? best.chair : null;
}
function renderTooltip() {
if (!hovered) {
tooltip.classList.remove("visible");
hoverChip.textContent = "chair hover: none";
return;
}
hoverChip.textContent = `chair hover: ${hovered.name}`;
tooltip.innerHTML = `
<strong>${hovered.name}</strong>
<div>chair key: ${hovered.key}</div>
<div>${placed.has(hovered.key) ? "선택됨" : "클릭하면 선택"}</div>
<div>${chairAssignments[hovered.key] ? `배치: ${(getPersonById(chairAssignments[hovered.key]) || { name: "알수없음" }).name}` : "배치 인원 없음"}</div>
`;
tooltip.style.left = `${pointer.x + 14}px`;
tooltip.style.top = `${pointer.y + 14}px`;
tooltip.classList.add("visible");
}
function requestDraw() {
if (rafPending) return;
rafPending = true;
window.requestAnimationFrame(() => {
rafPending = false;
draw();
});
}
function applyWorldTransform() {
ctx.setTransform(
pixelRatio * camera.scale,
0,
0,
-pixelRatio * camera.scale,
pixelRatio * camera.offsetX,
pixelRatio * ((world.maxY + world.minY) * camera.scale + camera.offsetY)
);
}
function draw() {
const rect = canvas.getBoundingClientRect();
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
ctx.clearRect(0, 0, rect.width, rect.height);
drawGrid(rect.width, rect.height);
const viewA = screenToWorld(0, rect.height);
const viewB = screenToWorld(rect.width, 0);
const viewMinX = Math.min(viewA.x, viewB.x);
const viewMaxX = Math.max(viewA.x, viewB.x);
const viewMinY = Math.min(viewA.y, viewB.y);
const viewMaxY = Math.max(viewA.y, viewB.y);
ctx.save();
applyWorldTransform();
ctx.strokeStyle = "rgba(100, 116, 139, 0.28)";
ctx.lineWidth = 1 / camera.scale;
const tileSize = meta.backgroundTileSize;
const tileMinX = Math.floor(viewMinX / tileSize);
const tileMaxX = Math.floor(viewMaxX / tileSize);
const tileMinY = Math.floor(viewMinY / tileSize);
const tileMaxY = Math.floor(viewMaxY / tileSize);
for (let tx = tileMinX; tx <= tileMaxX; tx += 1) {
for (let ty = tileMinY; ty <= tileMaxY; ty += 1) {
const range = bgTileRanges[`${tx},${ty}`];
if (!range) continue;
const start = range[0];
const count = range[1];
for (let i = start; i < start + count; i += 1) {
const offset = i * 4;
const x1 = bgSegValues[offset] / 10;
const y1 = bgSegValues[offset + 1] / 10;
const x2 = bgSegValues[offset + 2] / 10;
const y2 = bgSegValues[offset + 3] / 10;
if (
Math.max(x1, x2) < viewMinX ||
Math.min(x1, x2) > viewMaxX ||
Math.max(y1, y2) < viewMinY ||
Math.min(y1, y2) > viewMaxY
) continue;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
}
}
}
ctx.restore();
hovered = dragging ? null : pickChair(pointer.x, pointer.y);
ctx.save();
applyWorldTransform();
ctx.lineWidth = 1.45 / camera.scale;
ctx.lineCap = "round";
ctx.lineJoin = "round";
for (const chair of chairGeometry) {
if (chair.maxX < viewMinX || chair.minX > viewMaxX || chair.maxY < viewMinY || chair.minY > viewMaxY) continue;
const active = hovered && hovered.key === chair.key;
const selected = placed.has(chair.key);
const assignedPersonId = chairAssignments[chair.key];
const activePersonChair = activePersonId && assignedPersonId === activePersonId;
const assigned = Boolean(assignedPersonId);
const baseWidth = chair.kind === "block" ? 1.45 : 1.35;
ctx.strokeStyle = activePersonChair
? "rgba(234, 179, 8, 1)"
: assigned
? "rgba(37, 99, 235, 0.98)"
: selected
? "rgba(220, 38, 38, 0.98)"
: active
? "rgba(15, 118, 110, 0.98)"
: chair.kind === "group"
? "rgba(16, 134, 149, 0.74)"
: "rgba(21, 149, 142, 0.8)";
ctx.lineWidth = (activePersonChair ? 2.8 : assigned ? 2.4 : selected ? 2.6 : active ? 2.1 : baseWidth) / camera.scale;
ctx.stroke(chair.path);
}
ctx.restore();
scaleChip.textContent = `scale ${camera.scale.toFixed(4)}x`;
renderTooltip();
}
function persistPlaced() {
localStorage.setItem(STORAGE_KEY, JSON.stringify([...placed]));
}
canvas.addEventListener("pointerdown", (event) => {
dragging = true;
dragStart = { x: event.clientX, y: event.clientY, offsetX: camera.offsetX, offsetY: camera.offsetY };
canvas.classList.add("dragging");
});
window.addEventListener("pointerup", (event) => {
if (dragging && dragStart) {
const move = Math.hypot(event.clientX - dragStart.x, event.clientY - dragStart.y);
if (move < 4) {
const rect = canvas.getBoundingClientRect();
const picked = pickChair(event.clientX - rect.left, event.clientY - rect.top);
if (picked) {
if (placed.has(picked.key)) placed.delete(picked.key);
else placed.add(picked.key);
persistPlaced();
if (activePersonId) {
const currentChair = getChairByPerson(activePersonId);
if (chairAssignments[picked.key] === activePersonId) {
delete chairAssignments[picked.key];
} else {
if (currentChair && currentChair !== picked.key) delete chairAssignments[currentChair];
chairAssignments[picked.key] = activePersonId;
}
persistAssignments();
renderPeopleList();
}
}
}
}
dragging = false;
dragStart = null;
canvas.classList.remove("dragging");
requestDraw();
});
window.addEventListener("pointermove", (event) => {
const rect = canvas.getBoundingClientRect();
pointer = { x: event.clientX - rect.left, y: event.clientY - rect.top };
if (dragging && dragStart) {
camera.offsetX = dragStart.offsetX + (event.clientX - dragStart.x);
camera.offsetY = dragStart.offsetY + (event.clientY - dragStart.y);
}
requestDraw();
});
canvas.addEventListener("wheel", (event) => {
event.preventDefault();
const rect = canvas.getBoundingClientRect();
const mx = event.clientX - rect.left;
const my = event.clientY - rect.top;
const before = screenToWorld(mx, my);
const factor = event.deltaY < 0 ? 1.08 : 0.92;
camera.scale = Math.max(0.002, Math.min(2, camera.scale * factor));
const after = worldToScreen(before.x, before.y);
camera.offsetX += mx - after.x;
camera.offsetY += my - after.y;
requestDraw();
}, { passive: false });
document.getElementById("fit-btn").addEventListener("click", fit);
document.getElementById("clear-btn").addEventListener("click", () => {
placed.clear();
persistPlaced();
requestDraw();
});
clearAssignBtn.addEventListener("click", () => {
chairAssignments = {};
persistAssignments();
renderPeopleList();
requestDraw();
});
orgChartEl.addEventListener("click", (event) => {
const item = event.target.closest(".org-person[data-person-id]");
if (!item) return;
const personId = item.getAttribute("data-person-id");
activePersonId = personId === activePersonId ? null : personId;
persistActivePerson();
renderPeopleList();
requestDraw();
});
window.addEventListener("resize", resize);
renderPeopleList();
resize();
</script>
</body>
</html>

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,23 @@
# Served Assets
이 디렉터리는 `8081`에서 실제 URL 응답으로 직접 서빙되는 integration HTML 파일만 둔다.
현재 사용 중:
- `payment.html`
- `mh.html`
- `ledger/index.html`
- `ledger/ledger-override.css`
- `ledger/ledger-override.js`
- `ledger/MH 통합 대시보드_260320.css`
- `ledger/사업관리대장-1.xlsx`
규칙:
- `/integrations/payment` 는 이 디렉터리의 `payment.html`을 읽는다.
- `/integrations/mh` 는 이 디렉터리의 `mh.html`을 읽는다.
- `/integrations/ledger``ledger/index.html`을 읽는다.
- `/integrations/ledger-assets/*``ledger/` 하위 파일만 읽는다.
- `payment.html` 수정 원본은 `frontend/apps/payment/index.html`이고, `scripts/publish_payment_app.sh`로 반영한다.
- `mh.html` 수정 원본은 `frontend/apps/team/index.html`이고, `scripts/publish_team_app.sh`로 반영한다.
- 원본 참고 파일이나 비교용 파일은 이 디렉터리에 두지 않는다.

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,21 @@
# Ledger Served Assets
`8081` 사업관리대장 화면이 실제로 읽는 런타임 파일 모음이다.
source-of-truth:
- [frontend/apps/ledger](../../../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);
});
})();

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@@ -110,35 +110,35 @@ const App = () => {
};
const costCategories = [
{ name: '인건비', color: '#6366f1' },
{ name: '출장비', color: '#f43f5e' },
{ name: '복리후생비', color: '#fbbf24' },
{ name: '구매비', color: '#0ea5e9' },
{ name: '외주비', color: '#94a3b8' }
{ name: '인건비', color: '#0f3a2f' },
{ name: '출장비', color: '#a94832' },
{ name: '복리후생비', color: '#d68a3a' },
{ name: '구매비', color: '#4b87b3' },
{ name: '외주비', color: '#66756d' }
];
const positionStyles = {
'수석연구원': { bg: 'bg-purple-50', text: 'text-purple-600', border: 'border-purple-100', icon: 'bg-purple-600' },
'책임연구원': { bg: 'bg-blue-50', text: 'text-blue-600', border: 'border-blue-100', icon: 'bg-blue-600' },
'선임연구원': { bg: 'bg-indigo-50', text: 'text-indigo-600', border: 'border-indigo-100', icon: 'bg-indigo-600' },
'전임연구원': { bg: 'bg-emerald-50', text: 'text-emerald-600', border: 'border-emerald-100', icon: 'bg-emerald-600' },
'주임연구원': { bg: 'bg-slate-50', text: 'text-slate-600', border: 'border-slate-100', icon: 'bg-slate-600' },
'연구원': { bg: 'bg-slate-50', text: 'text-slate-500', border: 'border-slate-100', icon: 'bg-slate-400' },
'미지정': { bg: 'bg-gray-50', text: 'text-gray-400', border: 'border-gray-100', icon: 'bg-gray-300' }
'수석연구원': { bg: 'position-chip position-executive', text: 'position-text position-executive', border: 'position-border position-executive', icon: 'position-dot position-executive' },
'책임연구원': { bg: 'position-chip position-principal', text: 'position-text position-principal', border: 'position-border position-principal', icon: 'position-dot position-principal' },
'선임연구원': { bg: 'position-chip position-senior', text: 'position-text position-senior', border: 'position-border position-senior', icon: 'position-dot position-senior' },
'전임연구원': { bg: 'position-chip position-associate', text: 'position-text position-associate', border: 'position-border position-associate', icon: 'position-dot position-associate' },
'주임연구원': { bg: 'position-chip position-staff', text: 'position-text position-staff', border: 'position-border position-staff', icon: 'position-dot position-staff' },
'연구원': { bg: 'position-chip position-member', text: 'position-text position-member', border: 'position-border position-member', icon: 'position-dot position-member' },
'미지정': { bg: 'position-chip position-unset', text: 'position-text position-unset', border: 'position-border position-unset', icon: 'position-dot position-unset' }
};
const positionOrder = { '수석연구원': 1, '책임연구원': 2, '선임연구원': 3, '연구원': 4 };
const positionColorMap = {
'수석연구원': '#7c3aed',
'책임연구원': '#2563eb',
'선임연구원': '#4f46e5',
'전임연구원': '#059669',
'주임연구원': '#475569',
'연구원': '#64748b',
'미지정': '#9ca3af'
'수석연구원': '#0f3a2f',
'책임연구원': '#1a5645',
'선임연구원': '#2f9973',
'전임연구원': '#4b87b3',
'주임연구원': '#9a6422',
'연구원': '#66756d',
'미지정': '#b7aa93'
};
const getPositionStyle = (pos) => positionStyles[pos] || positionStyles['미지정'];
const getCostColor = (name) => costCategories.find(c => c.name === name)?.color || '#94a3b8';
const getCostColor = (name) => costCategories.find(c => c.name === name)?.color || '#66756d';
const getPositionColor = (name) => positionColorMap[name] || positionColorMap['미지정'];
const twoLineClampStyle = {
display: '-webkit-box',
@@ -164,7 +164,7 @@ const App = () => {
const buildDonutGradient = (items) => {
const total = items.reduce((sum, item) => sum + (item.value || 0), 0);
if (total <= 0) return 'conic-gradient(#e2e8f0 0deg 360deg)';
if (total <= 0) return 'conic-gradient(#eadcc4 0deg 360deg)';
let start = 0;
const slices = items.map((item) => {
const deg = ((item.value || 0) / total) * 360;
@@ -177,7 +177,7 @@ const App = () => {
};
const renderBreakdownTooltip = (breakdown, total) => (
<div className="pointer-events-none absolute left-1/2 top-0 z-20 -translate-x-1/2 -translate-y-[110%] whitespace-nowrap rounded-lg bg-slate-900 px-3 py-2 text-[12px] font-bold text-white opacity-0 shadow-lg transition-opacity group-hover:opacity-100">
<div className="pointer-events-none absolute left-1/2 top-0 z-20 -translate-x-1/2 -translate-y-[110%] whitespace-nowrap rounded-lg payment-tooltip px-3 py-2 text-[12px] font-bold opacity-0 shadow-lg transition-opacity group-hover:opacity-100">
{costCategories.map((cat) => {
const val = breakdown?.[cat.name] || 0;
const ratio = total > 0 ? ((val / total) * 100).toFixed(1) : '0.0';
@@ -195,7 +195,7 @@ const App = () => {
);
const renderPositionBreakdownTooltip = (breakdown, totalHrs) => (
<div className="pointer-events-none absolute left-1/2 top-0 z-20 -translate-x-1/2 -translate-y-[110%] whitespace-nowrap rounded-lg bg-slate-900 px-3 py-2 text-[12px] font-bold text-white opacity-0 shadow-lg transition-opacity group-hover:opacity-100">
<div className="pointer-events-none absolute left-1/2 top-0 z-20 -translate-x-1/2 -translate-y-[110%] whitespace-nowrap rounded-lg payment-tooltip px-3 py-2 text-[12px] font-bold opacity-0 shadow-lg transition-opacity group-hover:opacity-100">
{Object.entries(breakdown || {})
.sort(([a], [b]) => (positionOrder[a] || 99) - (positionOrder[b] || 99) || a.localeCompare(b))
.map(([pos, val]) => {
@@ -226,9 +226,9 @@ const App = () => {
return (
<div className="mt-2 grid grid-cols-[72px_1fr] items-center gap-2">
<div className="self-center text-center">
<div className="text-[16px] leading-none font-black text-slate-800">{Number(totalWorkers || 0)}명</div>
<div className="text-[16px] leading-none font-black payment-strong">{Number(totalWorkers || 0)}명</div>
</div>
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[10px] font-black text-slate-600 leading-tight">
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[10px] font-black payment-muted leading-tight">
{entries.map(([pos, val]) => {
const count = details?.[pos]?.names?.size || 0;
const hrsText = Number(val || 0).toFixed(1).replace(/\.0$/, '');
@@ -258,7 +258,7 @@ const App = () => {
{cells.map((cell) => {
const amount = Math.round(breakdown?.[cell.key] || 0);
return (
<div key={`${cell.key}-v`} className="px-2 py-1.5 text-right text-[11px] font-black text-slate-700 whitespace-nowrap">
<div key={`${cell.key}-v`} className="px-2 py-1.5 text-right text-[11px] font-black payment-muted whitespace-nowrap">
{amount === 0 ? '-' : `${amount.toLocaleString()}원`}
</div>
);
@@ -1134,23 +1134,23 @@ const App = () => {
const isAllFiltersApplied = selectedRev !== '전체' && selectedD1 !== '전체' && selectedD2 !== '전체' && selectedProject !== '전체';
return (
<div className="min-h-screen bg-[#f8fafc] p-6 font-sans text-slate-900">
<div className="w-full mx-auto space-y-6">
<div className="payment-theme min-h-screen p-6 font-sans">
<div className="w-full mx-auto space-y-6" style={{ maxWidth: '2000px' }}>
{!isAllFiltersApplied && (
<>
{/* KPIs */}
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-4 sticky top-0 z-30 bg-[#f8fafc] pb-3">
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-4 sticky top-0 z-30 payment-kpi-grid pb-3">
{[
{ label: '총 수입(매출)', value: formatWonRounded(viewData.kpis.income), totalValue: formatWonRounded(viewData.kpisAll.income), icon: Wallet, color: 'text-indigo-600' },
{ label: '인건비 합계', value: formatWonRounded(viewData.kpis.labor), totalValue: formatWonRounded(viewData.kpisAll.labor), icon: Briefcase, color: 'text-slate-600' },
{ label: '출장비', value: formatWonRounded(viewData.kpis.travel), totalValue: formatWonRounded(viewData.kpisAll.travel), icon: MapPin, color: 'text-rose-600' },
{ label: '복리후생비', value: formatWonRounded(viewData.kpis.welfare), totalValue: formatWonRounded(viewData.kpisAll.welfare), icon: Coffee, color: 'text-amber-600' },
{ label: '구매/외주비', value: formatWonRounded(viewData.kpis.others), totalValue: formatWonRounded(viewData.kpisAll.others), icon: Package, color: 'text-slate-500' },
{ label: '투입시간', value: `${viewData.kpis.hours.toLocaleString()}h`, totalValue: `${viewData.kpisAll.hours.toLocaleString()}h`, icon: Clock, color: 'text-indigo-600' },
{ label: '참여인원', value: `${viewData.kpis.workers}명`, totalValue: `${viewData.kpisAll.workers}명`, icon: Users, color: 'text-white', bg: 'bg-slate-900' },
{ label: '총 수입(매출)', value: formatWonRounded(viewData.kpis.income), totalValue: formatWonRounded(viewData.kpisAll.income), icon: Wallet, color: 'payment-kpi-income' },
{ label: '인건비 합계', value: formatWonRounded(viewData.kpis.labor), totalValue: formatWonRounded(viewData.kpisAll.labor), icon: Briefcase, color: 'payment-kpi-labor' },
{ label: '출장비', value: formatWonRounded(viewData.kpis.travel), totalValue: formatWonRounded(viewData.kpisAll.travel), icon: MapPin, color: 'payment-kpi-travel' },
{ label: '복리후생비', value: formatWonRounded(viewData.kpis.welfare), totalValue: formatWonRounded(viewData.kpisAll.welfare), icon: Coffee, color: 'payment-kpi-welfare' },
{ label: '구매/외주비', value: formatWonRounded(viewData.kpis.others), totalValue: formatWonRounded(viewData.kpisAll.others), icon: Package, color: 'payment-kpi-others' },
{ label: '투입시간', value: `${viewData.kpis.hours.toLocaleString()}h`, totalValue: `${viewData.kpisAll.hours.toLocaleString()}h`, icon: Clock, color: 'payment-kpi-hours' },
{ label: '참여인원', value: `${viewData.kpis.workers}명`, totalValue: `${viewData.kpisAll.workers}명`, icon: Users, color: 'payment-kpi-inverse', bg: 'payment-kpi-people' },
].map((kpi, i) => (
<div key={i} className={`${kpi.bg || 'bg-white'} ${kpi.color} p-4 rounded-[22px] border border-slate-100 shadow-sm flex flex-col h-24`}>
<div key={i} className={`payment-kpi-card ${kpi.bg || ''} ${kpi.color} p-4 rounded-[22px] flex flex-col h-24`}>
<span className="text-[11px] font-black uppercase opacity-60 flex justify-between">{kpi.label} <kpi.icon size={10}/></span>
<div className="flex flex-col leading-tight mt-1 gap-1">
<span className="text-lg font-black truncate">{kpi.value}</span>
@@ -1163,16 +1163,16 @@ const App = () => {
)}
{/* 상세 분석 테이블 */}
<section className="bg-white rounded-[35px] shadow-sm border border-slate-100 overflow-visible">
<div className={`px-6 py-4 border-b border-slate-50 flex items-center justify-between gap-4 sticky ${!isAllFiltersApplied ? 'top-[108px]' : 'top-0'} z-40 bg-white/95 backdrop-blur-sm`}>
<h2 className="text-lg font-black flex items-center gap-3"><List size={20} className="text-indigo-600" /> 분야별 프로젝트 상세 분석</h2>
<section className="payment-panel payment-table-panel rounded-[35px] overflow-visible">
<div className={`payment-panel-head px-6 py-4 flex items-center justify-between gap-4 sticky ${!isAllFiltersApplied ? 'top-[108px]' : 'top-0'} z-40 backdrop-blur-sm`}>
<h2 className="text-lg font-black flex items-center gap-3"><List size={20} className="payment-icon-accent" /> 분야별 프로젝트 상세 분석</h2>
<div className="group relative shrink-0">
<button type="button" className="px-3 py-2 bg-slate-900 text-white rounded-xl text-[12px] font-black tracking-wide shadow-sm border border-slate-800">
<button type="button" className="payment-filter-toggle px-3 py-2 rounded-xl text-[12px] font-black tracking-wide shadow-sm border">
카테고리 필터
</button>
<div className="absolute right-0 top-full mt-2 z-30 w-[min(980px,92vw)] rounded-2xl border border-slate-200 bg-white p-3 shadow-2xl opacity-0 pointer-events-none translate-y-1 transition-all duration-200 group-hover:opacity-100 group-hover:pointer-events-auto group-hover:translate-y-0 group-focus-within:opacity-100 group-focus-within:pointer-events-auto group-focus-within:translate-y-0">
<div className="payment-filter-pop absolute right-0 top-full mt-2 z-30 w-[min(980px,92vw)] rounded-2xl p-3 shadow-2xl opacity-0 pointer-events-none translate-y-1 transition-all duration-200 group-hover:opacity-100 group-hover:pointer-events-auto group-hover:translate-y-0 group-focus-within:opacity-100 group-focus-within:pointer-events-auto group-focus-within:translate-y-0">
<div className="flex items-center gap-2">
<div className="flex gap-2 bg-slate-50/80 p-1.5 rounded-2xl border border-slate-100 flex-1 min-w-[420px]">
<div className="payment-filter-bar flex gap-2 p-1.5 rounded-2xl flex-1 min-w-[420px]">
<select value={selectedRev} onChange={e => {setSelectedRev(e.target.value); setSelectedD1('전체'); setSelectedD2('전체'); setSelectedProject('전체');}} className="filter-select flex-1">
<option value="전체">대분류 전체</option>
{Object.keys(viewData.hierarchy)
@@ -1209,7 +1209,7 @@ const App = () => {
className="filter-select flex-[1.1]"
/>
</div>
<button onClick={() => {setSelectedRev('전체'); setSelectedD1('전체'); setSelectedD2('전체'); setSelectedProject('전체'); setProjectSearch('');}} className="p-1.5 bg-white rounded-xl border border-slate-200 text-slate-400 hover:text-indigo-600 transition-all shadow-sm shrink-0"><RefreshCw size={14}/></button>
<button onClick={() => {setSelectedRev('전체'); setSelectedD1('전체'); setSelectedD2('전체'); setSelectedProject('전체'); setProjectSearch('');}} className="payment-reset-btn p-1.5 rounded-xl transition-all shadow-sm shrink-0"><RefreshCw size={14}/></button>
</div>
</div>
</div>
@@ -1226,17 +1226,17 @@ const App = () => {
<col style={{ width: '23%' }} />
<col style={{ width: '26%' }} />
</colgroup>
<thead className="bg-slate-50/80">
<tr className="text-[11px] font-black text-slate-400 uppercase tracking-widest border-b border-slate-100">
<thead className="payment-table-head">
<tr className="text-[12px] font-extrabold uppercase tracking-widest payment-table-head-row">
<th className="px-4 py-3 whitespace-nowrap">대분류</th>
<th className="px-4 py-3 whitespace-nowrap">중분류</th>
<th className="px-4 py-3 whitespace-nowrap">소분류</th>
<th className="px-4 py-3 whitespace-nowrap">{viewData.isAllFiltersOff ? '' : '프로젝트명'}</th>
<th className="px-4 py-3 whitespace-nowrap">프로젝트명</th>
<th className="px-4 py-3 text-right whitespace-nowrap">수입(매출)</th>
<th className="px-4 py-3 text-right whitespace-nowrap">지출 합계</th>
<th className="px-4 py-3 whitespace-nowrap text-center">
<div className="text-[11px] font-black text-slate-500 mb-1 text-center">지출 구성비</div>
<div className="grid grid-cols-5 text-[10px] font-black text-slate-600 normal-case tracking-normal">
<div className="text-[11px] font-black payment-subhead mb-1 text-center">지출 구성비</div>
<div className="grid grid-cols-5 text-[10px] font-black payment-subhead normal-case tracking-normal">
<span className="py-1 text-center">인건비</span>
<span className="py-1 text-center">출장비</span>
<span className="py-1 text-center">복리후생비</span>
@@ -1250,7 +1250,7 @@ const App = () => {
<tbody className="text-[13px] font-bold">
{viewData.finalDisplayList.length === 0 && (
<tr>
<td colSpan={8} className="px-4 py-12 text-center text-slate-400 font-bold">표시할 데이터가 없습니다.</td>
<td colSpan={8} className="px-4 py-12 text-center payment-empty font-bold">표시할 데이터가 없습니다.</td>
</tr>
)}
{viewData.finalDisplayList.map((item, idx) => {
@@ -1259,16 +1259,16 @@ const App = () => {
return (
<tr
key={`subtotal-${idx}`}
className={`h-12 border-y ${isGrandTotal ? 'bg-indigo-100 border-indigo-300 shadow-[inset_0_1px_0_rgba(99,102,241,0.35)]' : 'bg-amber-50 border-amber-200'}`}
className={`h-12 border-y ${isGrandTotal ? 'payment-subtotal payment-subtotal-grand shadow-[inset_0_1px_0_rgba(33,70,52,0.18)]' : 'payment-subtotal payment-subtotal-mid'}`}
>
<td colSpan={item.labelColSpan || 4} className={`px-4 py-3 whitespace-nowrap ${isGrandTotal ? 'text-indigo-900 text-[14px] font-extrabold' : 'text-amber-900 font-black'}`}>
<td colSpan={item.labelColSpan || 4} className={`px-4 py-3 whitespace-nowrap ${isGrandTotal ? 'payment-subtotal-label-grand text-[14px] font-extrabold' : 'payment-subtotal-label-mid font-black'}`}>
{item.subtotalLabel}
</td>
<td className={`px-4 py-3 text-right font-black whitespace-nowrap ${isGrandTotal ? 'text-indigo-800 text-[14px]' : 'text-amber-800'}`}>{formatWonDash(item.income)}</td>
<td className={`px-4 py-3 text-right font-black whitespace-nowrap ${isGrandTotal ? 'text-indigo-900 text-[14px]' : 'text-amber-900'}`}>{formatWonRoundedDash(item.total)}</td>
<td className={`px-4 py-3 text-right font-black whitespace-nowrap ${isGrandTotal ? 'payment-subtotal-income-grand text-[14px]' : 'payment-subtotal-income-mid'}`}>{formatWonDash(item.income)}</td>
<td className={`px-4 py-3 text-right font-black whitespace-nowrap ${isGrandTotal ? 'payment-subtotal-total-grand text-[14px]' : 'payment-subtotal-total-mid'}`}>{formatWonRoundedDash(item.total)}</td>
<td className="px-4 py-3">{renderCostBreakdownTable(item.costBreakdown)}</td>
<td className="px-4 py-3">
<div className={`h-2.5 rounded-full overflow-hidden flex shadow-inner ${isGrandTotal ? 'bg-indigo-200/80' : 'bg-amber-100'}`}>
<div className={`h-2.5 rounded-full overflow-hidden flex shadow-inner ${isGrandTotal ? 'payment-progress-track-grand' : 'payment-progress-track-mid'}`}>
{Object.entries(item.positionBreakdown || {})
.sort(([a], [b]) => (positionOrder[a] || 99) - (positionOrder[b] || 99) || a.localeCompare(b))
.map(([pos, val]) => {
@@ -1284,12 +1284,12 @@ const App = () => {
}
return (
<tr key={`row-${idx}`} className="h-12 hover:bg-indigo-50/30 transition-all border-b border-slate-50 group">
<tr key={`row-${idx}`} className="payment-data-row h-12 transition-all border-b group">
{item.d1Span > 0 && (
<td
rowSpan={item.d1Span}
onClick={() => handleD1Click(item.d1)}
className={`px-3 py-3 border-r border-slate-100 align-middle font-black cursor-pointer transition-colors whitespace-normal ${selectedRev === item.d1 ? 'bg-slate-200 text-slate-900' : 'bg-white text-slate-900 hover:bg-slate-50 hover:text-slate-900'}`}
className={`px-3 py-3 payment-axis-cell align-middle font-black cursor-pointer transition-colors whitespace-normal ${selectedRev === item.d1 ? 'payment-axis-cell-active' : 'payment-axis-cell-idle'}`}
>
<span className="block whitespace-normal break-words leading-tight" style={twoLineClampStyle}>{item.d1}</span>
</td>
@@ -1298,7 +1298,7 @@ const App = () => {
<td
rowSpan={item.d2Span}
onClick={() => handleD2Click(item.d1, item.d2)}
className={`px-3 py-3 border-r border-slate-100 align-middle font-black cursor-pointer transition-colors whitespace-normal ${selectedRev === item.d1 && selectedD1 === item.d2 ? 'bg-slate-200 text-slate-900' : 'bg-white text-slate-900 hover:bg-slate-50 hover:text-slate-900'}`}
className={`px-3 py-3 payment-axis-cell align-middle font-black cursor-pointer transition-colors whitespace-normal ${selectedRev === item.d1 && selectedD1 === item.d2 ? 'payment-axis-cell-active' : 'payment-axis-cell-idle'}`}
>
<span className="block whitespace-normal break-words leading-tight" style={twoLineClampStyle}>{item.d2}</span>
</td>
@@ -1307,22 +1307,22 @@ const App = () => {
<td
rowSpan={item.d3Span}
onClick={() => handleD3Click(item.d1, item.d2, item.d3)}
className={`px-3 py-3 border-r border-slate-100 align-middle font-black cursor-pointer transition-colors whitespace-normal ${selectedRev === item.d1 && selectedD1 === item.d2 && selectedD2 === item.d3 ? 'bg-slate-200 text-slate-900' : 'bg-white text-slate-900 hover:bg-slate-50 hover:text-slate-900'}`}
className={`px-3 py-3 payment-axis-cell align-middle font-black cursor-pointer transition-colors whitespace-normal ${selectedRev === item.d1 && selectedD1 === item.d2 && selectedD2 === item.d3 ? 'payment-axis-cell-active' : 'payment-axis-cell-idle'}`}
>
<span className="block whitespace-normal break-words leading-tight" style={twoLineClampStyle}>{item.d3}</span>
</td>
)}
<td
onClick={() => { if (!viewData.isAllFiltersOff) handleD4Click(item.d1, item.d2, item.d3, item.name); }}
className={`px-4 py-3 text-slate-700 transition-colors ${viewData.isAllFiltersOff ? '' : 'truncate cursor-pointer hover:bg-indigo-50 hover:text-indigo-800'}`}
onClick={() => { handleD4Click(item.d1, item.d2, item.d3, item.name); }}
className="px-4 py-3 payment-project-cell font-extrabold truncate cursor-pointer transition-colors"
>
{viewData.isAllFiltersOff ? '\u00A0' : item.name}
{item.name}
</td>
<td className="px-4 py-3 text-right text-emerald-700 font-extrabold whitespace-nowrap">{formatWonDash(item.income)}</td>
<td className="px-4 py-3 text-right text-rose-700 font-extrabold whitespace-nowrap">{formatWonRoundedDash(item.total)}</td>
<td className="px-4 py-3 text-right payment-income font-extrabold whitespace-nowrap">{formatWonDash(item.income)}</td>
<td className="px-4 py-3 text-right payment-expense font-extrabold whitespace-nowrap">{formatWonRoundedDash(item.total)}</td>
<td className="px-4 py-3">{renderCostBreakdownTable(item.costBreakdown)}</td>
<td className="px-4 py-3">
<div className="h-2.5 bg-slate-100 rounded-full overflow-hidden flex shadow-inner">
<div className="h-2.5 payment-progress-track rounded-full overflow-hidden flex shadow-inner">
{Object.entries(item.positionBreakdown || {})
.sort(([a], [b]) => (positionOrder[a] || 99) - (positionOrder[b] || 99) || a.localeCompare(b))
.map(([pos, val]) => {
@@ -1343,8 +1343,8 @@ const App = () => {
{/* 하단 상세 차트 */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 pb-12">
<div className="lg:col-span-5 bg-white p-8 rounded-[40px] shadow-sm border border-slate-100 min-h-[480px] flex flex-col">
<h3 className="text-lg font-black mb-4 flex items-center gap-3"><Target className="text-indigo-600"/> 지출 구성 상세</h3>
<div className="lg:col-span-5 payment-panel p-8 rounded-[40px] min-h-[480px] flex flex-col">
<h3 className="text-lg font-black mb-4 flex items-center gap-3"><Target className="payment-icon-accent"/> 지출 구성 상세</h3>
<div className="flex-1">
{viewData.categoryData.length > 0 ? (
<div className="h-full flex flex-col gap-5">
@@ -1354,9 +1354,9 @@ const App = () => {
className="relative h-56 w-56 rounded-full"
style={{ background: buildDonutGradient(viewData.categoryData) }}
>
<div className="absolute inset-11 rounded-full bg-white border border-slate-100 flex flex-col items-center justify-center">
<span className="text-[12px] font-black text-slate-500">총 지출</span>
<span className="text-[15px] font-black text-slate-900">
<div className="absolute inset-11 payment-donut-center rounded-full flex flex-col items-center justify-center">
<span className="text-[12px] font-black payment-subhead">총 지출</span>
<span className="text-[15px] font-black payment-strong">
{formatWon(viewData.categoryData.reduce((sum, item) => sum + (item.value || 0), 0))}
</span>
</div>
@@ -1374,13 +1374,13 @@ const App = () => {
if (!isSelectable) return;
setSelectedExpenseDetailCategory((prev) => (prev === item.name ? '' : item.name));
}}
className={`flex items-center justify-between gap-2 text-[13px] font-bold text-left rounded-lg px-2 py-1.5 transition-colors ${isSelectable ? 'hover:bg-slate-50 cursor-pointer' : 'cursor-default'} ${isSelected ? 'bg-indigo-50' : ''}`}
className={`payment-cost-row flex items-center justify-between gap-2 text-[13px] font-bold text-left rounded-lg px-2 py-1.5 transition-colors ${isSelectable ? 'cursor-pointer' : 'cursor-default'} ${isSelected ? 'payment-cost-row-active' : ''}`}
>
<span className="flex items-center gap-2 text-slate-600 truncate">
<span className="flex items-center gap-2 payment-muted truncate">
<span className="inline-block w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: getCostColor(item.name) }}></span>
{item.name} ({item.ratio}%)
</span>
<span className="text-slate-900">{formatWon(item.value)}</span>
<span className="payment-strong">{formatWon(item.value)}</span>
</button>
);
})}
@@ -1388,20 +1388,20 @@ const App = () => {
</div>
{viewData.isAllFiltersOff && (
<div className="w-full mt-4 text-[12px] text-slate-400 font-bold text-center">
<div className="w-full mt-4 text-[12px] payment-empty font-bold text-center">
상세 내역은 필터 적용 시 표시됩니다.
</div>
)}
{!viewData.isAllFiltersOff && selectedExpenseDetailCategory && selectedExpenseDetailCategory !== '인건비' && (
<div className="w-full mt-5 pt-4 border-t border-slate-100">
<div className="text-[12px] font-black text-slate-600 mb-2">
<div className="w-full mt-5 pt-4 payment-divider-top">
<div className="text-[12px] font-black payment-subhead mb-2">
{selectedExpenseDetailCategory} 지출 구성 상세 내역
</div>
{(viewData.expenseDetailByCategory?.[selectedExpenseDetailCategory] || []).length > 0 ? (
<div className="max-h-56 overflow-y-auto rounded-lg border border-slate-100 custom-scrollbar">
<div className="max-h-56 overflow-y-auto rounded-lg payment-mini-table-shell custom-scrollbar">
<table className="w-full text-[12px] table-fixed border-collapse">
<thead className="bg-slate-50 text-slate-500 font-black">
<thead className="payment-mini-table-head font-black">
<tr>
<th className="px-2 py-2 text-left w-[74px]">발행월</th>
<th className="px-2 py-2 text-left w-[88px]">발행일</th>
@@ -1412,7 +1412,7 @@ const App = () => {
</thead>
<tbody>
{(viewData.expenseDetailByCategory[selectedExpenseDetailCategory] || []).map((row, idx) => (
<tr key={`${selectedExpenseDetailCategory}-${idx}`} className="border-t border-slate-50 text-slate-700">
<tr key={`${selectedExpenseDetailCategory}-${idx}`} className="payment-mini-table-row">
<td className="px-2 py-1.5 whitespace-nowrap">{row.issueMonth || '-'}</td>
<td className="px-2 py-1.5 whitespace-nowrap">{row.issueDate || '-'}</td>
<td className="px-2 py-1.5 truncate">{row.summary || '-'}</td>
@@ -1424,21 +1424,21 @@ const App = () => {
</table>
</div>
) : (
<div className="text-[12px] text-slate-400 font-bold">표시할 전표 데이터가 없습니다.</div>
<div className="text-[12px] payment-empty font-bold">표시할 전표 데이터가 없습니다.</div>
)}
</div>
)}
</div>
) : (
<div className="h-full flex items-center justify-center text-slate-300 text-sm font-bold">표시할 지출 데이터가 없습니다.</div>
<div className="h-full flex items-center justify-center payment-empty text-sm font-bold">표시할 지출 데이터가 없습니다.</div>
)}
</div>
</div>
<div className="lg:col-span-7 bg-white p-8 rounded-[40px] shadow-sm border border-slate-100 flex flex-col h-[560px] overflow-hidden">
<div className="lg:col-span-7 payment-panel p-8 rounded-[40px] flex flex-col h-[560px] overflow-hidden">
<h3 className="text-lg font-black mb-4 flex items-center gap-3 shrink-0">
<UserCheck className="text-indigo-600"/> 직급별 인원 투입 상세
<span className="ml-1 text-[11px] font-black text-indigo-600 bg-indigo-50 border border-indigo-100 px-2 py-1 rounded-lg">
<UserCheck className="payment-icon-accent"/> 직급별 인원 투입 상세
<span className="payment-mode-chip ml-1 text-[11px] font-black px-2 py-1 rounded-lg">
기준: {viewData.positionGroupMode}
</span>
</h3>
@@ -1453,33 +1453,33 @@ const App = () => {
})
.map(([pName, positions]) => (
<div key={pName} className="mb-8 last:mb-0">
<div className="bg-slate-900 px-4 py-1.5 rounded-xl text-[12px] font-black text-white mb-4 sticky top-0 z-10">{pName}</div>
<div className="payment-group-title px-4 py-1.5 rounded-xl text-[12px] font-black mb-4 sticky top-0 z-10">{pName}</div>
<div className="grid grid-cols-1 gap-3">
{Object.entries(positions)
.sort(([a], [b]) => (positionOrder[a] || 99) - (positionOrder[b] || 99) || a.localeCompare(b))
.map(([pos, data]) => {
const style = getPositionStyle(pos);
return (
<div key={pos} className={`bg-white border ${style.border} rounded-[28px] p-5 flex items-center gap-6 hover:shadow-md transition-all`}>
<div key={pos} className={`payment-position-card border ${style.border} rounded-[28px] p-5 flex items-center gap-6 transition-all`}>
<div className={`flex items-center gap-3 w-1/4 shrink-0 px-4 py-2 rounded-2xl ${style.bg} border ${style.border}`}>
<div className={`w-3 h-3 rounded-full ${style.icon} shadow-sm`}></div>
<div className={`text-[14px] font-black ${style.text}`}>{pos}</div>
</div>
<div className="flex-1 grid grid-cols-2 gap-8 border-l border-slate-100 pl-8">
<div className="flex-1 grid grid-cols-2 gap-8 payment-divider-left pl-8">
<div>
<div className="text-[11px] text-slate-400 font-black uppercase mb-1">Estimated Cost</div>
<div className="text-[16px] font-black text-indigo-600 font-mono">₩{Math.round(data.labor).toLocaleString()}</div>
<div className="text-[11px] payment-empty font-black uppercase mb-1">Estimated Cost</div>
<div className="text-[16px] font-black payment-icon-accent font-mono">₩{Math.round(data.labor).toLocaleString()}</div>
</div>
<div>
<div className="text-[11px] text-slate-400 font-black uppercase mb-1">Hours & Count</div>
<div className="text-[16px] font-black text-slate-900">{data.hrs.toFixed(2)}h <span className="text-slate-300 mx-1">|</span> {data.names.size}명</div>
<div className="text-[11px] payment-empty font-black uppercase mb-1">Hours & Count</div>
<div className="text-[16px] font-black payment-strong">{data.hrs.toFixed(2)}h <span className="payment-divider-mark mx-1">|</span> {data.names.size}명</div>
</div>
</div>
<div className="w-1/3 min-w-[260px] border-l border-slate-100 pl-4">
<div className="w-1/3 min-w-[260px] payment-divider-left pl-4">
<div className="overflow-x-auto overflow-y-hidden custom-scrollbar">
<div className="grid grid-rows-2 grid-flow-col auto-cols-max gap-x-1.5 gap-y-1.5 min-w-max pb-1">
{Array.from(data.names).map(name => (
<span key={name} className="px-2 py-0.5 bg-slate-50 text-slate-500 rounded-lg text-[11px] font-bold border border-slate-100 whitespace-nowrap">{name}</span>
<span key={name} className="payment-name-chip px-2 py-0.5 rounded-lg text-[11px] font-bold whitespace-nowrap">{name}</span>
))}
</div>
</div>
@@ -1491,7 +1491,7 @@ const App = () => {
</div>
))
) : (
<div className="h-full flex flex-col items-center justify-center text-slate-300 gap-3">
<div className="h-full flex flex-col items-center justify-center payment-empty gap-3">
<Info size={40} />
<span className="text-sm font-bold">표시할 데이터가 없습니다.</span>
</div>
@@ -1500,18 +1500,18 @@ const App = () => {
</div>
</div>
<section className="bg-white rounded-[35px] shadow-sm border border-slate-100 overflow-hidden">
<div className="px-6 py-4 border-b border-slate-50 flex items-center justify-between gap-4">
<h3 className="text-lg font-black flex items-center gap-3"><List size={18} className="text-indigo-600" /> 프로젝트별 Activity 분석</h3>
<section className="payment-panel rounded-[35px] overflow-hidden">
<div className="payment-panel-head px-6 py-4 flex items-center justify-between gap-4">
<h3 className="text-lg font-black flex items-center gap-3"><List size={18} className="payment-icon-accent" /> 프로젝트별 Activity 분석</h3>
</div>
<div className="p-6">
{viewData.projectActivityList.length > 0 ? (
<div className="space-y-4">
{viewData.projectActivityList.map((project) => (
<div key={`activity-${project.projectName}`} className="border border-slate-200 rounded-2xl overflow-hidden">
<div className="px-4 py-3 bg-slate-50 border-b border-slate-200 flex items-center justify-between gap-3">
<div className="text-[14px] font-black text-slate-900 truncate">{project.projectName}</div>
<div className="text-[12px] font-black text-indigo-700 whitespace-nowrap">총 {formatHours(project.totalHours)}h · {project.workerCount}명</div>
<div key={`activity-${project.projectName}`} className="payment-activity-card border rounded-2xl overflow-hidden">
<div className="payment-activity-card-head px-4 py-3 flex items-center justify-between gap-3">
<div className="text-[14px] font-black payment-strong truncate">{project.projectName}</div>
<div className="text-[12px] font-black payment-icon-accent whitespace-nowrap">총 {formatHours(project.totalHours)}h · {project.workerCount}명</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse table-fixed">
@@ -1521,8 +1521,8 @@ const App = () => {
<col style={{ width: '90px' }} />
<col style={{ width: 'auto' }} />
</colgroup>
<thead className="bg-slate-50/70 border-b border-slate-100">
<tr className="text-[11px] font-black text-slate-500 uppercase tracking-wide">
<thead className="payment-mini-table-head border-b">
<tr className="text-[11px] font-black payment-subhead uppercase tracking-wide">
<th className="px-3 py-2 whitespace-nowrap">Activity</th>
<th className="px-3 py-2 text-right whitespace-nowrap">투입시간</th>
<th className="px-3 py-2 text-right whitespace-nowrap">투입인원</th>
@@ -1531,14 +1531,14 @@ const App = () => {
</thead>
<tbody>
{project.activities.map((activity) => (
<tr key={`${project.projectName}-${activity.activityName}`} className="border-b border-slate-50 last:border-b-0">
<td className="px-3 py-2 text-[12px] font-black text-slate-800 whitespace-nowrap truncate">{activity.activityName}</td>
<td className="px-3 py-2 text-[12px] font-black text-right text-indigo-700 whitespace-nowrap">{formatHours(activity.hours)}h</td>
<td className="px-3 py-2 text-[12px] font-black text-right text-slate-700 whitespace-nowrap">{activity.workerCount}명</td>
<td className="px-3 py-2 text-[12px] text-slate-600">
<tr key={`${project.projectName}-${activity.activityName}`} className="payment-mini-table-row last:border-b-0">
<td className="px-3 py-2 text-[12px] font-black payment-strong whitespace-nowrap truncate">{activity.activityName}</td>
<td className="px-3 py-2 text-[12px] font-black text-right payment-icon-accent whitespace-nowrap">{formatHours(activity.hours)}h</td>
<td className="px-3 py-2 text-[12px] font-black text-right payment-muted whitespace-nowrap">{activity.workerCount}명</td>
<td className="px-3 py-2 text-[12px] payment-muted">
<div className="flex flex-wrap gap-1.5">
{activity.members.map((m) => (
<span key={`${activity.activityName}-${m.name}`} className="px-2 py-0.5 rounded-lg bg-slate-50 border border-slate-100 text-[11px] font-bold text-slate-600 whitespace-nowrap">
<span key={`${activity.activityName}-${m.name}`} className="payment-name-chip px-2 py-0.5 rounded-lg text-[11px] font-bold whitespace-nowrap">
{m.name} ({formatHours(m.hours)}h)
</span>
))}
@@ -1553,24 +1553,46 @@ const App = () => {
))}
</div>
) : (
<div className="py-10 text-center text-slate-300 text-sm font-bold">표시할 Activity 데이터가 없습니다.</div>
<div className="py-10 text-center payment-empty text-sm font-bold">표시할 Activity 데이터가 없습니다.</div>
)}
</div>
</section>
</div>
<style>{`
@import url('/design-tokens.css');
@import url('/design-patterns.css');
@import url('https://fonts.googleapis.com/css2?family=Pretendard:wght@400;600;700;900&display=swap');
body { font-family: 'Pretendard', sans-serif; letter-spacing: -0.025em; -webkit-font-smoothing: antialiased; background-color: #f8fafc; }
body { font-family: 'Pretendard', sans-serif; letter-spacing: -0.025em; -webkit-font-smoothing: antialiased; background-color: var(--ds-bg); color: var(--ds-ink); }
.payment-theme { color: var(--ds-ink); }
.payment-kpi-income, .payment-kpi-hours { color: var(--ds-brand-soft); }
.payment-kpi-labor, .payment-kpi-others { color: var(--ds-text-soft); }
.payment-kpi-travel { color: var(--ds-status-danger); }
.payment-kpi-welfare { color: var(--ds-status-warning); }
.payment-filter-pop { border: 1px solid var(--ds-line); background: rgba(255,250,243,0.98); }
.payment-subtotal { border-color: var(--ds-line); }
.payment-subtotal-grand { background: #efe2ca; }
.payment-subtotal-mid { background: #f6e6c9; }
.payment-subtotal-label-grand, .payment-subtotal-total-grand { color: var(--ds-brand-deep); }
.payment-subtotal-income-grand { color: var(--ds-brand-soft); }
.payment-subtotal-label-mid, .payment-subtotal-total-mid { color: #9a6422; }
.payment-subtotal-income-mid { color: #7b5a20; }
.payment-donut-center { background: rgba(255,250,243,0.98); border: 1px solid var(--ds-line-soft); }
.payment-cost-row:hover { background: rgba(234,220,196,0.34); }
.payment-cost-row-active { background: rgba(242,196,132,0.18); }
.payment-position-card { background: rgba(255,250,243,0.96); box-shadow: var(--ds-shadow-soft); }
.payment-activity-card { border-color: var(--ds-line-soft); }
.payment-activity-card-head { background: rgba(246,237,221,0.68); border-bottom: 1px solid var(--ds-line-soft); }
.filter-select {
background-color: transparent; border: none; padding: 0.35rem 1.6rem 0.35rem 0.5rem; font-size: 10px; font-weight: 800;
outline: none; appearance: none; cursor: pointer; transition: all 0.2s;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%2394a3b8'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'%3E%3C/path%3E%3C/svg%3E");
color: var(--ds-ink);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%2366756d'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'%3E%3C/path%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: right 0.4rem center; background-size: 0.6rem;
}
.filter-select:hover { color: #6366f1; background-color: white; border-radius: 8px; }
.filter-select:hover { color: var(--ds-brand-soft); background-color: rgba(255,255,255,0.98); border-radius: 8px; }
.custom-scrollbar::-webkit-scrollbar { width: 4px; height: 4px; }
.custom-scrollbar::-webkit-scrollbar-thumb { background: #e2e8f0; border-radius: 10px; }
.custom-scrollbar::-webkit-scrollbar-thumb { background: var(--ds-line); border-radius: 10px; }
`}</style>
</div>
);

View File

@@ -1,38 +1,41 @@
@import url("/design-tokens.css?v=20260401-01");
@import url("/design-patterns.css?v=20260401-01");
:root {
--font-sans: "Pretendard", sans-serif;
--font-sans: var(--ds-font-sans);
--color-bg: #f1f5f9;
--color-bg-soft: #eef2ff;
--color-surface: #ffffff;
--color-surface-soft: rgba(255, 255, 255, 0.88);
--color-surface-strong: #e2e8f0;
--color-text: #1e293b;
--color-text-soft: #475569;
--color-text-muted: #64748b;
--color-border: #cbd5e1;
--color-border-soft: rgba(148, 163, 184, 0.3);
--color-header: #1e293b;
--color-header-soft: #334155;
--color-accent: #4f46e5;
--color-accent-soft: #e0e7ff;
--color-accent-strong: #4338ca;
--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: 8px;
--radius-md: 12px;
--radius-lg: 18px;
--radius-xl: 24px;
--radius-pill: 999px;
--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: 0 4px 14px rgba(15, 23, 42, 0.08);
--shadow-card: 0 18px 44px rgba(15, 23, 42, 0.12);
--shadow-float: 0 18px 36px rgba(79, 70, 229, 0.16);
--shadow-soft: var(--ds-shadow-soft);
--shadow-card: var(--ds-shadow-card);
--shadow-float: var(--ds-shadow-float);
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--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);
}
* {
@@ -46,15 +49,13 @@ body {
min-height: 100%;
font-family: var(--font-sans);
color: var(--color-text);
background:
radial-gradient(circle at top left, rgba(79, 70, 229, 0.12), transparent 22%),
radial-gradient(circle at bottom right, rgba(148, 163, 184, 0.18), transparent 28%),
var(--color-bg);
background: var(--ds-bg-gradient);
}
body {
min-height: 100vh;
overflow: hidden;
background: var(--ds-bg-gradient);
}
button,
@@ -92,18 +93,18 @@ a {
.ui-button-secondary {
border: 1px solid var(--color-border-soft);
color: var(--color-text);
background: rgba(255, 255, 255, 0.72);
background: var(--ds-surface-tint);
}
.ui-input {
border: 1px solid var(--color-border-soft);
border-radius: var(--radius-pill);
background: rgba(255, 255, 255, 0.88);
background: var(--ds-surface-tint-strong);
color: var(--color-text);
outline: none;
}
.ui-input:focus {
border-color: rgba(79, 70, 229, 0.45);
box-shadow: 0 0 0 4px rgba(79, 70, 229, 0.08);
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

View File

@@ -4,6 +4,7 @@ let selectedDept = '전체';
let editingMembers = [];
let collapsedUnits = new Set();
let isListMode = false;
let isListDetailModal = false;
let emptyStateMessage = '서버에 조직 데이터가 없습니다. 상단의 업로드 버튼으로 초기 데이터를 넣어주세요.';
let photoPreviewObjectUrl = null;
let seatMapLayoutCache = null;
@@ -44,6 +45,15 @@ function cloneMembers(items) {
return JSON.parse(JSON.stringify(items));
}
function isRetiredLegacyMember(member) {
const workStatus = String(member?.['근무상태'] || '').trim();
return workStatus === '퇴직';
}
function getVisibleLegacyMembers(items) {
return (items || []).filter((member) => !isRetiredLegacyMember(member));
}
function getPhotoPlaceholder(name = '') {
return `https://via.placeholder.com/160?text=${encodeURIComponent(name || 'Profile')}`;
}
@@ -155,7 +165,7 @@ async function uploadProfilePhoto(file, memberName) {
}
function setMembers(items) {
members = items.map(toLegacyMember);
members = getVisibleLegacyMembers(items.map(toLegacyMember));
if (selectedDept !== '전체' && !members.some((member) => member['부서'] === selectedDept)) {
selectedDept = '전체';
}
@@ -476,6 +486,24 @@ function createNodeDOM(node, parentId) {
return nodeItem;
}
function applyMemberPlacementFromTarget(member, targetLevel, targetName, targetMember = null) {
const targetLevelIndex = levelOrder.indexOf(targetLevel);
if (targetLevelIndex === -1) {
return member;
}
for (let index = 0; index <= targetLevelIndex; index += 1) {
member[levelOrder[index]] = targetMember ? (targetMember[levelOrder[index]] || '') : (index === targetLevelIndex ? targetName : member[levelOrder[index]]);
}
if (!targetMember) {
member[targetLevel] = targetName;
}
for (let index = targetLevelIndex + 1; index < levelOrder.length; index += 1) {
member[levelOrder[index]] = '';
}
rebuildMemberPath(member);
return member;
}
function drawLines() {
const container = document.getElementById('tree-root');
const svg = document.getElementById('svg-canvas');
@@ -619,6 +647,11 @@ function render() {
deptBox.id = deptId;
deptBox.className = 'dept-box';
deptBox.setAttribute('data-level', '부서');
if (isAdmin) {
deptBox.ondragover = (event) => handleDragOver(event);
deptBox.ondragleave = (event) => handleDragLeave(event);
deptBox.ondrop = (event) => handleDrop(event, '부서', deptName);
}
deptBox.innerHTML = `<div class="dept-header ${isAdmin ? 'clickable-title' : ''} ${hasMembers ? 'has-members' : ''}" ${isAdmin ? `onclick="openOrgEditModal('부서', '${jsString(deptName)}')"` : ''}>${deptName} (${totalCount})</div>`;
if (hasMembers) {
@@ -818,6 +851,53 @@ function openAddModal(event) {
openModal(null);
}
function renderListViewShell(defaultDate) {
const modal = document.getElementById('modal');
modal.querySelector('.modal-content').classList.add('wide');
document.getElementById('modal-title').innerText = '인원 명단';
const fieldsArea = document.getElementById('modal-fields');
fieldsArea.className = 'flex flex-col w-full overflow-hidden';
fieldsArea.style.maxHeight = '75vh';
fieldsArea.style.overflowY = 'hidden';
fieldsArea.innerHTML = `
<div class="list-toolbar">
<div class="list-toolbar-row">
<div class="list-toolbar-group">
<button type="button" onclick="showCurrentListView()" class="list-mode-btn">현재 명단</button>
</div>
<div class="list-toolbar-divider" aria-hidden="true"></div>
<div class="list-toolbar-group list-date-group">
<input type="date" id="list-snapshot-date" value="${escapeHtml(defaultDate)}" class="list-date-input">
<button type="button" onclick="loadSnapshotListView()" class="list-mode-btn">기준일 조회</button>
</div>
<div class="list-toolbar-divider" aria-hidden="true"></div>
<div class="list-toolbar-group list-date-group">
<input type="date" id="list-compare-from" value="${escapeHtml(defaultDate)}" class="list-date-input">
<span class="list-date-separator">~</span>
<input type="date" id="list-compare-to" value="${escapeHtml(defaultDate)}" class="list-date-input">
<button type="button" onclick="loadCompareListView()" class="list-mode-btn">변경 비교</button>
</div>
</div>
<div class="list-toolbar-row">
<input type="text" id="list-search-input" placeholder="이름 또는 부서/팀 검색 (Enter 시 이동)" class="flex-1 bg-[#f6eddd] border-2 border-[#e0d0b4] p-3 rounded-xl text-sm outline-none font-bold text-[#1f2f25] focus:border-[#d68a3a] transition-all" onkeydown="if(event.key==='Enter') handleListSearch(this.value)">
<button type="button" onclick="handleListSearch(document.getElementById('list-search-input').value)" class="bg-[#214634] text-white px-5 rounded-xl font-bold text-sm">검색</button>
</div>
<div id="list-view-status" class="list-view-status"></div>
</div>
<div id="list-table-container" class="overflow-y-auto flex-1 border rounded-xl"></div>
`;
modal.style.display = 'flex';
}
function returnToListViewModal() {
if (!isListMode) {
return;
}
isListDetailModal = false;
renderListViewShell(listViewState.snapshotDate || getDefaultHistoryDate());
renderListViewModalContent();
}
function updateParentList() {
const type = document.getElementById('new-unit-type').value;
const parentSelect = document.getElementById('new-unit-parent');
@@ -851,18 +931,20 @@ function openUnitAddModal(event) {
</select>
</div>
<div class="col-span-2">
<label class="text-[11px] font-black text-slate-600 block">상위 위치 선택</label>
<select id="new-unit-parent" class="w-full bg-white p-3 rounded-xl border text-sm font-bold outline-none"></select>
<label class="member-form-label block">상위 위치 선택</label>
<select id="new-unit-parent" class="member-form-select"></select>
</div>
<div class="col-span-2">
<label class="text-[11px] font-black text-slate-600 block">신규 명칭 입력</label>
<input id="new-unit-name" placeholder="예: 신규개발팀" class="w-full bg-slate-50 p-3 rounded-xl border font-bold text-sm outline-none">
<label class="member-form-label block">신규 명칭 입력</label>
<input id="new-unit-name" placeholder="예: 신규개발팀" class="member-form-input">
</div>
`;
updateParentList();
document.getElementById('modal-footer-area').innerHTML = `
<button onclick="closeModal()" class="flex-1 bg-slate-100 py-3.5 rounded-xl font-bold text-sm">취소</button>
<button onclick="saveNewUnit()" class="flex-1 bg-indigo-600 text-white py-3.5 rounded-xl font-bold text-sm">저장</button>
<div class="modal-footer-actions">
<button onclick="closeModal()" class="modal-btn modal-btn-cancel">취소</button>
<button onclick="saveNewUnit()" class="modal-btn modal-btn-save">저장</button>
</div>
`;
modal.style.display = 'flex';
}
@@ -925,14 +1007,16 @@ function openOrgEditModal(level, oldName) {
fieldsArea.style.maxHeight = 'none';
fieldsArea.innerHTML = `
<div class="col-span-2">
<label class="text-[11px] font-black text-slate-400 block">새로운 ${level} 명칭</label>
<input id="new-org-name" value="${oldName}" class="w-full bg-slate-50 p-3 rounded-xl border font-bold text-sm outline-none">
<label class="member-form-label block">새로운 ${level} 명칭</label>
<input id="new-org-name" value="${oldName}" class="member-form-input">
</div>
`;
document.getElementById('modal-footer-area').innerHTML = `
<button onclick="deleteOrg('${jsString(level)}', '${jsString(oldName)}')" class="bg-red-50 text-red-600 py-3.5 px-6 rounded-xl font-bold text-sm border border-red-100 hover:bg-red-100 transition-colors">삭제</button>
<button onclick="closeModal()" class="flex-1 bg-slate-100 py-3.5 rounded-xl font-bold text-sm">취소</button>
<button onclick="saveOrgName('${jsString(level)}', '${jsString(oldName)}')" class="flex-1 bg-indigo-600 text-white py-3.5 rounded-xl font-bold text-sm">저장</button>
<button onclick="deleteOrg('${jsString(level)}', '${jsString(oldName)}')" class="modal-btn modal-btn-delete">삭제</button>
<div class="modal-footer-actions">
<button onclick="closeModal()" class="modal-btn modal-btn-cancel">취소</button>
<button onclick="saveOrgName('${jsString(level)}', '${jsString(oldName)}')" class="modal-btn modal-btn-save">저장</button>
</div>
`;
modal.style.display = 'flex';
}
@@ -1014,11 +1098,11 @@ function handlePhotoFileChange(event) {
function renderSeatPreviewCard(seatInfo) {
const assigned = Boolean(seatInfo?.assigned);
const safeLabel = escapeHtml(seatInfo?.seatLabel || '');
const seatMapLabel = String(seatInfo?.seatMapName || '자리배치도').replace(/\s*자리배치도\s*$/u, '').trim() || '사무실';
const safeSeatMapName = escapeHtml(seatInfo?.seatMapName || '자리배치도');
const safeSlotKey = escapeHtml(seatInfo?.slotKey || '');
const safeOfficeLabel = escapeHtml(seatMapLabel);
const badge = assigned
? `<span class="seat-preview-badge">${safeLabel || '배치완료'}</span>`
? `<span class="seat-preview-badge">${safeOfficeLabel}</span>`
: '<span class="seat-preview-badge seat-preview-badge-muted">미배치</span>';
const body = assigned
? `
@@ -1039,11 +1123,11 @@ function renderSeatPreviewCard(seatInfo) {
`;
return `
<div class="seat-preview-card">
<div class="seat-preview-card${assigned ? ' is-assigned' : ''}">
<div class="seat-preview-head">
<div>
<strong>재석위치</strong>
<p>${assigned ? '현재 자리배치도 기준으로 배치된 좌석 정보를 표시합니다.' : '현재 자리배치도에서 배치된 좌석이 없습니다.'}</p>
<p>${assigned ? '현재 배치된 사무실과 좌석 위치를 강조해서 표시합니다.' : '현재 자리배치도에서 배치된 좌석이 없습니다.'}</p>
</div>
${badge}
</div>
@@ -1093,12 +1177,8 @@ function switchModalTab(tab) {
const isBasic = tab === 'basic';
document.getElementById('modal-sec-basic').classList.toggle('hidden', !isBasic);
document.getElementById('modal-sec-org').classList.toggle('hidden', isBasic);
document.getElementById('modal-tab-basic').className = isBasic
? 'flex-1 py-3 font-bold border-b-2 border-indigo-600 text-indigo-600 text-sm transition-all'
: 'flex-1 py-3 font-bold border-b-2 border-transparent text-slate-400 text-sm transition-all';
document.getElementById('modal-tab-org').className = !isBasic
? 'flex-1 py-3 font-bold border-b-2 border-indigo-600 text-indigo-600 text-sm transition-all'
: 'flex-1 py-3 font-bold border-b-2 border-transparent text-slate-400 text-sm transition-all';
document.getElementById('modal-tab-basic').className = isBasic ? 'member-modal-tab is-active' : 'member-modal-tab';
document.getElementById('modal-tab-org').className = !isBasic ? 'member-modal-tab is-active' : 'member-modal-tab';
}
function openModal(id) {
@@ -1115,14 +1195,14 @@ function openModal(id) {
fieldsArea.style.maxHeight = 'none';
fieldsArea.innerHTML = `
<div class="member-detail-top-row">
<div class="relative w-32 h-32 rounded-full overflow-hidden border-4 border-indigo-100 shadow-lg">
<div class="relative w-32 h-32 rounded-full overflow-hidden border-4 shadow-lg" style="border-color: var(--color-surface-strong);">
<img src="${member['사진'] || 'https://via.placeholder.com/120?text=Profile'}" class="w-full h-full object-cover">
</div>
<div class="member-detail-summary">
<div>
<h2 class="text-2xl font-black text-slate-800">${member['이름'] || ''}</h2>
<p class="text-indigo-600 font-bold">${member['직급'] || '-'} / ${member['직책'] || '팀원'}</p>
<p class="text-slate-400 text-xs mt-1 font-medium">${(member._path || []).map((path) => path.name).join(' > ')}</p>
<p class="font-bold" style="color: var(--color-header);">${member['직급'] || '-'} / ${member['직책'] || '팀원'}</p>
<p class="text-xs mt-1 font-medium" style="color: var(--color-text-muted);">${(member._path || []).map((path) => path.name).join(' > ')}</p>
</div>
<div class="member-inline-info-grid">
<div class="member-inline-info-card">
@@ -1140,7 +1220,7 @@ function openModal(id) {
<div id="member-seat-preview">${renderSeatPreviewCard({ assigned: false, seatLabel: member['자리위치'] || '', seatMapName: '자리배치도', slotKey: '' })}</div>
</div>
`;
footer.innerHTML = '<button onclick="closeModal()" class="w-full bg-slate-800 text-white py-4 rounded-xl font-bold text-sm shadow-lg">닫기</button>';
footer.innerHTML = '<button onclick="closeModal()" class="modal-btn modal-btn-close">닫기</button>';
modal.style.display = 'flex';
hydrateMemberSeatPreview(member);
return;
@@ -1150,7 +1230,8 @@ function openModal(id) {
modal.querySelector('.modal-content').classList.add('wide');
fieldsArea.className = 'flex flex-col w-full';
fieldsArea.style.maxHeight = 'none';
fieldsArea.style.overflowY = 'visible';
fieldsArea.style.overflowY = 'auto';
isListDetailModal = isListMode;
const sourceValues = isListMode ? editingMembers : members;
let orgFields = '<div id="modal-sec-org" class="hidden grid grid-cols-2 gap-3 modal-form-grid">';
@@ -1159,14 +1240,14 @@ function openModal(id) {
const currentValue = member[field] || '';
orgFields += `
<div class="col-span-1">
<label class="text-[11px] font-black text-slate-600 block">${field}</label>
<select id="sel-${field}" onchange="toggleManualInput('${field}')" class="w-full bg-white p-3 rounded-xl border text-sm font-bold text-slate-700 outline-none">
<option value="__NEW__" class="text-indigo-600 font-bold">+ 직접/신규 입력</option>
<label class="member-form-label block">${field}</label>
<select id="sel-${field}" onchange="toggleManualInput('${field}')" class="member-form-select">
<option value="__NEW__" class="member-form-new-option">+ 직접/신규 입력</option>
<option value="__NONE__" ${currentValue === '' ? 'selected' : ''}>-- 선택 안 함 --</option>
${uniqueValues.map((value) => `<option value="${value}" ${value === currentValue ? 'selected' : ''}>${value}</option>`).join('')}
</select>
<div id="manual-${field}" class="hidden mt-2">
<input id="input-${field}" placeholder="직접 입력" class="w-full bg-indigo-50 p-3 rounded-xl border-indigo-200 border text-sm font-bold">
<div id="manual-${field}" class="hidden member-form-manual">
<input id="input-${field}" placeholder="직접 입력" class="member-form-input">
</div>
</div>
`;
@@ -1175,39 +1256,41 @@ function openModal(id) {
const isFlexible = member['근무시간'] === '유연근무제';
orgFields += `
<div class="col-span-1">
<label class="text-[11px] font-black text-slate-600 block">근무 상태</label>
<select id="m-status" class="w-full bg-white p-3 rounded-xl border text-sm font-bold outline-none">
<option value="근무" ${member['근무상태'] !== '휴직' ? 'selected' : ''}>근무</option>
<label class="member-form-label block">근무 상태</label>
<select id="m-status" class="member-form-select">
<option value="근무" ${member['근무상태'] !== '휴직' && member['근무상태'] !== '퇴직' ? 'selected' : ''}>근무</option>
<option value="휴직" ${member['근무상태'] === '휴직' ? 'selected' : ''}>휴직</option>
<option value="퇴직" ${member['근무상태'] === '퇴직' ? 'selected' : ''}>퇴직</option>
</select>
</div>
<div class="col-span-1">
<label class="text-[11px] font-black text-slate-600 block">근무 시간</label>
<select id="m-worktime" onchange="toggleFlexibleTime(this.value)" class="w-full bg-white p-3 rounded-xl border text-sm font-bold outline-none">
<label class="member-form-label block">근무 시간</label>
<select id="m-worktime" onchange="toggleFlexibleTime(this.value)" class="member-form-select">
<option value="09~18" ${!isFlexible ? 'selected' : ''}>09~18</option>
<option value="유연근무제" ${isFlexible ? 'selected' : ''}>유연근무제</option>
</select>
<div id="flexible-time-area" class="${isFlexible ? '' : 'hidden'} mt-2 flex items-center gap-2">
<input type="time" id="m-work-start" value="${member['유연근무_시작'] || '09:00'}" class="bg-indigo-50 p-2 rounded-lg border border-indigo-100 text-xs font-bold w-full">
<input type="time" id="m-work-end" value="${member['유연근무_종료'] || '18:00'}" class="bg-indigo-50 p-2 rounded-lg border border-indigo-100 text-xs font-bold w-full">
<input type="time" id="m-work-start" value="${member['유연근무_시작'] || '09:00'}" class="member-form-time">
<input type="time" id="m-work-end" value="${member['유연근무_종료'] || '18:00'}" class="member-form-time">
</div>
</div>
</div>`;
fieldsArea.innerHTML = `
<div class="flex border-b mb-6 sticky top-0 bg-white z-10">
<button id="modal-tab-basic" onclick="switchModalTab('basic')" class="flex-1 py-3 font-bold border-b-2 border-indigo-600 text-indigo-600 text-sm transition-all">기본 정보</button>
<button id="modal-tab-org" onclick="switchModalTab('org')" class="flex-1 py-3 font-bold border-b-2 border-transparent text-slate-400 text-sm transition-all">조직 및 근무</button>
<div class="member-modal-tabs">
<button id="modal-tab-basic" onclick="switchModalTab('basic')" class="member-modal-tab is-active">기본 정보</button>
<button id="modal-tab-org" onclick="switchModalTab('org')" class="member-modal-tab">조직 및 근무</button>
</div>
<div id="modal-sec-basic" class="grid grid-cols-2 gap-3 modal-form-grid">
<div id="modal-sec-basic" class="modal-form-grid member-basic-editor">
<input type="hidden" id="m-id" value="${id || ''}">
<input type="hidden" id="m-photo-hidden" value="${member['사진'] || ''}">
<input type="hidden" id="m-seat-hidden" value="${member['자리위치'] || ''}">
<div class="col-span-2 member-edit-layout">
<div class="member-edit-left-pane">
<div class="member-edit-profile-card">
<label class="text-[11px] font-black text-slate-600 block">프로필 사진</label>
<div class="member-photo-upload-card member-photo-upload-card-compact">
<div class="member-basic-split">
<div class="member-basic-left">
<div class="member-photo-panel">
<p class="member-modal-panel-title">기본 정보</p>
<div class="member-photo-upload-card member-photo-upload-card-inline">
<div class="member-photo-card-title">프로필 사진</div>
<div class="member-photo-preview-wrap">
<img id="m-photo-preview" src="${member['사진'] || getPhotoPlaceholder(member['이름'] || '')}" alt="프로필 미리보기" class="member-photo-preview">
</div>
@@ -1219,43 +1302,47 @@ function openModal(id) {
<strong id="m-photo-file-name" class="member-photo-file-name">선택된 파일 없음</strong>
</div>
</div>
<div class="member-name-field member-name-field-compact">
<label class="text-[11px] font-black text-slate-600 block">이름 (필수)</label>
<input id="m-name" value="${member['이름'] || ''}" oninput="syncPhotoPreviewFromUrl()" class="w-full bg-slate-50 p-3 rounded-xl border font-bold text-sm outline-none">
</div>
<div class="member-inline-info-grid member-inline-info-grid-stacked">
<div class="member-inline-info-card member-inline-info-card-full">
<label>사번</label>
<input id="m-employee-id" value="${member['사번'] || ''}" class="w-full bg-white p-3 rounded-xl border font-bold text-sm outline-none">
<div class="member-basic-fields member-modal-panel">
<p class="member-modal-panel-title">기본 정보</p>
<div class="member-basic-field">
<label class="member-form-label block">이름 (필수)</label>
<input id="m-name" value="${member['이름'] || ''}" oninput="syncPhotoPreviewFromUrl()" class="member-form-input">
</div>
<div class="member-inline-info-card member-inline-info-card-full">
<label>전화번호</label>
<input id="m-phone" value="${member['전화번호'] || ''}" class="w-full bg-white p-3 rounded-xl border font-bold text-sm outline-none">
<div class="member-basic-field">
<label class="member-form-label block">사번</label>
<input id="m-employee-id" value="${member['사번'] || ''}" class="member-form-input">
</div>
<div class="member-inline-info-card member-inline-info-card-full">
<label>이메일</label>
<input id="m-email" value="${member['이메일'] || ''}" class="w-full bg-white p-3 rounded-xl border font-bold text-sm outline-none">
<div class="member-basic-field">
<label class="member-form-label block">전화번호</label>
<input id="m-phone" value="${member['전화번호'] || ''}" class="member-form-input">
</div>
<div class="member-basic-field">
<label class="member-form-label block">이메일</label>
<input id="m-email" value="${member['이메일'] || ''}" class="member-form-input">
</div>
</div>
</div>
</div>
<div class="member-edit-right-pane">
<div class="member-seat-field member-seat-field-emphasis">
<div class="member-basic-right">
<p class="member-modal-panel-title" style="padding:16px 16px 0;">조직 및 근무</p>
<div class="member-seat-field member-seat-field-compact">
<div id="member-seat-preview">${renderSeatPreviewCard({ assigned: false, seatLabel: member['자리위치'] || '', seatMapName: '자리배치도', slotKey: '' })}</div>
</div>
</div>
</div>
</div>
${orgFields}
<div class="member-modal-panel">${orgFields}</div>
`;
resetPhotoPreviewObjectUrl();
const deleteBtn = id ? `<button onclick="deleteMember('${id}')" class="bg-red-50 text-red-600 py-3.5 px-6 rounded-xl font-bold text-sm border border-red-100 hover:bg-red-100 transition-colors">삭제</button>` : '';
const deleteBtn = id ? `<button onclick="deleteMember('${id}')" class="modal-btn modal-btn-delete">삭제</button>` : '';
footer.innerHTML = `
${deleteBtn}
<button onclick="closeModal()" class="flex-1 bg-slate-100 py-3.5 rounded-xl font-bold text-sm">취소</button>
<button onclick="saveMember()" class="flex-1 bg-indigo-600 text-white py-3.5 rounded-xl font-bold text-sm">저장</button>
<div class="modal-footer-actions">
<button onclick="closeModal()" class="modal-btn modal-btn-cancel">취소</button>
<button onclick="saveMember()" class="modal-btn modal-btn-save">저장</button>
</div>
`;
modal.style.display = 'flex';
if (id) {
@@ -1264,11 +1351,17 @@ function openModal(id) {
}
function closeModal() {
if (isListMode && isListDetailModal) {
returnToListViewModal();
return;
}
resetPhotoPreviewObjectUrl();
document.getElementById('modal').style.display = 'none';
document.getElementById('modal-fields').className = 'grid grid-cols-2 gap-x-8 gap-y-5';
document.getElementById('modal-fields').style.maxHeight = 'none';
document.getElementById('modal-fields').style.overflowY = 'visible';
document.querySelector('.modal-content').classList.remove('wide');
isListDetailModal = false;
isListMode = false;
}
@@ -1322,8 +1415,7 @@ async function saveMember() {
if (!id) {
targetList.push(member);
}
renderListViewTable();
closeModal();
returnToListViewModal();
return;
}
@@ -1352,11 +1444,16 @@ async function deleteMember(id) {
}
if (isListMode) {
const shouldReturnToList = isListDetailModal;
const idx = editingMembers.findIndex((member) => member._id === id);
if (idx !== -1) {
editingMembers.splice(idx, 1);
}
if (shouldReturnToList) {
returnToListViewModal();
} else {
renderListViewTable();
}
return;
}
@@ -1381,44 +1478,11 @@ function openListViewModal(event) {
listViewState.compareToDate = defaultDate;
listViewState.snapshotMembers = [];
listViewState.compareItems = [];
const modal = document.getElementById('modal');
modal.querySelector('.modal-content').classList.add('wide');
document.getElementById('modal-title').innerText = '인원 명단';
const fieldsArea = document.getElementById('modal-fields');
fieldsArea.className = 'flex flex-col w-full overflow-hidden';
fieldsArea.style.maxHeight = '75vh';
isListMode = true;
isListDetailModal = false;
editingMembers = cloneMembers(members);
fieldsArea.innerHTML = `
<div class="list-toolbar">
<div class="list-toolbar-row">
<div class="list-toolbar-group">
<button type="button" onclick="showCurrentListView()" class="list-mode-btn">현재 명단</button>
</div>
<div class="list-toolbar-divider" aria-hidden="true"></div>
<div class="list-toolbar-group list-date-group">
<input type="date" id="list-snapshot-date" value="${escapeHtml(defaultDate)}" class="list-date-input">
<button type="button" onclick="loadSnapshotListView()" class="list-mode-btn">기준일 조회</button>
</div>
<div class="list-toolbar-divider" aria-hidden="true"></div>
<div class="list-toolbar-group list-date-group">
<input type="date" id="list-compare-from" value="${escapeHtml(defaultDate)}" class="list-date-input">
<span class="list-date-separator">~</span>
<input type="date" id="list-compare-to" value="${escapeHtml(defaultDate)}" class="list-date-input">
<button type="button" onclick="loadCompareListView()" class="list-mode-btn">변경 비교</button>
</div>
</div>
<div class="list-toolbar-row">
<input type="text" id="list-search-input" placeholder="이름 또는 부서/팀 검색 (Enter 시 이동)" class="flex-1 bg-slate-50 border-2 border-slate-100 p-3 rounded-xl text-sm outline-none font-bold focus:border-indigo-400 transition-all" onkeydown="if(event.key==='Enter') handleListSearch(this.value)">
<button type="button" onclick="handleListSearch(document.getElementById('list-search-input').value)" class="bg-indigo-600 text-white px-5 rounded-xl font-bold text-sm">검색</button>
</div>
<div id="list-view-status" class="list-view-status"></div>
</div>
<div id="list-table-container" class="overflow-y-auto flex-1 border rounded-xl"></div>
`;
renderListViewShell(defaultDate);
renderListViewModalContent();
modal.style.display = 'flex';
}
async function applyListViewChanges() {
@@ -1443,19 +1507,19 @@ function renderListViewFooter() {
footer.innerHTML = `
<div class="flex gap-2 w-full justify-between items-center">
<div class="flex gap-2">
<button onclick="openAddModal(event)" class="bg-indigo-50 text-indigo-600 px-4 py-2 rounded-lg text-xs font-bold border border-indigo-100">+ 구성원 추가</button>
<button onclick="openUnitAddModal(event)" class="bg-indigo-50 text-indigo-600 px-4 py-2 rounded-lg text-xs font-bold border border-indigo-100">+ 조직 추가</button>
<button onclick="openAddModal(event)" class="bg-[#f6eddd] text-[#214634] px-4 py-2 rounded-lg text-xs font-bold border border-[#e0d0b4]">+ 구성원 추가</button>
<button onclick="openUnitAddModal(event)" class="bg-[#f6eddd] text-[#214634] px-4 py-2 rounded-lg text-xs font-bold border border-[#e0d0b4]">+ 조직 추가</button>
</div>
<div class="flex gap-2 items-center">
<p class="text-[10px] text-slate-400 font-bold mr-4">항목을 드래그하여 순서를 바꿀 수 있습니다.</p>
<button onclick="closeModal()" class="bg-slate-100 text-slate-600 px-6 py-2 rounded-lg text-xs font-bold">취소</button>
<button onclick="applyListViewChanges()" class="bg-indigo-600 text-white px-8 py-2 rounded-lg text-xs font-bold shadow-lg shadow-indigo-200">반영하기</button>
<p class="text-[10px] text-[#8b8a77] font-bold mr-4">항목을 드래그하여 순서를 바꿀 수 있습니다.</p>
<button onclick="closeModal()" class="bg-[#efe4d0] text-[#5b665a] px-6 py-2 rounded-lg text-xs font-bold">취소</button>
<button onclick="applyListViewChanges()" class="bg-[#214634] text-white px-8 py-2 rounded-lg text-xs font-bold shadow-lg shadow-[#d6c1a3]">반영하기</button>
</div>
</div>
`;
return;
}
footer.innerHTML = '<div class="flex gap-2 w-full justify-end items-center"><button onclick="closeModal()" class="bg-indigo-600 text-white px-10 py-2.5 rounded-lg text-xs font-bold shadow-lg shadow-indigo-200">닫기</button></div>';
footer.innerHTML = '<div class="flex gap-2 w-full justify-end items-center"><button onclick="closeModal()" class="bg-[#214634] text-white px-10 py-2.5 rounded-lg text-xs font-bold shadow-lg shadow-[#d6c1a3]">닫기</button></div>';
}
function getRenderableListMembers() {
@@ -1583,7 +1647,7 @@ async function loadSnapshotListView() {
}
const payload = await apiFetch(`/api/members?as_of=${encodeURIComponent(snapshotDate)}`);
listViewState.snapshotDate = snapshotDate;
listViewState.snapshotMembers = (payload.items || []).map(toLegacyMember);
listViewState.snapshotMembers = getVisibleLegacyMembers((payload.items || []).map(toLegacyMember));
listViewState.mode = 'snapshot';
renderListViewModalContent();
}
@@ -1682,12 +1746,29 @@ function toggleUnitCollapse(level, name) {
let draggedGroup = null;
function handleListGroupDragStart(event, level, name) {
draggedIdx = null;
draggedGroup = { level, name };
event.dataTransfer.effectAllowed = 'move';
}
function handleListGroupDrop(event, targetLevel, targetName) {
event.preventDefault();
if (draggedIdx !== null) {
const movingMember = editingMembers[draggedIdx];
if (!movingMember) {
return;
}
const moved = editingMembers.splice(draggedIdx, 1)[0];
applyMemberPlacementFromTarget(moved, targetLevel, targetName, null);
let targetIdx = editingMembers.findIndex((member) => member[targetLevel] === targetName);
if (targetIdx === -1) {
targetIdx = editingMembers.length;
}
editingMembers.splice(targetIdx, 0, moved);
draggedIdx = null;
renderListViewTable();
return;
}
if (!draggedGroup || (draggedGroup.level === targetLevel && draggedGroup.name === targetName)) {
return;
}
@@ -1728,6 +1809,7 @@ function handleListSearch(value) {
let draggedIdx = null;
function handleListDragStart(event, index) {
draggedGroup = null;
draggedIdx = index;
event.dataTransfer.effectAllowed = 'move';
event.target.classList.add('dragging');
@@ -1738,7 +1820,11 @@ function handleListDrop(event, targetIdx) {
if (draggedIdx === null || draggedIdx === targetIdx) {
return;
}
const targetMember = editingMembers[targetIdx] || null;
const moved = editingMembers.splice(draggedIdx, 1)[0];
if (targetMember) {
applyMemberPlacementFromTarget(moved, levelOrder[levelOrder.length - 1], targetMember[levelOrder[levelOrder.length - 1]] || '', targetMember);
}
editingMembers.splice(targetIdx, 0, moved);
draggedIdx = null;
renderListViewTable();
@@ -1789,13 +1875,10 @@ async function handleDrop(event, targetLevel, targetName) {
}
const nextMembers = cloneMembers(members);
const moved = nextMembers[memberIndex];
for (let index = 0; index <= targetLevelIndex; index += 1) {
moved[levelOrder[index]] = targetMember ? targetMember[levelOrder[index]] : targetName;
if (targetLevelIndex === -1) {
return;
}
for (let index = targetLevelIndex + 1; index < levelOrder.length; index += 1) {
moved[levelOrder[index]] = '';
}
rebuildMemberPath(moved);
applyMemberPlacementFromTarget(moved, targetLevel, targetName, targetMember);
nextMembers.splice(memberIndex, 1);
nextMembers.push(moved);
await syncMembers(nextMembers);
@@ -1844,10 +1927,7 @@ async function handleDropMember(event, targetId) {
}
const moved = nextMembers[movingIdx];
const target = nextMembers[targetIdx];
levelOrder.forEach((level) => {
moved[level] = target[level];
});
rebuildMemberPath(moved);
applyMemberPlacementFromTarget(moved, levelOrder[levelOrder.length - 1], target[levelOrder[levelOrder.length - 1]] || '', target);
nextMembers.splice(movingIdx, 1);
targetIdx = nextMembers.findIndex((member) => member._id === targetId);
nextMembers.splice(insertAfter ? targetIdx + 1 : targetIdx, 0, moved);

View File

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

View File

@@ -0,0 +1,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

60
scripts/prepare_dev_worktree.sh Executable file
View File

@@ -0,0 +1,60 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
DEV_DIR="${DEV_DIR:-${ROOT_DIR}/.dev-worktree-8081}"
TARGET_REF="${1:-HEAD}"
FORCE_RECREATE="${FORCE_RECREATE:-0}"
copy_optional_path() {
local rel_path="$1"
local src="${ROOT_DIR}/${rel_path}"
local dst="${DEV_DIR}/${rel_path}"
if [[ ! -e "${src}" ]]; then
return 0
fi
mkdir -p "$(dirname "${dst}")"
cp -a "${src}" "${dst}"
}
if [[ "${DEV_DIR}" == "${ROOT_DIR}" ]]; then
echo "DEV_DIR must not be the same as the production workspace." >&2
exit 1
fi
if [[ -d "${DEV_DIR}/.git" && "${FORCE_RECREATE}" != "1" ]]; then
echo "[1/6] Reusing existing dev workspace at ${DEV_DIR}"
else
echo "[1/6] Removing previous dev workspace at ${DEV_DIR}"
rm -rf "${DEV_DIR}"
echo "[2/6] Cloning production workspace into isolated dev workspace"
git clone --no-hardlinks "${ROOT_DIR}" "${DEV_DIR}" >/dev/null
echo "[3/6] Checking out detached ref ${TARGET_REF}"
git -C "${DEV_DIR}" checkout --detach "${TARGET_REF}" >/dev/null
fi
echo "[4/6] Copying local runtime env when available"
copy_optional_path ".env"
if [[ ! -f "${DEV_DIR}/.env" && -f "${DEV_DIR}/.env.example" ]]; then
cp "${DEV_DIR}/.env.example" "${DEV_DIR}/.env"
echo "Created ${DEV_DIR}/.env from .env.example"
fi
echo "[5/6] Copying local-only incoming design assets when available"
copy_optional_path "incoming-files/1.png"
copy_optional_path "incoming-files/260320.html"
copy_optional_path "incoming-files/sample style.css"
copy_optional_path "incoming-files/seat/center_chair_people_map(2).html"
copy_optional_path "incoming-files/reference/ledger"
echo "[6/6] Dev worktree ready"
echo "Path: ${DEV_DIR}"
echo "Use this to start 8081 from the isolated workspace:"
echo " cd ${DEV_DIR} && docker compose -p mh-dashboard-organization-dev --env-file .env -f docker-compose.8081.yml up -d --build"
if [[ "${FORCE_RECREATE}" != "1" ]]; then
echo "To fully rebuild the dev workspace, run:"
echo " FORCE_RECREATE=1 ./scripts/prepare_dev_worktree.sh"
fi

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}"

15
scripts/start_8081.sh Executable file
View File

@@ -0,0 +1,15 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
DEV_DIR="${DEV_DIR:-${ROOT_DIR}/.dev-worktree-8081}"
"${ROOT_DIR}/scripts/prepare_dev_worktree.sh"
cd "${DEV_DIR}"
docker compose -p mh-dashboard-organization-dev --env-file .env -f docker-compose.8081.yml up -d --build
echo "8081 started from ${DEV_DIR}"
echo "Verify mounts with:"
echo " docker inspect mh-dashboard-organization-dev-backend-1 --format '{{range .Mounts}}{{println .Source \"->\" .Destination}}{{end}}'"

Some files were not shown because too many files have changed in this diff Show More