Compare commits
34 Commits
1d15cf9b9b
...
work-8081
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c52fa53e8 | ||
|
|
f5c68ada80 | ||
|
|
c0564ee326 | ||
|
|
f8ea345882 | ||
|
|
8125193378 | ||
|
|
a4480c3435 | ||
|
|
19c8c6ade1 | ||
|
|
c3afc0c772 | ||
|
|
03e90d18a3 | ||
|
|
57d9f630bc | ||
|
|
1e82572e15 | ||
|
|
e58e584a15 | ||
|
|
fb5b0f00c2 | ||
|
|
637b390024 | ||
|
|
4b4ffafbd2 | ||
|
|
1cd0f21a36 | ||
|
|
f77be3f482 | ||
|
|
2e8c79bb43 | ||
|
|
8121c9cf41 | ||
|
|
e67fd41cbf | ||
|
|
c9a93ea936 | ||
|
|
8d0cc78abc | ||
|
|
bbebe24763 | ||
|
|
2053791589 | ||
|
|
fc23156b2c | ||
|
|
33f157cb08 | ||
|
|
b735a4cdd1 | ||
|
|
6e55b99e9a | ||
|
|
cbae8769bf | ||
|
|
bc60f932c3 | ||
|
|
ca57a4a1e4 | ||
|
|
e50b24c25b | ||
|
|
24852d4401 | ||
|
|
d66614123e |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -11,3 +11,9 @@ node_modules/
|
||||
incoming-files/*.Zone.Identifier
|
||||
*:Zone.Identifier
|
||||
incoming-files/~$*
|
||||
|
||||
# Local-only inspection / conversion artifacts
|
||||
incoming-files/6f.html
|
||||
incoming-files/7f.html
|
||||
incoming-files/center.html
|
||||
.dev-worktree-8081/
|
||||
|
||||
Binary file not shown.
Binary file not shown.
59
CONTRIBUTING.md
Normal file
59
CONTRIBUTING.md
Normal 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)
|
||||
@@ -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=20260326-02" />
|
||||
<link rel="stylesheet" href="/legacy/static/organization.css?v=20260326-02" />
|
||||
<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=20260326-02"></script>
|
||||
<script src="/legacy/static/organization.js?v=20260402-02"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
63
README.md
Normal file
63
README.md
Normal 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`
|
||||
@@ -11,7 +11,6 @@ RUN pip install --no-cache-dir -r /app/requirements.txt
|
||||
COPY backend/app /app/backend/app
|
||||
COPY DashBoard-organization.html /app/legacy/DashBoard-organization.html
|
||||
COPY DashBoard-organization-backup.html /app/legacy/DashBoard-organization-backup.html
|
||||
COPY organization1.xlsx /app/legacy/organization1.xlsx
|
||||
COPY legacy/static /app/legacy/static
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
497
backend/app/admin_db_status.py
Normal file
497
backend/app/admin_db_status.py
Normal 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
172
backend/app/auth_routes.py
Normal 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",
|
||||
}
|
||||
@@ -281,6 +281,67 @@ 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',
|
||||
revision_label TEXT NOT NULL,
|
||||
created_by_user_id BIGINT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
note TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS member_versions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
member_id INTEGER NOT NULL REFERENCES members(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
company TEXT NOT NULL DEFAULT '',
|
||||
rank TEXT NOT NULL DEFAULT '',
|
||||
role TEXT NOT NULL DEFAULT '',
|
||||
department TEXT NOT NULL DEFAULT '',
|
||||
grp TEXT NOT NULL DEFAULT '',
|
||||
division TEXT NOT NULL DEFAULT '',
|
||||
team TEXT NOT NULL DEFAULT '',
|
||||
cell TEXT NOT NULL DEFAULT '',
|
||||
work_status TEXT NOT NULL DEFAULT '',
|
||||
work_time TEXT NOT NULL DEFAULT '',
|
||||
phone TEXT NOT NULL DEFAULT '',
|
||||
email TEXT NOT NULL DEFAULT '',
|
||||
photo_url TEXT NOT NULL DEFAULT '',
|
||||
valid_from TIMESTAMPTZ NOT NULL,
|
||||
valid_to TIMESTAMPTZ,
|
||||
revision_no BIGINT NOT NULL,
|
||||
changed_by_user_id BIGINT,
|
||||
change_reason TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS seat_assignment_versions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
member_id INTEGER NOT NULL REFERENCES members(id) ON DELETE CASCADE,
|
||||
seat_map_id INTEGER REFERENCES seat_maps(id) ON DELETE CASCADE,
|
||||
seat_slot_id INTEGER REFERENCES seat_slots(id) ON DELETE CASCADE,
|
||||
seat_label TEXT NOT NULL DEFAULT '',
|
||||
valid_from TIMESTAMPTZ NOT NULL,
|
||||
valid_to TIMESTAMPTZ,
|
||||
revision_no BIGINT NOT NULL,
|
||||
changed_by_user_id BIGINT,
|
||||
change_reason TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS auth;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS auth.users (
|
||||
@@ -474,6 +535,18 @@ 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);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS seat_assignment_versions_member_time_idx
|
||||
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);
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
@@ -539,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;
|
||||
"""
|
||||
|
||||
|
||||
@@ -556,6 +632,7 @@ def init_db(max_retries: int = 20, retry_delay: float = 2.0) -> None:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(SCHEMA_SQL)
|
||||
cur.execute(MIGRATION_SQL)
|
||||
ensure_history_backfill(cur)
|
||||
conn.commit()
|
||||
return
|
||||
except psycopg.OperationalError as exc:
|
||||
@@ -563,3 +640,89 @@ def init_db(max_retries: int = 20, retry_delay: float = 2.0) -> None:
|
||||
time.sleep(retry_delay)
|
||||
if last_error is not None:
|
||||
raise last_error
|
||||
|
||||
|
||||
def ensure_history_backfill(cur) -> None:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id
|
||||
FROM history_revisions
|
||||
WHERE scope = 'organization'
|
||||
AND revision_label = 'initial-backfill'
|
||||
ORDER BY id ASC
|
||||
LIMIT 1
|
||||
"""
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO history_revisions (scope, revision_label, note)
|
||||
VALUES ('organization', 'initial-backfill', 'Seeded from current members and seat_positions state')
|
||||
RETURNING id
|
||||
"""
|
||||
)
|
||||
revision_id = int(cur.fetchone()["id"])
|
||||
else:
|
||||
revision_id = int(row["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
|
||||
)
|
||||
SELECT
|
||||
m.id, m.name, COALESCE(m.company, ''), COALESCE(m.rank, ''), COALESCE(m.role, ''),
|
||||
COALESCE(m.department, ''), COALESCE(m.grp, ''), COALESCE(m.division, ''), COALESCE(m.team, ''), COALESCE(m.cell, ''),
|
||||
COALESCE(m.work_status, ''), COALESCE(m.work_time, ''), COALESCE(m.phone, ''), COALESCE(m.email, ''), COALESCE(m.photo_url, ''),
|
||||
TIMESTAMPTZ '1970-01-01 00:00:00+00', NULL, %s, NULL, 'initial-backfill'
|
||||
FROM members AS m
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM member_versions mv
|
||||
WHERE mv.member_id = m.id
|
||||
)
|
||||
""",
|
||||
(revision_id,),
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
SELECT
|
||||
sp.member_id, sp.seat_map_id, sp.seat_slot_id, COALESCE(sp.seat_label, ''),
|
||||
TIMESTAMPTZ '1970-01-01 00:00:00+00', NULL, %s, NULL, 'initial-backfill'
|
||||
FROM seat_positions AS sp
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM seat_assignment_versions sav
|
||||
WHERE sav.member_id = sp.member_id
|
||||
)
|
||||
""",
|
||||
(revision_id,),
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE member_versions
|
||||
SET valid_from = TIMESTAMPTZ '1970-01-01 00:00:00+00'
|
||||
WHERE revision_no = %s
|
||||
AND change_reason = 'initial-backfill'
|
||||
""",
|
||||
(revision_id,),
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE seat_assignment_versions
|
||||
SET valid_from = TIMESTAMPTZ '1970-01-01 00:00:00+00'
|
||||
WHERE revision_no = %s
|
||||
AND change_reason = 'initial-backfill'
|
||||
""",
|
||||
(revision_id,),
|
||||
)
|
||||
|
||||
50
backend/app/integration_routes.py
Normal file
50
backend/app/integration_routes.py
Normal 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()
|
||||
111
backend/app/ledger_runtime.py
Normal file
111
backend/app/ledger_runtime.py
Normal 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
|
||||
1511
backend/app/main.py
1511
backend/app/main.py
File diff suppressed because it is too large
Load Diff
147
backend/app/member_routes.py
Normal file
147
backend/app/member_routes.py
Normal 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)}
|
||||
19
backend/app/repositories/__init__.py
Normal file
19
backend/app/repositories/__init__.py
Normal 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",
|
||||
]
|
||||
145
backend/app/repositories/organization.py
Normal file
145
backend/app/repositories/organization.py
Normal 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()
|
||||
13
backend/app/routes/__init__.py
Normal file
13
backend/app/routes/__init__.py
Normal 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",
|
||||
]
|
||||
3
backend/app/routes/auth.py
Normal file
3
backend/app/routes/auth.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from ..auth_routes import register_auth_routes
|
||||
|
||||
__all__ = ["register_auth_routes"]
|
||||
3
backend/app/routes/integration.py
Normal file
3
backend/app/routes/integration.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from ..integration_routes import register_integration_routes
|
||||
|
||||
__all__ = ["register_integration_routes"]
|
||||
3
backend/app/routes/organization.py
Normal file
3
backend/app/routes/organization.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from ..member_routes import register_member_routes
|
||||
|
||||
__all__ = ["register_member_routes"]
|
||||
3
backend/app/routes/seatmap.py
Normal file
3
backend/app/routes/seatmap.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from ..seatmap_routes import register_seatmap_routes
|
||||
|
||||
__all__ = ["register_seatmap_routes"]
|
||||
3
backend/app/routes/system.py
Normal file
3
backend/app/routes/system.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from ..system_routes import register_system_routes
|
||||
|
||||
__all__ = ["register_system_routes"]
|
||||
15
backend/app/schemas/__init__.py
Normal file
15
backend/app/schemas/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from .organization import (
|
||||
MemberBulkPayload,
|
||||
MemberPayload,
|
||||
SeatLayoutPayload,
|
||||
SeatLayoutPlacementPayload,
|
||||
SeatMapPayload,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"MemberBulkPayload",
|
||||
"MemberPayload",
|
||||
"SeatLayoutPayload",
|
||||
"SeatLayoutPlacementPayload",
|
||||
"SeatMapPayload",
|
||||
]
|
||||
58
backend/app/schemas/organization.py
Normal file
58
backend/app/schemas/organization.py
Normal 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]
|
||||
224
backend/app/seatmap_routes.py
Normal file
224
backend/app/seatmap_routes.py
Normal 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)}
|
||||
21
backend/app/services/__init__.py
Normal file
21
backend/app/services/__init__.py
Normal 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",
|
||||
]
|
||||
350
backend/app/services/organization.py
Normal file
350
backend/app/services/organization.py
Normal 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)
|
||||
120
backend/app/system_routes.py
Normal file
120
backend/app/system_routes.py
Normal 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)
|
||||
521474
center2.dxf
521474
center2.dxf
File diff suppressed because it is too large
Load Diff
564192
center3.dxf
564192
center3.dxf
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
78
docker-compose.8081.yml
Normal file
78
docker-compose.8081.yml
Normal file
@@ -0,0 +1,78 @@
|
||||
services:
|
||||
proxy:
|
||||
image: nginx:1.27-alpine
|
||||
depends_on:
|
||||
frontend:
|
||||
condition: service_healthy
|
||||
backend:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "8081:80"
|
||||
volumes:
|
||||
- ./proxy/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1/ || exit 1"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: frontend/Dockerfile
|
||||
volumes:
|
||||
- ./frontend/public:/usr/share/nginx/html:ro
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1/ || exit 1"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: backend/Dockerfile
|
||||
command: uvicorn backend.app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
env_file:
|
||||
- .env
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./backend/app:/app/backend/app:ro
|
||||
- ./DashBoard-organization.html:/app/legacy/DashBoard-organization.html:ro
|
||||
- ./DashBoard-organization-backup.html:/app/legacy/DashBoard-organization-backup.html:ro
|
||||
- ./legacy/static:/app/legacy/static:ro
|
||||
- ./incoming-files:/app/incoming-files:ro
|
||||
- uploads_data:/data/uploads
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/health')\" || exit 1"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 8
|
||||
start_period: 20s
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 10s
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
uploads_data:
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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` 에서 데이터가 조회되는지 확인합니다.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Purpose
|
||||
|
||||
이 문서는 `total` 브랜치에서 진행한 통합 작업을 기능 단위로 정리한 개발 히스토리다.
|
||||
이 문서는 이 저장소에서 진행한 통합 작업을 기능 단위로 정리한 개발 히스토리다.
|
||||
목표는 다음 두 가지다.
|
||||
|
||||
- 지금까지 어떤 기능을 어떤 방식으로 붙였는지 빠르게 파악
|
||||
@@ -161,17 +161,77 @@
|
||||
|
||||
### 작업 내용
|
||||
|
||||
- WSL 내부 `0.0.0.0:8080` 바인딩 확인
|
||||
- Windows host에서 `portproxy`와 방화벽 규칙으로 다른 PC 접속 가능하게 정리
|
||||
- 개발 환경의 외부 접속 경로를 정리
|
||||
- 호스트 방화벽과 포트 포워딩 규칙으로 다른 PC 접속 가능하게 구성
|
||||
|
||||
### 유의사항
|
||||
|
||||
- Windows LAN IP 또는 WSL IP가 바뀌면 `portproxy`의 `connectaddress`는 다시 맞춰야 한다
|
||||
- 호스트 IP나 포워딩 대상 IP가 바뀌면 포트 포워딩 설정을 다시 맞춰야 한다
|
||||
- 운영 안정성을 위해 향후 자동화 스크립트화가 필요함
|
||||
|
||||
## 10. 인증 기본 구조 추가
|
||||
|
||||
### 작업 내용
|
||||
|
||||
- 프런트 로그인 화면을 실제 `/api/auth/login` API와 연결
|
||||
- 로그인 세션 확인용 `/api/auth/me` 추가
|
||||
- 로그아웃용 `/api/auth/logout` 추가
|
||||
- 로그인 감사로그와 세션 저장 테이블 추가
|
||||
|
||||
### 해결 방식
|
||||
|
||||
- 업무 데이터는 기존 `members` 중심으로 유지
|
||||
- 인증 데이터는 `auth.users`, `auth.sessions`, `auth.login_audit_logs` 로 분리
|
||||
- 구성원 import 시 사번 기준으로 계정을 동기화하고 기본 관리자 계정을 seed
|
||||
|
||||
### 현재 한계
|
||||
|
||||
- 권한 모델은 아직 `role` 단일 컬럼 수준이다
|
||||
- API별 세부 권한 검증은 아직 미완성이다
|
||||
- `/api/mock-login` 은 아직 남아 있어 운영 기준으로는 정리가 필요하다
|
||||
|
||||
## 11. 이력형 DB 전환 방향 확정
|
||||
|
||||
### 배경
|
||||
|
||||
- 월간 스냅샷 파일보다, 사용자가 원하는 날짜 기준으로 조직도와 자리배치도를 바로 조회하는 요구가 더 중요해졌다
|
||||
- 조직도 기본 정보나 자리배치 정보처럼 원래 날짜가 없는 데이터도 과거/현재 버전 차이를 추적해야 한다
|
||||
|
||||
### 결정
|
||||
|
||||
- 월간 스냅샷 기능은 범위에서 제외
|
||||
- 대신 DB 자체를 `valid_from`, `valid_to` 기반 버전 구조로 전환
|
||||
- 사용자 조회는 파일 스냅샷이 아니라 `as_of` 기준 조회 방식으로 설계
|
||||
|
||||
### 우선 적용 대상
|
||||
|
||||
- `members` -> `member_versions`
|
||||
- `seat_positions` -> `seat_assignment_versions`
|
||||
|
||||
### 기대 효과
|
||||
|
||||
- 특정 날짜의 조직 상태 재구성 가능
|
||||
- 특정 날짜의 자리배치도 재구성 가능
|
||||
- 기간 비교나 변경 추적 UI로 확장 가능
|
||||
|
||||
### 현재 반영 상태
|
||||
|
||||
- `history_revisions`
|
||||
- `member_versions`
|
||||
- `seat_assignment_versions`
|
||||
- `entity_change_events`
|
||||
|
||||
초기 단계로 테이블과 baseline backfill 경로를 먼저 추가했다.
|
||||
아직 조직도/자리배치도 쓰기 API가 매 수정마다 version row 를 append 하도록 완전히 전환된 상태는 아니다.
|
||||
|
||||
### 설계 문서
|
||||
|
||||
- [HISTORY_ASOF_DB_PLAN.md](HISTORY_ASOF_DB_PLAN.md)
|
||||
|
||||
## Next Focus
|
||||
|
||||
- `#2` 영속성 운영 검증과 문서 기준 정리
|
||||
- 권한 제어와 mock login 정리
|
||||
- `#9` as-of date 기반 history 구조 설계 및 점진적 도입
|
||||
- 자리배치도 조직 트리, 나머지 사무실 도면 등 실사용 기능 고도화
|
||||
- 프로젝트별 분석의 남은 소수점/분류 오차 정리
|
||||
- 자리배치도 색상/조직 트리 등 추가 UX 기능 고도화
|
||||
- 실제 인증 체계 전환
|
||||
- 나머지 사무실 도면 추가
|
||||
|
||||
216
docs/DEV_PROD_DB_PROTOCOL.md
Normal file
216
docs/DEV_PROD_DB_PROTOCOL.md
Normal file
@@ -0,0 +1,216 @@
|
||||
# Dev / Prod DB Protocol
|
||||
|
||||
## 목적
|
||||
|
||||
- `8081` 작업용은 기능 개발과 화면 검증을 먼저 수행하는 환경이다.
|
||||
- `8080` 공개용은 실제 기준 데이터와 운영 화면을 제공하는 환경이다.
|
||||
- 코드와 데이터의 기준을 분리해서 관리하되, 데이터 정본은 항상 `8080` 공개용 DB로 유지한다.
|
||||
|
||||
## 현재 구조
|
||||
|
||||
### 코드 경로
|
||||
|
||||
- 공개용 `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`
|
||||
|
||||
즉 현재는 `8080` 과 `8081` 이 코드 workspace 와 DB volume 모두 분리된 상태로 운영한다.
|
||||
|
||||
## 정본 기준
|
||||
|
||||
- 코드 선행 환경: `8081`
|
||||
- 데이터 정본: `8080`
|
||||
- 공개 반영 기준: `8081`에서 검증 완료된 코드만 `8080`에 승격
|
||||
|
||||
중요:
|
||||
- `8081` DB는 독립 정본이 아니다.
|
||||
- `8081` DB는 `8080` DB를 기준으로 맞춘 검증용 복제본이어야 한다.
|
||||
|
||||
## 왜 이 규칙이 필요한가
|
||||
|
||||
- 조직현황, 조직도, 자리배치 인원, 퇴사자 제외, 멤버 수는 코드보다 DB 영향이 크다.
|
||||
- 작업용 DB가 공개용과 달라지면 기능 검증 결과 자체가 왜곡된다.
|
||||
- 원인 분석 시 `코드 차이`와 `DB 차이`를 분리할 수 있어야 한다.
|
||||
|
||||
## 현재 확인된 차이 예시
|
||||
|
||||
2026-03-27 확인 기준:
|
||||
|
||||
- `members`
|
||||
- `8080`: `227`
|
||||
- `8081`: `236`
|
||||
- `member_retirements`
|
||||
- `8080`: `9`
|
||||
- `8081`: `0`
|
||||
- `seat_maps`
|
||||
- `8080`: `21`
|
||||
- `8081`: `3`
|
||||
- `seat_positions`
|
||||
- `8080`: `5`
|
||||
- `8081`: `0`
|
||||
- `seat_slots`
|
||||
- `8080`: `57308`
|
||||
- `8081`: `370`
|
||||
|
||||
## 기준 테이블 분류
|
||||
|
||||
### A. 공개용 정본 기준으로 항상 맞춰야 하는 테이블
|
||||
|
||||
- `members`
|
||||
- `member_aliases`
|
||||
- `member_overrides`
|
||||
- `member_retirements`
|
||||
- `seat_maps`
|
||||
- `seat_slots`
|
||||
- `seat_positions`
|
||||
|
||||
### B. 원본 재적재로 다시 만들 수 있는 통합 테이블
|
||||
|
||||
- `integration_import_batches`
|
||||
- `integration_raw_organization_rows`
|
||||
- `integration_raw_mh_rows`
|
||||
- `integration_raw_mh_pm_rows`
|
||||
- `integration_raw_payment_rows`
|
||||
- `integration_projects`
|
||||
- `integration_project_aliases`
|
||||
- `integration_project_category_mappings`
|
||||
- `integration_project_pm_assignments`
|
||||
- `integration_work_logs`
|
||||
- `integration_work_log_segments`
|
||||
- `integration_vouchers`
|
||||
|
||||
### C. 별도 정책이 필요한 영역
|
||||
|
||||
- `snapshots`
|
||||
- 인증 관련 스키마와 테이블
|
||||
|
||||
## 작업 프로토콜
|
||||
|
||||
### 1. 작업 시작 전
|
||||
|
||||
1. `8080`과 `8081` 모두 기동 상태 확인
|
||||
2. 이번 작업이 `코드 변경`인지 `데이터 변경`인지 먼저 구분
|
||||
3. 공개용 기준 데이터가 필요한 화면이면 `8081` DB를 먼저 `8080` 기준으로 맞춤
|
||||
4. 작업 전후 검증은 [REGRESSION_CHECKLIST.md](REGRESSION_CHECKLIST.md) 기준으로 수행
|
||||
|
||||
### 2. 기능 개발 중
|
||||
|
||||
1. 코드 수정은 먼저 `8081`에서 수행
|
||||
2. UI, 계산식, 자리배치도 동작은 `8081`에서 확인
|
||||
3. 조직도/멤버/자리배치 검증은 공개용 기준 데이터가 반영된 `8081` DB에서만 수행
|
||||
|
||||
### 3. 검증 완료 후
|
||||
|
||||
1. 코드만 `8080`으로 승격
|
||||
2. 데이터 반영이 필요한 기능은 별도 절차를 문서화한 뒤 적용
|
||||
3. 공개용 DB를 개발 실험용으로 사용하지 않음
|
||||
|
||||
## 금지 사항
|
||||
|
||||
- `8081` DB를 장기간 독립 정본처럼 취급하지 않기
|
||||
- 퇴사자, 멤버, 좌석 정보를 작업용에서 수작업으로만 유지하지 않기
|
||||
- DB 차이를 무시하고 `8081` 검증 결과가 `8080`과 같다고 가정하지 않기
|
||||
|
||||
## 권장 동기화 범위
|
||||
|
||||
### 최소 범위
|
||||
|
||||
조직도/자리배치도 검증 전 반드시 동기화:
|
||||
|
||||
1. `members`
|
||||
2. `member_aliases`
|
||||
3. `member_overrides`
|
||||
4. `member_retirements`
|
||||
5. `seat_maps`
|
||||
6. `seat_slots`
|
||||
7. `seat_positions`
|
||||
|
||||
### 전체 범위
|
||||
|
||||
분석 화면까지 공개용 기준으로 검증해야 하면 아래도 포함:
|
||||
|
||||
1. `integration_import_batches`
|
||||
2. `integration_raw_*`
|
||||
3. `integration_projects`
|
||||
4. `integration_project_*`
|
||||
5. `integration_work_logs`
|
||||
6. `integration_work_log_segments`
|
||||
7. `integration_vouchers`
|
||||
|
||||
## 세션 시작 체크
|
||||
|
||||
1. 지금 작업이 `코드 변경`인지 `데이터 변경`인지 구분
|
||||
2. 공개용 기준 데이터가 필요한지 판단
|
||||
3. 필요하면 `8081` DB를 `8080` 기준으로 먼저 동기화
|
||||
4. 그 뒤 기능 개발과 검증 수행
|
||||
5. 검증은 [REGRESSION_CHECKLIST.md](REGRESSION_CHECKLIST.md) 기준으로 수행
|
||||
6. 검증 완료 후 공개용에 코드 승격
|
||||
|
||||
## 다음 액션
|
||||
|
||||
- `8081` DB를 `8080` 기준으로 맞추는 반복 가능한 동기화 절차를 만든다
|
||||
- 최소한 `A 그룹` 테이블은 수동 기억에 의존하지 않고 다시 수행 가능해야 한다
|
||||
- 이후 모든 작업은 이 문서를 기본 프로토콜로 따른다
|
||||
|
||||
## 실행 절차
|
||||
|
||||
반복 가능한 동기화 스크립트:
|
||||
|
||||
- [sync_prod_db_to_dev.sh](../scripts/sync_prod_db_to_dev.sh)
|
||||
- [docker-compose.8081.yml](../docker-compose.8081.yml)
|
||||
|
||||
사용 방법:
|
||||
|
||||
```bash
|
||||
./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`
|
||||
- 조직도, 멤버, 자리배치도 검증 전 사용
|
||||
- `full`
|
||||
- 분석 화면까지 공개용 기준 데이터로 맞춰야 할 때 사용
|
||||
|
||||
주의:
|
||||
|
||||
- 스크립트는 동기화 전에 `8081`의 `proxy`, `frontend`, `backend` 를 잠시 멈춘다
|
||||
- 이유는 중간 상태를 읽는 API 요청과 DB truncate/restore 가 충돌하면 deadlock 또는 부분 검증이 발생할 수 있기 때문이다
|
||||
- 스크립트는 `8080` DB 데이터를 덤프해서 `8081` DB의 대상 테이블을 비우고 다시 적재한다
|
||||
- `8081`에서만 존재하던 대상 테이블 데이터는 사라진다
|
||||
- `seat_positions` 는 portable CSV 경로로 별도 복원한다
|
||||
- 복원 후 `members.seat_label`, `auth.users`, history backfill 을 다시 맞춘다
|
||||
- 실행 후 주요 테이블 수량과 seat 정합성 수치를 출력한다
|
||||
- 따라서 실행 전 현재 작업용 DB 상태를 유지해야 하면 별도 백업 후 실행한다
|
||||
294
docs/HISTORY_ASOF_DB_PLAN.md
Normal file
294
docs/HISTORY_ASOF_DB_PLAN.md
Normal file
@@ -0,0 +1,294 @@
|
||||
# History / As-Of DB Plan
|
||||
|
||||
## Goal
|
||||
|
||||
월간 스냅샷 파일을 따로 만드는 대신, DB 자체를 시간축이 있는 구조로 전환한다.
|
||||
목표는 다음과 같다.
|
||||
|
||||
- 조직도와 자리배치도를 수정할 때마다 과거 값이 사라지지 않게 누적 저장
|
||||
- 사용자가 특정 날짜 또는 기간을 선택하면 그 시점 기준 상태를 다시 조회
|
||||
- 날짜가 원래 없는 데이터도 `유효 시작일`과 `유효 종료일`을 부여해 과거 버전 조회 가능하게 만들기
|
||||
|
||||
핵심 원칙은 아래 한 줄이다.
|
||||
|
||||
- 최신 값을 덮어쓰지 않고, `valid_from`, `valid_to` 기반 버전 행을 누적한다
|
||||
|
||||
## Why This Instead Of Snapshots
|
||||
|
||||
- 월간 스냅샷 파일은 생성 시점만 남고 중간 변경 추적이 약하다
|
||||
- 원하는 날짜 기준으로 바로 조회하기 어렵다
|
||||
- 조직도만 따로 파일로 남으면 자리배치도, 권한, 운영 이력을 함께 맞추기 어렵다
|
||||
|
||||
따라서 이 프로젝트에는 "파일 스냅샷"보다 "시점 조회 가능한 버전 DB"가 더 맞다.
|
||||
|
||||
## Query Model
|
||||
|
||||
조회 기준은 `as_of` 또는 `date_from`, `date_to` 이다.
|
||||
|
||||
- 특정 날짜 조회:
|
||||
- `GET /api/members?as_of=2026-03-01`
|
||||
- `GET /api/seat-maps/active/layout?as_of=2026-03-01`
|
||||
- 기간 비교:
|
||||
- `GET /api/history/organization/compare?date_from=2026-03-01&date_to=2026-03-31`
|
||||
|
||||
공통 조회 조건은 아래다.
|
||||
|
||||
```sql
|
||||
WHERE valid_from <= :as_of
|
||||
AND (valid_to IS NULL OR valid_to > :as_of)
|
||||
```
|
||||
|
||||
## Recommended Data Model
|
||||
|
||||
### 1. Stable Base Tables
|
||||
|
||||
식별자와 최소 메타만 유지하는 기준 테이블.
|
||||
|
||||
```sql
|
||||
CREATE TABLE members (
|
||||
id SERIAL PRIMARY KEY,
|
||||
employee_id TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
```sql
|
||||
CREATE TABLE seat_assignment_targets (
|
||||
member_id INTEGER PRIMARY KEY REFERENCES members(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
설명:
|
||||
- `members` 는 "사람 자체" 식별자 역할
|
||||
- 실제 이름, 조직, 직급, 연락처, 좌석 같은 표시 데이터는 버전 테이블로 이동
|
||||
|
||||
### 2. Member Version Table
|
||||
|
||||
```sql
|
||||
CREATE TABLE member_versions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
member_id INTEGER NOT NULL REFERENCES members(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
company TEXT NOT NULL DEFAULT '',
|
||||
rank TEXT NOT NULL DEFAULT '',
|
||||
role TEXT NOT NULL DEFAULT '',
|
||||
department TEXT NOT NULL DEFAULT '',
|
||||
grp TEXT NOT NULL DEFAULT '',
|
||||
division TEXT NOT NULL DEFAULT '',
|
||||
team TEXT NOT NULL DEFAULT '',
|
||||
cell TEXT NOT NULL DEFAULT '',
|
||||
work_status TEXT NOT NULL DEFAULT '',
|
||||
work_time TEXT NOT NULL DEFAULT '',
|
||||
phone TEXT NOT NULL DEFAULT '',
|
||||
email TEXT NOT NULL DEFAULT '',
|
||||
photo_url TEXT NOT NULL DEFAULT '',
|
||||
valid_from TIMESTAMPTZ NOT NULL,
|
||||
valid_to TIMESTAMPTZ,
|
||||
revision_no BIGINT NOT NULL,
|
||||
changed_by_user_id BIGINT,
|
||||
change_reason TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX member_versions_member_time_idx
|
||||
ON member_versions (member_id, valid_from, valid_to);
|
||||
```
|
||||
|
||||
설명:
|
||||
- 날짜가 원래 없던 조직도 데이터도 이 테이블에서 과거 버전 관리
|
||||
- 어떤 시점에 이름, 조직, 직책, 연락처가 어땠는지 재구성 가능
|
||||
|
||||
### 3. Seat Assignment Version Table
|
||||
|
||||
```sql
|
||||
CREATE TABLE seat_assignment_versions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
member_id INTEGER NOT NULL REFERENCES members(id) ON DELETE CASCADE,
|
||||
seat_map_id INTEGER REFERENCES seat_maps(id) ON DELETE CASCADE,
|
||||
seat_slot_id INTEGER REFERENCES seat_slots(id) ON DELETE CASCADE,
|
||||
seat_label TEXT NOT NULL DEFAULT '',
|
||||
valid_from TIMESTAMPTZ NOT NULL,
|
||||
valid_to TIMESTAMPTZ,
|
||||
revision_no BIGINT NOT NULL,
|
||||
changed_by_user_id BIGINT,
|
||||
change_reason TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX seat_assignment_versions_member_time_idx
|
||||
ON seat_assignment_versions (member_id, valid_from, valid_to);
|
||||
```
|
||||
|
||||
설명:
|
||||
- 현재 `seat_positions` 가 맡는 "최신 좌석 상태"를 버전형으로 저장
|
||||
- 특정 날짜의 자리배치도를 다시 그릴 수 있음
|
||||
|
||||
### 4. Optional Change Event Table
|
||||
|
||||
```sql
|
||||
CREATE TABLE 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
|
||||
);
|
||||
```
|
||||
|
||||
설명:
|
||||
- 버전 테이블은 "그 시점의 전체 값"
|
||||
- 이벤트 테이블은 "무엇이 바뀌었는지"
|
||||
- 초기에는 없어도 되지만, 추후 비교 UI와 감사로그에 유용
|
||||
|
||||
### 5. Revision Table
|
||||
|
||||
```sql
|
||||
CREATE TABLE history_revisions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
scope TEXT NOT NULL DEFAULT 'organization',
|
||||
revision_label TEXT NOT NULL,
|
||||
created_by_user_id BIGINT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
note TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
```
|
||||
|
||||
설명:
|
||||
- 버전 묶음을 사람 친화적으로 관리할 때 사용
|
||||
- 예: `2026-03-27 1차 조직개편 반영`
|
||||
|
||||
## How Writes Change
|
||||
|
||||
현재 구조:
|
||||
- `UPDATE members SET ...`
|
||||
- `UPSERT seat_positions ...`
|
||||
|
||||
바꿀 구조:
|
||||
1. 현재 유효한 버전 행을 조회
|
||||
2. 값이 달라지면 기존 행의 `valid_to` 를 닫음
|
||||
3. 새 값을 가진 행을 `valid_from = now()` 로 insert
|
||||
4. 필요하면 최신 캐시 테이블도 함께 갱신
|
||||
|
||||
예시:
|
||||
|
||||
```sql
|
||||
UPDATE member_versions
|
||||
SET valid_to = NOW()
|
||||
WHERE member_id = :member_id
|
||||
AND valid_to IS NULL;
|
||||
|
||||
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 (
|
||||
:member_id, :name, :company, :rank, :role, :department, :grp, :division, :team, :cell,
|
||||
:work_status, :work_time, :phone, :email, :photo_url,
|
||||
NOW(), NULL, :revision_no, :changed_by_user_id, :change_reason
|
||||
);
|
||||
```
|
||||
|
||||
## How Date-Bearing And Date-Less Data Coexist
|
||||
|
||||
### 날짜가 원래 있는 데이터
|
||||
|
||||
- `integration_work_logs.work_date`
|
||||
- `integration_vouchers.issue_date`
|
||||
|
||||
이 데이터는 원래 날짜 컬럼이 있으므로 그대로 사용하면 된다.
|
||||
|
||||
### 날짜가 원래 없는 데이터
|
||||
|
||||
- 조직도 인원 기본 정보
|
||||
- 조직 소속
|
||||
- 자리배치 상태
|
||||
- 사진 경로
|
||||
|
||||
이 데이터는 `valid_from`, `valid_to` 를 붙여 시점 조회가 가능하게 만든다.
|
||||
|
||||
즉, "날짜가 없는 데이터"가 아니라 "유효기간을 부여한 버전 데이터"로 바꾸는 것이다.
|
||||
|
||||
## API Direction
|
||||
|
||||
### Common UI Input
|
||||
|
||||
사용자가 실제 HTML에서 고르는 기준은 헤더의 날짜 제어를 공통 입력으로 쓰는 것이 맞다.
|
||||
|
||||
권장안:
|
||||
- 프로젝트/팀 분석: 기존처럼 `시작일 ~ 종료일`
|
||||
- 조직도/자리배치도: 우선 `기준일(as_of)` 1개를 사용
|
||||
- 필요하면 조직도 비교 화면에서 `비교 시작일`, `비교 종료일` 확장
|
||||
|
||||
현재 상태:
|
||||
- 헤더 날짜 제어는 `프로젝트별 분석`, `팀/개인별 분석` iframe에 이미 전달되고 있음
|
||||
- 조직도/자리배치도는 아직 헤더 날짜를 실제 조회 조건으로 사용하지 않음
|
||||
|
||||
권장 API:
|
||||
|
||||
```text
|
||||
GET /api/members?as_of=2026-03-27
|
||||
GET /api/members/{id}?as_of=2026-03-27
|
||||
GET /api/seat-maps/active/layout?as_of=2026-03-27
|
||||
GET /api/history/organization/compare?date_from=2026-03-01&date_to=2026-03-31
|
||||
```
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
### Phase 1. History Tables Add
|
||||
|
||||
- `member_versions`
|
||||
- `seat_assignment_versions`
|
||||
- `history_revisions`
|
||||
- 필요 시 `entity_change_events`
|
||||
|
||||
현재 `members`, `seat_positions` 는 그대로 유지
|
||||
|
||||
### Phase 2. Backfill
|
||||
|
||||
- 현재 `members` 최신값을 `member_versions(valid_from = NOW(), valid_to = NULL)` 로 적재
|
||||
- 현재 `seat_positions` 최신값을 `seat_assignment_versions(valid_from = NOW(), valid_to = NULL)` 로 적재
|
||||
- 이 단계에서는 과거 진짜 이력은 없고 "현재 상태를 버전 구조에 싣는 것"이 목표
|
||||
|
||||
### Phase 3. Dual Write
|
||||
|
||||
- 조직도 수정 시:
|
||||
- 기존 `members` 갱신
|
||||
- 동시에 `member_versions` 에 append
|
||||
- 자리배치 저장 시:
|
||||
- 기존 `seat_positions` 갱신
|
||||
- 동시에 `seat_assignment_versions` 에 append
|
||||
|
||||
### Phase 4. As-Of Read APIs
|
||||
|
||||
- 조직도 API에 `as_of` 지원
|
||||
- 자리배치도 API에 `as_of` 지원
|
||||
- 헤더 날짜 제어와 연결
|
||||
|
||||
### Phase 5. Full History-First Read
|
||||
|
||||
- 최신 조회도 버전 테이블 기준으로 전환
|
||||
- `members`, `seat_positions` 는 캐시 또는 편의 테이블로 축소 가능
|
||||
|
||||
## Recommended First Scope
|
||||
|
||||
처음부터 모든 테이블을 이력화하지 말고 아래부터 시작하는 것이 안전하다.
|
||||
|
||||
1. `members` -> `member_versions`
|
||||
2. `seat_positions` -> `seat_assignment_versions`
|
||||
3. 조직도/자리배치도 조회 API에 `as_of`
|
||||
|
||||
이 세 가지가 되면 사용자는 원하는 날짜의 조직 상태와 좌석 상태를 볼 수 있다.
|
||||
|
||||
## Explicitly Removed From Scope
|
||||
|
||||
- 월간 스냅샷 파일 생성
|
||||
- 스냅샷 다운로드 기능
|
||||
- 조직도만 따로 파일로 내보내는 방식
|
||||
|
||||
이 프로젝트의 방향은 "파일 스냅샷"이 아니라 "시점 조회 가능한 버전 DB"다.
|
||||
@@ -1,5 +1,12 @@
|
||||
# 인프라 검증 체크리스트
|
||||
|
||||
## 현재 확인 상태
|
||||
- 2026-03-27 기준 `docker compose ps` 에서 `proxy`, `frontend`, `backend`, `db` 모두 `healthy`
|
||||
- 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](DEV_PROD_DB_PROTOCOL.md) 기준으로 관리
|
||||
|
||||
## 1. 컨테이너 기동
|
||||
- `docker compose build`
|
||||
- `docker compose up -d`
|
||||
@@ -32,4 +39,8 @@
|
||||
- 확인 기준:
|
||||
- DB 데이터 유지
|
||||
- 업로드 파일 유지
|
||||
- 스냅샷 파일 유지
|
||||
|
||||
## 6. 제외 또는 후속 검증 항목
|
||||
- 월간 스냅샷 파일 유지 검증은 현재 코드 기준 미구현 항목
|
||||
- 스냅샷 기능을 다시 범위에 넣을 경우 별도 API/파일 경로/다운로드 검증 절차를 추가해야 함
|
||||
- `8081`에서 조직도, 멤버, 자리배치도 검증 전에는 `8080` 정본 DB 기준 동기화가 필요함
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
# Next Session Checkpoint
|
||||
|
||||
## Current Base
|
||||
|
||||
- branch: `total`
|
||||
- latest integration commit: `61b5638`
|
||||
- main history doc: [DEVELOPMENT_HISTORY.md](/home/hyunho/projects/mh-dashboard-organization/docs/DEVELOPMENT_HISTORY.md)
|
||||
|
||||
## 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`
|
||||
|
||||
### External Access
|
||||
|
||||
- WSL 내부 8080 리슨 확인
|
||||
- Windows `portproxy`를 이용해 다른 PC에서 접속 가능하게 설정
|
||||
- 현재 기준 주소:
|
||||
- `http://172.16.40.144:8080`
|
||||
|
||||
## Important Runtime Notes
|
||||
|
||||
### 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` 실제 인증 체계 전환
|
||||
- `#6` 4개 기능 통합 대시보드 프레임 및 공통 헤더 구축
|
||||
- `#7` 자리배치도 팀별 색상 오버레이 표시
|
||||
- `#8` 자리배치도 좌석 클릭 시 개인 상위 조직 트리 표시
|
||||
- `#9` 조직도·자리배치도 변경 이력 버전 누적 저장
|
||||
|
||||
## Unfinished Ideas Discussed Today
|
||||
|
||||
### Seat Map UX
|
||||
|
||||
- 자리배치도 내 인원 등록 시 팀별 색상 표시
|
||||
- 좌석 클릭 시 본인까지의 상위 조직 트리 표시
|
||||
- 나머지 사무실 2개 도면 추가
|
||||
- `한맥빌딩 7층`
|
||||
- `한맥빌딩 6층`
|
||||
- 비관리자 열람 화면 품질 추가 점검
|
||||
|
||||
### History / Versioning
|
||||
|
||||
- 조직도와 자리배치도 수정 이력을 버전 누적형으로 저장
|
||||
- 원본 DB와 별도의 history/snapshot 구조 설계
|
||||
- 날짜/버전 형식 예:
|
||||
- `00.00.00`
|
||||
- 또는 날짜 기반 revision
|
||||
|
||||
### Project Analysis Accuracy
|
||||
|
||||
- 총합은 거의 맞았지만 일부 프로젝트 단위 소수점/분류 오차는 추가 정밀 보정 필요
|
||||
- `opayment` 기준으로 특정 프로젝트 차이를 계속 줄여야 함
|
||||
|
||||
### Auth / Permission
|
||||
|
||||
- mock login을 실제 인증 체계로 전환
|
||||
- 역할별 접근 제어 정리
|
||||
- 조직도/자리배치도/분석 화면 권한 경계 재정리
|
||||
|
||||
## Recommended Next Work Order
|
||||
|
||||
1. 자리배치도 저장/표시를 브라우저에서 한 번 더 실사용 검증
|
||||
2. `#7`, `#8`, `#9` 중 우선순위 확정
|
||||
3. 프로젝트별 분석 남은 오차 정밀 보정
|
||||
4. 실제 인증 체계 설계/구현
|
||||
|
||||
## Quick Resume Prompt
|
||||
|
||||
다음 세션 시작 시 아래 기준으로 이어가면 된다.
|
||||
|
||||
- 브랜치 `total`에서 시작
|
||||
- 최근 커밋 `61b5638` 확인
|
||||
- `docs/DEVELOPMENT_HISTORY.md`
|
||||
- `docs/NEXT_SESSION_CHECKPOINT.md`
|
||||
- Gitea 이슈 `#7`, `#8`, `#9`
|
||||
|
||||
그리고 먼저 현재 외부 접속과 자리배치 저장이 정상인지 확인한 뒤 다음 기능 개발로 넘어간다.
|
||||
170
docs/REGRESSION_CHECKLIST.md
Normal file
170
docs/REGRESSION_CHECKLIST.md
Normal file
@@ -0,0 +1,170 @@
|
||||
# 회귀 검증 체크리스트
|
||||
|
||||
## 목적
|
||||
|
||||
- 새 기능을 추가하거나 기존 기능을 수정할 때, 이전에 되던 핵심 기능이 깨졌는지 빠르게 확인한다.
|
||||
- `8081` 작업용에서 검증한 결과를 신뢰할 수 있도록 `환경`, `데이터`, `핵심 시나리오`를 고정한다.
|
||||
- 완료 판단을 감이 아니라 반복 가능한 체크 절차로 바꾼다.
|
||||
|
||||
## 적용 원칙
|
||||
|
||||
- 코드 수정은 먼저 `8081`에서 수행한다.
|
||||
- 데이터 기준은 항상 `8080` 공개용 DB를 따른다.
|
||||
- 검증 전에는 작업 범위에 맞는 DB 동기화를 먼저 수행한다.
|
||||
- 기능 수정 후에는 관련 화면만 보지 말고, 이 문서의 핵심 시나리오를 함께 확인한다.
|
||||
|
||||
관련 문서:
|
||||
|
||||
- [DEV_PROD_DB_PROTOCOL.md](DEV_PROD_DB_PROTOCOL.md)
|
||||
- [INFRA_VALIDATION_CHECKLIST.md](INFRA_VALIDATION_CHECKLIST.md)
|
||||
|
||||
## 작업 시작 전
|
||||
|
||||
### 1. 서버 상태 확인
|
||||
|
||||
- `8081` 작업용 접속 확인
|
||||
- `8080` 공개용 접속 확인
|
||||
- `docker compose ps`에서 `backend`, `frontend`, `proxy`, `db`가 정상인지 확인
|
||||
- `8081`은 기본적으로 `./scripts/start_8081.sh` 또는 `./scripts/prepare_dev_worktree.sh` 후 `.dev-worktree-8081` 에서 `docker compose -p mh-dashboard-organization-dev --env-file .env -f docker-compose.8081.yml up -d --build` 로 기동
|
||||
- `8081` 기동 후 `docker inspect mh-dashboard-organization-dev-backend-1`에서 마운트 경로가 `.dev-worktree-8081/...`인지 확인
|
||||
- 구조 정리, 라우트 분리, 기본 원본 API 변경 후에는 먼저 `./scripts/check_8081_smoke.sh` 를 실행한다
|
||||
|
||||
### 2. 데이터 동기화 범위 결정
|
||||
|
||||
- 조직도, 관리자모드, 자리배치도 작업 전:
|
||||
- `./scripts/sync_prod_db_to_dev.sh minimal`
|
||||
- 프로젝트별 분석, 팀/개인별 분석 작업 전:
|
||||
- `./scripts/sync_prod_db_to_dev.sh analysis`
|
||||
- 공개용 기준 전체 데이터 재검증이 필요한 경우만:
|
||||
- `./scripts/sync_prod_db_to_dev.sh full`
|
||||
|
||||
### 3. 기준 고정
|
||||
|
||||
- 어느 서버에서 재현했는지 기록
|
||||
- 어떤 데이터 동기화 범위로 검증했는지 기록
|
||||
- 브라우저 캐시 영향을 피하려면 강력 새로고침 후 확인
|
||||
|
||||
## 공통 회귀 시나리오
|
||||
|
||||
기능 수정 후 아래 항목을 최소한 확인한다.
|
||||
|
||||
### A. 허브 및 공통 진입
|
||||
|
||||
- 메인 허브가 정상 렌더링된다.
|
||||
- 상단 탭 이동이 정상 동작한다.
|
||||
- 로그인 상태가 비정상적으로 풀리지 않는다.
|
||||
- `DB 상태` 탭이 정상 렌더링된다.
|
||||
|
||||
### B. 조직현황
|
||||
|
||||
- 조직도 트리가 정상 표시된다.
|
||||
- 관리자모드 진입이 가능하다.
|
||||
- 대상인원 클릭 시 기본정보 모달이 열린다.
|
||||
- `+` 신규 구성원 추가 모달이 열린다.
|
||||
- 기본정보 저장이 정상 동작한다.
|
||||
|
||||
### C. 자리배치도
|
||||
|
||||
- `기술개발센터`, `한맥빌딩 6층`, `한맥빌딩 7층` 도면이 모두 열린다.
|
||||
- 미배치 인원 목록이 정상 표시된다.
|
||||
- 미배치 인원을 chair에 드래그앤드롭할 수 있다.
|
||||
- 드롭 후:
|
||||
- 미배치 목록에서 사라진다.
|
||||
- chair에 배치 상태가 표시된다.
|
||||
- 이름/직급 표기가 보인다.
|
||||
- 배치된 좌석 클릭 후 해제 또는 수정 흐름이 정상 동작한다.
|
||||
|
||||
### D. 조직도와 자리배치 연동
|
||||
|
||||
- 조직도에서 인원 클릭 시 상세 정보가 열린다.
|
||||
- 재석위치 미리보기가 표시된다.
|
||||
- 좌석이 배정된 인원은 해당 자리로 줌인된다.
|
||||
|
||||
### E. 프로젝트별 분석
|
||||
|
||||
- 월 선택이 정상 동작한다.
|
||||
- 프로젝트 목록과 합계가 비어 있지 않다.
|
||||
- `1월`, `2월` 데이터가 현재 기준값과 일치한다.
|
||||
|
||||
현재 기준 검증값:
|
||||
|
||||
- `2026-01`
|
||||
- 시간 `37,342.39`
|
||||
- 인건비 `1,391,966,625`
|
||||
- `2026-02`
|
||||
- 시간 `29,060.59`
|
||||
- 인건비 `1,078,337,651`
|
||||
|
||||
### F. 팀/개인별 분석
|
||||
|
||||
- `전체`, `GPD`, `TDC` 버튼이 순서대로 보인다.
|
||||
- `전체`에서 모든 팀이 노출된다.
|
||||
- `GPD`, `TDC` 선택 시 각 소속 범위만 버튼 기준으로 보인다.
|
||||
- 검색은 버튼 상태와 무관하게 전체 데이터를 검색한다.
|
||||
|
||||
### G. 사업관리대장 기본 원본
|
||||
|
||||
- `/api/integration/business-ledger-default` 가 `200` 이어야 한다.
|
||||
- `사업관리대장` 탭 진입 시 기본 원본이 비어 있지 않다.
|
||||
- 기본 원본 응답은 바이너리 XLSX 시그니처(`PK`)를 반환해야 한다.
|
||||
|
||||
## 작업 유형별 필수 추가 확인
|
||||
|
||||
### 조직도 / 관리자모드 수정 시
|
||||
|
||||
- 대상인원 수정 모달 레이아웃이 깨지지 않는지 확인
|
||||
- 신규 구성원 추가 모달도 같은 레이아웃으로 보이는지 확인
|
||||
- 저장 후 목록 반영이 정상인지 확인
|
||||
|
||||
### 자리배치도 수정 시
|
||||
|
||||
- viewer iframe 로드 여부 확인
|
||||
- 드래그앤드롭 이후 배치 상태가 즉시 반영되는지 확인
|
||||
- 조직도 상세 재석위치 preview까지 같이 확인
|
||||
|
||||
### 분석 로직 수정 시
|
||||
|
||||
- 작업 전에 반드시 `analysis` 또는 `full` 동기화 수행
|
||||
- 월별 합계 검증값 재확인
|
||||
- 원본 기준과 차이가 있으면 반올림, 제외 인원, 가공시간 규칙부터 점검
|
||||
|
||||
## 완료 처리 기준
|
||||
|
||||
수정 사항을 완료로 판단하려면 아래를 모두 만족해야 한다.
|
||||
|
||||
- 수정한 기능이 의도대로 동작한다.
|
||||
- 관련 공통 회귀 시나리오가 깨지지 않는다.
|
||||
- 필요한 경우 `8081`에서 검증 결과를 숫자 또는 화면 기준으로 기록한다.
|
||||
- 이후에만 `8080` 공개용 반영 여부를 판단한다.
|
||||
|
||||
## 장애 원인 분류 기준
|
||||
|
||||
문제가 생기면 먼저 아래 셋 중 어디인지 분리한다.
|
||||
|
||||
- 코드 차이
|
||||
- `8080`, `8081`의 정적 파일 또는 백엔드 로직이 다름
|
||||
- DB 차이
|
||||
- `members`, `seat_maps`, `integration_*` 등 기준 데이터가 다름
|
||||
- 캐시 또는 런타임 상태
|
||||
- 정적 파일 캐시, 컨테이너 재시작 미반영, 브라우저 세션 상태 문제
|
||||
|
||||
이 분류를 먼저 해야 원인을 잘못 짚지 않는다.
|
||||
|
||||
## 권장 기록 방식
|
||||
|
||||
작업 종료 시 아래 형식으로 남긴다.
|
||||
|
||||
```text
|
||||
작업 범위:
|
||||
- 예: 조직현황 관리자모드 기본정보 모달 레이아웃 변경
|
||||
|
||||
검증 환경:
|
||||
- 서버: 8081
|
||||
- DB 동기화: minimal / analysis / full 중 무엇을 사용했는지
|
||||
|
||||
검증 결과:
|
||||
- 조직도: 정상
|
||||
- 관리자모드 모달: 정상
|
||||
- 자리배치도 연동: 정상 또는 미검증
|
||||
- 프로젝트별 분석: 정상 또는 미검증
|
||||
```
|
||||
172
docs/TEAM_GUIDE.md
Normal file
172
docs/TEAM_GUIDE.md
Normal 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)
|
||||
@@ -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`
|
||||
118
docs/architecture/8081_SERVING_MAP.md
Normal file
118
docs/architecture/8081_SERVING_MAP.md
Normal 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 상태를 화면에서 바로 보여준다.
|
||||
- 기존 참고 자산을 지우지 않고도 실제 서빙 경로와 참고 경로를 구분할 수 있다.
|
||||
205
docs/architecture/DB_TABLE_CATALOG.md
Normal file
205
docs/architecture/DB_TABLE_CATALOG.md
Normal 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 상태` 화면에서 이 분류를 기준으로 계속 설명 유지
|
||||
129
docs/architecture/DESIGN_SSOT.md
Normal file
129
docs/architecture/DESIGN_SSOT.md
Normal 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
|
||||
7
frontend/apps/db-status/README.md
Normal file
7
frontend/apps/db-status/README.md
Normal 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 저장 구조와 적재 상태를 사람이 읽을 수 있게 보여준다.
|
||||
918
frontend/apps/db-status/index.html
Normal file
918
frontend/apps/db-status/index.html
Normal 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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
return new Intl.NumberFormat("ko-KR").format(Number(value || 0));
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) return '<span class="muted">-</span>';
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return escapeHtml(value);
|
||||
return parsed.toLocaleString("ko-KR", { hour12: false });
|
||||
}
|
||||
|
||||
function formatBytes(value) {
|
||||
const size = Number(value || 0);
|
||||
if (size <= 0) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
let current = size;
|
||||
let unit = 0;
|
||||
while (current >= 1024 && unit < units.length - 1) {
|
||||
current /= 1024;
|
||||
unit += 1;
|
||||
}
|
||||
return `${current.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`;
|
||||
}
|
||||
|
||||
function renderOverview(overview) {
|
||||
const target = document.getElementById("overview");
|
||||
target.innerHTML = [
|
||||
["핵심 테이블", overview.visible_tables],
|
||||
["전체 테이블", overview.total_tables],
|
||||
["등록 인원", overview.registered_members],
|
||||
["재직 인원", overview.active_members],
|
||||
["고정 오피스 도면", overview.fixed_office_maps],
|
||||
["현재 active 도면", overview.active_seat_maps],
|
||||
["Import 배치", overview.import_batches],
|
||||
["바이너리 원본", overview.binary_sources],
|
||||
].map(([label, value]) => `
|
||||
<article class="kpi">
|
||||
<span class="kpi-label">${escapeHtml(label)}</span>
|
||||
<span class="kpi-value">${formatNumber(value)}</span>
|
||||
</article>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function renderTables(items) {
|
||||
const target = document.getElementById("table-body");
|
||||
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>
|
||||
26
frontend/apps/ledger/README.md
Normal file
26
frontend/apps/ledger/README.md
Normal 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` 이후 `사업관리대장`을 화면별 앱 구조로 승격하기 위한 첫 단계다.
|
||||
- 아직 프레임워크 앱은 아니고, 독립 관리되는 정식 화면 소스 디렉터리다.
|
||||
1377
frontend/apps/ledger/assets/MH 통합 대시보드_260320.css
Normal file
1377
frontend/apps/ledger/assets/MH 통합 대시보드_260320.css
Normal file
File diff suppressed because it is too large
Load Diff
328
frontend/apps/ledger/assets/ledger-override.css
Normal file
328
frontend/apps/ledger/assets/ledger-override.css
Normal 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%;
|
||||
}
|
||||
}
|
||||
661
frontend/apps/ledger/assets/ledger-override.js
Normal file
661
frontend/apps/ledger/assets/ledger-override.js
Normal 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);
|
||||
});
|
||||
})();
|
||||
954
frontend/apps/ledger/index.html
Normal file
954
frontend/apps/ledger/index.html
Normal 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,"&").replace(/</g,"<").replace(/>/g,">");
|
||||
const escAttr=v=>esc(v).replace(/"/g,""");
|
||||
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&¤t) 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&¬e) 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>
|
||||
16
frontend/apps/organization/README.md
Normal file
16
frontend/apps/organization/README.md
Normal 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`를 실행해 서빙 파일로 반영한다.
|
||||
110
frontend/apps/organization/assets/common.css
Normal file
110
frontend/apps/organization/assets/common.css
Normal 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);
|
||||
}
|
||||
1744
frontend/apps/organization/assets/organization.css
Normal file
1744
frontend/apps/organization/assets/organization.css
Normal file
File diff suppressed because it is too large
Load Diff
1979
frontend/apps/organization/assets/organization.js
Normal file
1979
frontend/apps/organization/assets/organization.js
Normal file
File diff suppressed because it is too large
Load Diff
65
frontend/apps/organization/index.html
Normal file
65
frontend/apps/organization/index.html
Normal 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>
|
||||
18
frontend/apps/payment/README.md
Normal file
18
frontend/apps/payment/README.md
Normal 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 한다.
|
||||
1622
frontend/apps/payment/index.html
Normal file
1622
frontend/apps/payment/index.html
Normal file
File diff suppressed because it is too large
Load Diff
18
frontend/apps/team/README.md
Normal file
18
frontend/apps/team/README.md
Normal 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 한다.
|
||||
3472
frontend/apps/team/index.html
Normal file
3472
frontend/apps/team/index.html
Normal file
File diff suppressed because it is too large
Load Diff
@@ -11,13 +11,20 @@ const currentViewTitle = document.getElementById("current-view-title");
|
||||
const globalDateControls = document.getElementById("global-date-controls");
|
||||
const globalStartDateInput = document.getElementById("global-start-date");
|
||||
const globalEndDateInput = document.getElementById("global-end-date");
|
||||
const organizationHistoryControls = document.getElementById("organization-history-controls");
|
||||
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");
|
||||
@@ -43,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"),
|
||||
@@ -68,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"),
|
||||
@@ -93,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;
|
||||
@@ -101,8 +111,8 @@ const APP_BASE_URL = String(window.__MH_BASE_URL || "").replace(/\/$/, "");
|
||||
|
||||
const seatMapOffices = [
|
||||
{ key: "technical-development-center", label: "기술개발센터", ready: true },
|
||||
{ key: "hanmac-building-7f", label: "한맥빌딩 7층", ready: false },
|
||||
{ key: "hanmac-building-6f", label: "한맥빌딩 6층", ready: false },
|
||||
{ key: "hanmac-building-6f", label: "한맥빌딩 6층", ready: true },
|
||||
{ key: "hanmac-building-7f", label: "한맥빌딩 7층", ready: true },
|
||||
];
|
||||
|
||||
const viewLabels = {
|
||||
@@ -110,6 +120,7 @@ const viewLabels = {
|
||||
project: "프로젝트별 분석",
|
||||
team: "팀/개인별 분석",
|
||||
organization: "조직 현황",
|
||||
"db-status": "DB 상태",
|
||||
"seatmap-admin": "자리배치도",
|
||||
"seatmap-readonly": "자리배치도",
|
||||
};
|
||||
@@ -127,6 +138,7 @@ const seatMapState = {
|
||||
search: "",
|
||||
status: "",
|
||||
statusTone: "info",
|
||||
selectedMemberId: null,
|
||||
draggingMemberId: null,
|
||||
zoom: 1,
|
||||
panning: false,
|
||||
@@ -148,13 +160,152 @@ const seatMapState = {
|
||||
forceReadOnly: false,
|
||||
};
|
||||
|
||||
let currentView = "organization";
|
||||
let currentView = "ledger";
|
||||
const globalDateState = {
|
||||
loaded: true,
|
||||
startDate: "2026-01-01",
|
||||
endDate: "2026-01-31",
|
||||
loaded: false,
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
};
|
||||
|
||||
const organizationHistoryState = {
|
||||
selectedMonth: "",
|
||||
currentMonth: "",
|
||||
};
|
||||
|
||||
function padDatePart(value) {
|
||||
return String(value).padStart(2, "0");
|
||||
}
|
||||
|
||||
function getCurrentMonthValue() {
|
||||
const now = new Date();
|
||||
return `${now.getFullYear()}-${padDatePart(now.getMonth() + 1)}`;
|
||||
}
|
||||
|
||||
function getMonthLabel(monthValue) {
|
||||
const [, month] = String(monthValue || "").split("-");
|
||||
if (!month) return "";
|
||||
const monthNumber = Number(month);
|
||||
if (!Number.isInteger(monthNumber) || monthNumber <= 0) return "";
|
||||
return `${monthNumber}월`;
|
||||
}
|
||||
|
||||
function getMonthEndDate(monthValue) {
|
||||
const [yearText, monthText] = String(monthValue || "").split("-");
|
||||
const year = Number(yearText);
|
||||
const month = Number(monthText);
|
||||
if (!Number.isInteger(year) || !Number.isInteger(month) || month <= 0) return "";
|
||||
const lastDay = new Date(year, month, 0);
|
||||
return `${lastDay.getFullYear()}-${padDatePart(lastDay.getMonth() + 1)}-${padDatePart(lastDay.getDate())}`;
|
||||
}
|
||||
|
||||
function getTodayDate() {
|
||||
const now = new Date();
|
||||
return `${now.getFullYear()}-${padDatePart(now.getMonth() + 1)}-${padDatePart(now.getDate())}`;
|
||||
}
|
||||
|
||||
function parseDateOnly(value) {
|
||||
const raw = String(value || "").trim();
|
||||
if (!raw) return null;
|
||||
const match = raw.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||
if (!match) return null;
|
||||
const year = Number(match[1]);
|
||||
const monthIndex = Number(match[2]) - 1;
|
||||
const day = Number(match[3]);
|
||||
const parsed = new Date(year, monthIndex, day);
|
||||
if (
|
||||
Number.isNaN(parsed.getTime())
|
||||
|| parsed.getFullYear() !== year
|
||||
|| parsed.getMonth() !== monthIndex
|
||||
|| parsed.getDate() !== day
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function formatDateOnly(date) {
|
||||
if (!(date instanceof Date) || Number.isNaN(date.getTime())) return "";
|
||||
return `${date.getFullYear()}-${padDatePart(date.getMonth() + 1)}-${padDatePart(date.getDate())}`;
|
||||
}
|
||||
|
||||
function getMonthRangeForDate(date) {
|
||||
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
|
||||
return { startDate: "", endDate: "" };
|
||||
}
|
||||
const start = new Date(date.getFullYear(), date.getMonth(), 1);
|
||||
const end = new Date(date.getFullYear(), date.getMonth() + 1, 0);
|
||||
return {
|
||||
startDate: formatDateOnly(start),
|
||||
endDate: formatDateOnly(end),
|
||||
};
|
||||
}
|
||||
|
||||
function getPreviousMonthRange(baseDate = new Date()) {
|
||||
return getMonthRangeForDate(new Date(baseDate.getFullYear(), baseDate.getMonth() - 1, 1));
|
||||
}
|
||||
|
||||
function resolveLatestCompletedMonthRange(maxDateText) {
|
||||
const fallbackRange = getPreviousMonthRange();
|
||||
const latestAvailableDate = parseDateOnly(String(maxDateText || "").slice(0, 10));
|
||||
if (!latestAvailableDate) return fallbackRange;
|
||||
const fallbackStart = parseDateOnly(fallbackRange.startDate);
|
||||
if (fallbackStart && latestAvailableDate >= fallbackStart) {
|
||||
return fallbackRange;
|
||||
}
|
||||
return getMonthRangeForDate(latestAvailableDate);
|
||||
}
|
||||
|
||||
function syncOrganizationHistoryControls() {
|
||||
if (!organizationHistoryControls) return;
|
||||
const visible = currentView === "organization";
|
||||
organizationHistoryControls.classList.toggle("hidden", !visible);
|
||||
if (organizationCompareBtn) {
|
||||
const isCurrentMonth = !organizationHistoryState.selectedMonth || organizationHistoryState.selectedMonth === organizationHistoryState.currentMonth;
|
||||
organizationCompareBtn.classList.toggle("hidden", !visible || isCurrentMonth);
|
||||
}
|
||||
}
|
||||
|
||||
function initializeOrganizationMonthOptions() {
|
||||
if (!organizationMonthSelect) return;
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const monthCount = now.getMonth() + 1;
|
||||
organizationMonthSelect.innerHTML = "";
|
||||
for (let month = monthCount; month >= 1; month -= 1) {
|
||||
const value = `${year}-${padDatePart(month)}`;
|
||||
const option = document.createElement("option");
|
||||
option.value = value;
|
||||
option.textContent = month === monthCount ? `${month}월 (최신)` : `${month}월`;
|
||||
organizationMonthSelect.append(option);
|
||||
}
|
||||
organizationHistoryState.currentMonth = `${year}-${padDatePart(monthCount)}`;
|
||||
organizationHistoryState.selectedMonth = organizationHistoryState.currentMonth;
|
||||
organizationMonthSelect.value = organizationHistoryState.selectedMonth;
|
||||
syncOrganizationHistoryControls();
|
||||
}
|
||||
|
||||
function postOrganizationHistoryState() {
|
||||
if (!organizationFrame?.contentWindow) return;
|
||||
const selectedMonth = organizationHistoryState.selectedMonth || organizationHistoryState.currentMonth;
|
||||
const currentMonth = organizationHistoryState.currentMonth || getCurrentMonthValue();
|
||||
const isHistorical = Boolean(selectedMonth) && selectedMonth !== currentMonth;
|
||||
organizationFrame.contentWindow.postMessage(
|
||||
{
|
||||
source: "total-control",
|
||||
type: "organization-history-view",
|
||||
month: selectedMonth,
|
||||
asOfDate: isHistorical ? getMonthEndDate(selectedMonth) : "",
|
||||
historical: isHistorical,
|
||||
},
|
||||
window.location.origin,
|
||||
);
|
||||
syncOrganizationHistoryControls();
|
||||
}
|
||||
|
||||
function getGlobalAsOfDate() {
|
||||
return globalDateState.endDate || "";
|
||||
}
|
||||
|
||||
function getSession() {
|
||||
try {
|
||||
return JSON.parse(sessionStorage.getItem(sessionKey) || "null");
|
||||
@@ -181,12 +332,15 @@ function buildAuthHeaders(headers) {
|
||||
}
|
||||
|
||||
function shouldShowGlobalDateControls() {
|
||||
return currentView === "ledger" || currentView === "project" || currentView === "team" || currentView === "organization";
|
||||
return currentView === "ledger"
|
||||
|| currentView === "project"
|
||||
|| currentView === "team";
|
||||
}
|
||||
|
||||
function syncGlobalDateControlVisibility() {
|
||||
if (!globalDateControls) return;
|
||||
globalDateControls.classList.toggle("hidden", !shouldShowGlobalDateControls());
|
||||
syncOrganizationHistoryControls();
|
||||
}
|
||||
|
||||
function syncGlobalDateControlInputs() {
|
||||
@@ -208,7 +362,21 @@ function postGlobalDateRangeToFrame(frame) {
|
||||
frame.contentWindow.postMessage(getGlobalDateRangePayload(), window.location.origin);
|
||||
}
|
||||
|
||||
function buildAsOfQuery() {
|
||||
const asOf = getGlobalAsOfDate();
|
||||
if (!asOf) return "";
|
||||
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);
|
||||
}
|
||||
@@ -217,16 +385,59 @@ 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 {
|
||||
const payload = await fetchJson("/api/integration/summary");
|
||||
const work = payload?.date_ranges?.work || {};
|
||||
const voucher = payload?.date_ranges?.voucher || {};
|
||||
const starts = [work.min_work_date, voucher.min_voucher_date].filter(Boolean).sort();
|
||||
const ends = [work.max_work_date, voucher.max_voucher_date].filter(Boolean).sort();
|
||||
globalDateState.startDate = starts[0] ? String(starts[0]).slice(0, 10) : "";
|
||||
globalDateState.endDate = ends.length ? String(ends[ends.length - 1]).slice(0, 10) : "";
|
||||
const defaultRange = resolveLatestCompletedMonthRange(ends.length ? ends[ends.length - 1] : "");
|
||||
globalDateState.startDate = defaultRange.startDate;
|
||||
globalDateState.endDate = defaultRange.endDate;
|
||||
globalDateState.loaded = true;
|
||||
syncGlobalDateControlInputs();
|
||||
postGlobalDateRangeToFrame(projectFrame);
|
||||
@@ -284,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;
|
||||
@@ -630,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;
|
||||
@@ -648,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;
|
||||
@@ -686,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);
|
||||
});
|
||||
}
|
||||
@@ -694,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);
|
||||
});
|
||||
@@ -717,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}`);
|
||||
@@ -777,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>
|
||||
`;
|
||||
@@ -789,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>
|
||||
`;
|
||||
}
|
||||
@@ -801,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>
|
||||
`;
|
||||
}
|
||||
@@ -828,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>
|
||||
@@ -846,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>
|
||||
`;
|
||||
}
|
||||
@@ -858,9 +1228,10 @@ function renderDxfSeatMapBoard() {
|
||||
seatMapBoard.innerHTML = `<div class="seatmap-empty-card"><strong>DXF 뷰어 데이터를 준비하지 못했습니다.</strong></div>`;
|
||||
return;
|
||||
}
|
||||
const viewerUrl = resolveAppUrl(`/api/seat-maps/${seatMapState.seatMap.id}/viewer`);
|
||||
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>
|
||||
<iframe
|
||||
id="seatmap-dxf-frame"
|
||||
class="seatmap-dxf-frame"
|
||||
@@ -874,6 +1245,12 @@ function renderDxfSeatMapBoard() {
|
||||
setupSeatMapViewerFrame();
|
||||
}
|
||||
|
||||
function setSeatMapDropOverlayActive(active) {
|
||||
const overlay = seatMapBoard?.querySelector("[data-seatmap-drop-overlay]");
|
||||
if (!overlay) return;
|
||||
overlay.classList.toggle("is-active", Boolean(active && seatMapState.editMode));
|
||||
}
|
||||
|
||||
function getDraftPlacedSlotKeys() {
|
||||
const slotMap = getSeatSlotMap();
|
||||
return (seatMapState.draftPlacements || [])
|
||||
@@ -917,6 +1294,13 @@ function syncSeatMapViewerFrame() {
|
||||
);
|
||||
}
|
||||
|
||||
function scheduleSeatMapViewerSync() {
|
||||
syncSeatMapViewerFrame();
|
||||
window.setTimeout(() => {
|
||||
syncSeatMapViewerFrame();
|
||||
}, 80);
|
||||
}
|
||||
|
||||
function renderSeatMapActions() {
|
||||
const hasSeatMap = Boolean(seatMapState.seatMap);
|
||||
const adminMode = isSeatMapAdminMode();
|
||||
@@ -935,11 +1319,13 @@ function renderSeatMapActions() {
|
||||
function updateSeatMapDraftUi() {
|
||||
renderSeatMapActions();
|
||||
renderUnassignedMembers();
|
||||
renderSeatMapMemberContext(seatMapState.selectedMemberId);
|
||||
syncSeatMapViewerFrame();
|
||||
}
|
||||
|
||||
function setupSeatMapViewerFrame() {
|
||||
const frame = seatMapBoard?.querySelector("#seatmap-dxf-frame");
|
||||
const overlay = seatMapBoard?.querySelector("[data-seatmap-drop-overlay]");
|
||||
if (!frame) return;
|
||||
|
||||
frame.addEventListener("load", () => {
|
||||
@@ -950,26 +1336,42 @@ function setupSeatMapViewerFrame() {
|
||||
const canvas = frameDocument?.getElementById("canvas");
|
||||
if (!frameWindow || !frameDocument || !canvas || !frameWindow.__mhSeatmap) return;
|
||||
|
||||
canvas.addEventListener("dragover", (event) => {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "move";
|
||||
});
|
||||
|
||||
canvas.addEventListener("drop", (event) => {
|
||||
const handleDrop = (event) => {
|
||||
event.preventDefault();
|
||||
const memberId = getDraggedMemberId(event);
|
||||
if (!memberId) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
if (!memberId) {
|
||||
setSeatMapStatus("드롭 감지됨: memberId를 읽지 못했습니다.", "error");
|
||||
return;
|
||||
}
|
||||
const frameRect = frame.getBoundingClientRect();
|
||||
const canvasRect = canvas.getBoundingClientRect();
|
||||
const picked = frameWindow.__mhSeatmap.pickChairAt(
|
||||
event.clientX - rect.left,
|
||||
event.clientY - rect.top,
|
||||
event.clientX - frameRect.left - canvasRect.left,
|
||||
event.clientY - frameRect.top - canvasRect.top,
|
||||
);
|
||||
if (!picked?.key) return;
|
||||
if (!picked?.key) {
|
||||
setSeatMapStatus(`드롭 감지됨: 좌석 인식 실패 (memberId=${memberId})`, "error");
|
||||
return;
|
||||
}
|
||||
const matchedSlot = (seatMapState.slots || []).find((item) => String(item.slot_key) === String(picked.key));
|
||||
if (!matchedSlot) return;
|
||||
if (!matchedSlot) {
|
||||
setSeatMapStatus(`드롭 감지됨: slot 매칭 실패 (${picked.key})`, "error");
|
||||
return;
|
||||
}
|
||||
upsertDraftPlacementForSlot(memberId, Number(matchedSlot.id));
|
||||
setSeatMapStatus(`드롭 성공: memberId=${memberId}, slot=${picked.key}, slotId=${matchedSlot.id}`, "info");
|
||||
updateSeatMapDraftUi();
|
||||
});
|
||||
};
|
||||
|
||||
const handleDragOver = (event) => {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "move";
|
||||
};
|
||||
|
||||
canvas.addEventListener("dragover", handleDragOver);
|
||||
canvas.addEventListener("drop", handleDrop);
|
||||
overlay?.addEventListener("dragover", handleDragOver);
|
||||
overlay?.addEventListener("drop", handleDrop);
|
||||
}, { once: true });
|
||||
}
|
||||
|
||||
@@ -1137,6 +1539,9 @@ function handleEmbeddedNavigationMessage(event) {
|
||||
updateSeatMapDraftUi();
|
||||
}
|
||||
}
|
||||
if (data.type === "seatmap-member-selected") {
|
||||
renderSeatMapMemberContext(Number(data.memberId || 0) || null);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchJson(url, options) {
|
||||
@@ -1168,24 +1573,9 @@ async function loadSeatMapData(force = false) {
|
||||
|
||||
try {
|
||||
const office = getCurrentSeatMapOffice();
|
||||
if (!office.ready) {
|
||||
const membersPayload = await fetchJson("/api/members");
|
||||
seatMapState.seatMap = null;
|
||||
seatMapState.members = Array.isArray(membersPayload.items) ? membersPayload.items : [];
|
||||
seatMapState.slots = [];
|
||||
seatMapState.placements = [];
|
||||
seatMapState.zoom = 1;
|
||||
seatMapState.hoveredSlotId = null;
|
||||
seatMapState.editMode = canEditSeatMap();
|
||||
resetSeatMapDraft();
|
||||
seatMapState.loaded = true;
|
||||
setSeatMapStatus(`${office.label} 도면은 아직 등록 전입니다.`, "info");
|
||||
renderSeatMap();
|
||||
return;
|
||||
}
|
||||
const activePayload = await fetchJson("/api/seat-maps/active");
|
||||
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`);
|
||||
const layoutPayload = await fetchJson(`/api/seat-maps/${activeSeatMap.id}/layout${buildSeatMapAsOfQuery()}`);
|
||||
seatMapState.seatMap = {
|
||||
...(layoutPayload.seat_map || {}),
|
||||
viewer_data: layoutPayload.viewer_data || null,
|
||||
@@ -1195,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;
|
||||
@@ -1208,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;
|
||||
@@ -1381,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";
|
||||
@@ -1397,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";
|
||||
@@ -1406,14 +1808,20 @@ 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);
|
||||
} else if (isOrganization) {
|
||||
postOrganizationHistoryState();
|
||||
}
|
||||
if (isProject && previousView !== "project" && projectFrame) {
|
||||
const frameSrc = projectFrame.dataset.src || projectFrame.src;
|
||||
@@ -1427,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();
|
||||
}
|
||||
@@ -1479,12 +1891,10 @@ if (loginForm) {
|
||||
body: formData,
|
||||
});
|
||||
setSession(payload);
|
||||
setActiveView("ledger");
|
||||
loginForm.reset();
|
||||
loginMessage.textContent = "";
|
||||
renderAuth();
|
||||
if (currentView === "seatmap-admin" || currentView === "seatmap-readonly") {
|
||||
await loadSeatMapData(true);
|
||||
}
|
||||
} catch (error) {
|
||||
loginMessage.textContent = error.message || "로그인에 실패했습니다.";
|
||||
}
|
||||
@@ -1527,9 +1937,24 @@ if (globalEndDateInput) {
|
||||
globalDateState.endDate = globalEndDateInput.value || "";
|
||||
postGlobalDateRangeToFrame(projectFrame);
|
||||
postGlobalDateRangeToFrame(teamFrame);
|
||||
if (currentView === "seatmap-admin" || currentView === "seatmap-readonly") {
|
||||
seatMapState.loaded = false;
|
||||
loadSeatMapData(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
organizationFrame?.addEventListener("load", () => {
|
||||
postOrganizationHistoryState();
|
||||
});
|
||||
|
||||
ledgerFrame?.addEventListener("load", () => {
|
||||
if (currentView === "ledger") {
|
||||
notifyEmbeddedTabActivated();
|
||||
}
|
||||
void pushDefaultLedgerSourceToFrame(true);
|
||||
});
|
||||
|
||||
projectFrame?.addEventListener("load", () => {
|
||||
postGlobalDateRangeToFrame(projectFrame);
|
||||
if (currentView === "project") {
|
||||
@@ -1551,6 +1976,32 @@ navButtons.forEach((button) => {
|
||||
});
|
||||
});
|
||||
|
||||
if (organizationMonthSelect) {
|
||||
initializeOrganizationMonthOptions();
|
||||
organizationMonthSelect.addEventListener("change", () => {
|
||||
organizationHistoryState.selectedMonth = organizationMonthSelect.value || organizationHistoryState.currentMonth;
|
||||
postOrganizationHistoryState();
|
||||
});
|
||||
}
|
||||
|
||||
if (organizationCompareBtn) {
|
||||
organizationCompareBtn.addEventListener("click", () => {
|
||||
if (!organizationFrame?.contentWindow) return;
|
||||
const fromDate = getMonthEndDate(organizationHistoryState.selectedMonth);
|
||||
const toDate = getTodayDate();
|
||||
if (!fromDate) return;
|
||||
organizationFrame.contentWindow.postMessage(
|
||||
{
|
||||
source: "total-control",
|
||||
type: "open-history-compare",
|
||||
fromDate,
|
||||
toDate,
|
||||
},
|
||||
window.location.origin,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Object.values(seatMapDom).forEach((dom) => {
|
||||
dom.officeTabs?.addEventListener("click", (event) => {
|
||||
const button = event.target.closest("[data-seatmap-office]");
|
||||
@@ -1585,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;
|
||||
@@ -1621,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()
|
||||
@@ -1683,12 +2146,14 @@ document.addEventListener("dragstart", (event) => {
|
||||
const memberId = Number(card.dataset.memberId);
|
||||
if (!memberId) return;
|
||||
seatMapState.draggingMemberId = memberId;
|
||||
setSeatMapDropOverlayActive(true);
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
event.dataTransfer.setData("text/plain", String(memberId));
|
||||
});
|
||||
|
||||
document.addEventListener("dragend", () => {
|
||||
seatMapState.draggingMemberId = null;
|
||||
setSeatMapDropOverlayActive(false);
|
||||
});
|
||||
|
||||
document.addEventListener("click", () => {
|
||||
|
||||
918
frontend/public/db-status.html
Normal file
918
frontend/public/db-status.html
Normal 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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
return new Intl.NumberFormat("ko-KR").format(Number(value || 0));
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) return '<span class="muted">-</span>';
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return escapeHtml(value);
|
||||
return parsed.toLocaleString("ko-KR", { hour12: false });
|
||||
}
|
||||
|
||||
function formatBytes(value) {
|
||||
const size = Number(value || 0);
|
||||
if (size <= 0) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
let current = size;
|
||||
let unit = 0;
|
||||
while (current >= 1024 && unit < units.length - 1) {
|
||||
current /= 1024;
|
||||
unit += 1;
|
||||
}
|
||||
return `${current.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`;
|
||||
}
|
||||
|
||||
function renderOverview(overview) {
|
||||
const target = document.getElementById("overview");
|
||||
target.innerHTML = [
|
||||
["핵심 테이블", overview.visible_tables],
|
||||
["전체 테이블", overview.total_tables],
|
||||
["등록 인원", overview.registered_members],
|
||||
["재직 인원", overview.active_members],
|
||||
["고정 오피스 도면", overview.fixed_office_maps],
|
||||
["현재 active 도면", overview.active_seat_maps],
|
||||
["Import 배치", overview.import_batches],
|
||||
["바이너리 원본", overview.binary_sources],
|
||||
].map(([label, value]) => `
|
||||
<article class="kpi">
|
||||
<span class="kpi-label">${escapeHtml(label)}</span>
|
||||
<span class="kpi-value">${formatNumber(value)}</span>
|
||||
</article>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function renderTables(items) {
|
||||
const target = document.getElementById("table-body");
|
||||
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>
|
||||
730
frontend/public/design-patterns.css
Normal file
730
frontend/public/design-patterns.css
Normal 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;
|
||||
}
|
||||
}
|
||||
60
frontend/public/design-tokens.css
Normal file
60
frontend/public/design-tokens.css
Normal 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;
|
||||
}
|
||||
@@ -3,12 +3,22 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>MH 조직현황 대시보드</title>
|
||||
<title>MH 대시보드-공개용</title>
|
||||
<script>
|
||||
document.title = window.location.port === '8081'
|
||||
? 'MH 대시보드-작업용'
|
||||
: 'MH 대시보드-공개용';
|
||||
</script>
|
||||
<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=20260326-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">
|
||||
@@ -53,6 +63,14 @@
|
||||
<input id="global-end-date" type="date" aria-label="종료일">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="organization-history-controls" class="header-date-controls hidden">
|
||||
<span class="header-date-label">조직 기준월</span>
|
||||
<label class="header-date-field">
|
||||
<select id="organization-month-select" aria-label="조직 기준월"></select>
|
||||
</label>
|
||||
<button id="organization-compare-btn" class="ghost-button ghost-button-soft hidden" type="button">조직도 변경사항 확인</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
@@ -61,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">
|
||||
@@ -78,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=20260326-02" data-src="/legacy/organization?v=20260326-02" 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">
|
||||
@@ -149,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>
|
||||
@@ -188,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>
|
||||
@@ -200,6 +234,6 @@
|
||||
</main>
|
||||
</section>
|
||||
|
||||
<script src="/app.js?v=20260326-02"></script>
|
||||
<script src="/app.js?v=20260401-02"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
100
frontend/public/styles-8081-design.css
Normal file
100
frontend/public/styles-8081-design.css
Normal 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);
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -306,16 +333,33 @@ body {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.header-date-field select {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
outline: none;
|
||||
appearance: none;
|
||||
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;
|
||||
@@ -331,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);
|
||||
}
|
||||
@@ -352,7 +396,7 @@ body {
|
||||
}
|
||||
|
||||
.ghost-button-soft {
|
||||
background: #f8fafc;
|
||||
background: var(--color-surface-soft);
|
||||
}
|
||||
|
||||
.user-chip {
|
||||
@@ -370,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;
|
||||
@@ -410,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;
|
||||
}
|
||||
@@ -429,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 {
|
||||
@@ -443,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;
|
||||
@@ -474,7 +518,7 @@ body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
background: #fff;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.stage-empty {
|
||||
@@ -491,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 {
|
||||
@@ -550,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;
|
||||
@@ -623,6 +713,7 @@ body {
|
||||
}
|
||||
|
||||
.seatmap-dxf-frame-shell {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
@@ -630,6 +721,18 @@ body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.seatmap-dxf-drop-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 3;
|
||||
pointer-events: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.seatmap-dxf-drop-overlay.is-active {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.seatmap-dxf-frame {
|
||||
display: block;
|
||||
width: 100%;
|
||||
@@ -816,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;
|
||||
@@ -823,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 {
|
||||
@@ -834,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;
|
||||
@@ -862,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;
|
||||
}
|
||||
@@ -911,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;
|
||||
@@ -1098,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;
|
||||
@@ -1122,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;
|
||||
}
|
||||
|
||||
@@ -1135,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 {
|
||||
@@ -1169,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
BIN
incoming-files/1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 748 KiB |
2598
incoming-files/260320.html
Normal file
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
36
incoming-files/README.md
Normal 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`
|
||||
24
incoming-files/reference/README.md
Normal file
24
incoming-files/reference/README.md
Normal 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
|
||||
- 복구 비교용 자산
|
||||
- 디자인 레퍼런스 파일
|
||||
1377
incoming-files/reference/ledger/MH 통합 대시보드_260320.css
Normal file
1377
incoming-files/reference/ledger/MH 통합 대시보드_260320.css
Normal file
File diff suppressed because it is too large
Load Diff
2598
incoming-files/reference/ledger/MH 통합 대시보드_260320.html
Normal file
2598
incoming-files/reference/ledger/MH 통합 대시보드_260320.html
Normal file
File diff suppressed because one or more lines are too long
22
incoming-files/reference/ledger/README.md
Normal file
22
incoming-files/reference/ledger/README.md
Normal 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/` 같은 중첩 복사본을 만들지 않는다.
|
||||
- 원본 정리가 필요하면 이 디렉터리에서만 구조를 맞춘다.
|
||||
328
incoming-files/reference/ledger/ledger-override.css
Normal file
328
incoming-files/reference/ledger/ledger-override.css
Normal 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%;
|
||||
}
|
||||
}
|
||||
498
incoming-files/reference/ledger/ledger-override.js
Normal file
498
incoming-files/reference/ledger/ledger-override.js
Normal 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);
|
||||
});
|
||||
})();
|
||||
BIN
incoming-files/reference/ledger/사업관리대장-1.xlsx
Normal file
BIN
incoming-files/reference/ledger/사업관리대장-1.xlsx
Normal file
Binary file not shown.
@@ -421,6 +421,7 @@ const App = () => {
|
||||
if (parts.length < 3) return clean;
|
||||
return `${parts[0]}-${parts[1].padStart(2, '0')}-${parts[2].padStart(2, '0')}`;
|
||||
};
|
||||
const getExpenseDate = (row) => norm(getVal(row, ['발행일', '청구일', '발행 일자', '청구 일자'], 2));
|
||||
|
||||
const processedData = useMemo(() => {
|
||||
if (!dataLoaded.expense || !dataLoaded.work) return null;
|
||||
@@ -484,7 +485,8 @@ const App = () => {
|
||||
const workerName = norm(getVal(row, ['이름']));
|
||||
const position = norm(getVal(row, ['직책', '직급']));
|
||||
const userState = norm(getVal(row, ['user_state', 'User State', 'user state', 'userstate', 'User_State']));
|
||||
const isWeekend = userState.includes('주말');
|
||||
const weekendFlag = norm(getVal(row, ['주말/지각']));
|
||||
const isWeekend = userState.includes('주말') || weekendFlag.includes('주말');
|
||||
const isMhSchema = hasNamedHeader(row, '메인업무 프로젝트명') || hasNamedHeader(row, '연장근무 프로젝트명') || hasNamedHeader(row, '연장근무 시간(가공)');
|
||||
const importedLabor = pnum(getVal(row, ['산정금액', '인건비']));
|
||||
const overtimeHoursFromRow = isMhSchema
|
||||
@@ -623,7 +625,7 @@ const App = () => {
|
||||
return raw.length >= 7 ? raw.slice(0, 7) : raw;
|
||||
};
|
||||
|
||||
const excludedWorkers = new Set(['정태원', '양병홍', '장계석', '장종찬', '김원식', '김형준']);
|
||||
const excludedWorkers = new Set(['정태원', '양병홍', '장종찬', '김형준']);
|
||||
const selectedProjectKey = normalizeProjectKey(selectedProject === '전체' ? '' : selectedProject);
|
||||
const projectSearchKey = normalizeProjectKey(projectSearch);
|
||||
|
||||
@@ -648,7 +650,7 @@ const App = () => {
|
||||
const d1FromRow = normalizeD1Category(getVal(item, ['D1', '매출/비매출', '매출비매출'], 14));
|
||||
if (!d1FromRow) return false; // D1(K) empty rows are ignored.
|
||||
const info = projectToDepth[pKey] || unknownDepth;
|
||||
const issueDate = getVal(item, ['발행일'], 2);
|
||||
const issueDate = getExpenseDate(item);
|
||||
return (selectedRev === '전체' || info.d1 === selectedRev) &&
|
||||
(selectedD1 === '전체' || info.d2 === selectedD1) &&
|
||||
(selectedD2 === '전체' || info.d3 === selectedD2) &&
|
||||
@@ -667,7 +669,7 @@ const App = () => {
|
||||
const baseAllExp = expenseRaw.filter(item => {
|
||||
const d1FromRow = normalizeD1Category(getVal(item, ['D1', '매출/비매출', '매출비매출'], 14));
|
||||
if (!d1FromRow) return false;
|
||||
const issueDate = getVal(item, ['발행일'], 2);
|
||||
const issueDate = getExpenseDate(item);
|
||||
return isWithinRange(issueDate);
|
||||
});
|
||||
|
||||
@@ -996,7 +998,7 @@ const App = () => {
|
||||
else if (div === '외주비' || div === '외주') category = '외주비';
|
||||
else if (div === '제외') return;
|
||||
|
||||
const issueDate = norm(getVal(e, ['발행일', '발행 일자', '일자'], 2));
|
||||
const issueDate = getExpenseDate(e);
|
||||
expenseDetailByCategory[category].push({
|
||||
issueMonth: toIssueMonth(issueDate),
|
||||
issueDate,
|
||||
1377
incoming-files/sample style.css
Normal file
1377
incoming-files/sample style.css
Normal file
File diff suppressed because it is too large
Load Diff
931
incoming-files/seat/center_chair_people_map(2).html
Normal file
931
incoming-files/seat/center_chair_people_map(2).html
Normal 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>
|
||||
931
incoming-files/seat/center_chair_people_map.html
Normal file
931
incoming-files/seat/center_chair_people_map.html
Normal 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"></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>
|
||||
932
incoming-files/seat/center_chair_people_map_6f.html
Normal file
932
incoming-files/seat/center_chair_people_map_6f.html
Normal file
@@ -0,0 +1,932 @@
|
||||
<!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 6f</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_6f.js"></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>
|
||||
|
||||
932
incoming-files/seat/center_chair_people_map_7f.html
Normal file
932
incoming-files/seat/center_chair_people_map_7f.html
Normal file
@@ -0,0 +1,932 @@
|
||||
<!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 7f</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_7f.js"></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>
|
||||
|
||||
1
incoming-files/seat/center_chair_people_payload.js
Normal file
1
incoming-files/seat/center_chair_people_payload.js
Normal file
File diff suppressed because one or more lines are too long
1
incoming-files/seat/center_chair_people_payload_6f.js
Normal file
1
incoming-files/seat/center_chair_people_payload_6f.js
Normal file
File diff suppressed because one or more lines are too long
1
incoming-files/seat/center_chair_people_payload_7f.js
Normal file
1
incoming-files/seat/center_chair_people_payload_7f.js
Normal file
File diff suppressed because one or more lines are too long
23
incoming-files/served/README.md
Normal file
23
incoming-files/served/README.md
Normal 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`로 반영한다.
|
||||
- 원본 참고 파일이나 비교용 파일은 이 디렉터리에 두지 않는다.
|
||||
5
incoming-files/served/db-status/README.md
Normal file
5
incoming-files/served/db-status/README.md
Normal 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`를 사용한다.
|
||||
918
incoming-files/served/db-status/index.html
Normal file
918
incoming-files/served/db-status/index.html
Normal 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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
return new Intl.NumberFormat("ko-KR").format(Number(value || 0));
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) return '<span class="muted">-</span>';
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return escapeHtml(value);
|
||||
return parsed.toLocaleString("ko-KR", { hour12: false });
|
||||
}
|
||||
|
||||
function formatBytes(value) {
|
||||
const size = Number(value || 0);
|
||||
if (size <= 0) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
let current = size;
|
||||
let unit = 0;
|
||||
while (current >= 1024 && unit < units.length - 1) {
|
||||
current /= 1024;
|
||||
unit += 1;
|
||||
}
|
||||
return `${current.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`;
|
||||
}
|
||||
|
||||
function renderOverview(overview) {
|
||||
const target = document.getElementById("overview");
|
||||
target.innerHTML = [
|
||||
["핵심 테이블", overview.visible_tables],
|
||||
["전체 테이블", overview.total_tables],
|
||||
["등록 인원", overview.registered_members],
|
||||
["재직 인원", overview.active_members],
|
||||
["고정 오피스 도면", overview.fixed_office_maps],
|
||||
["현재 active 도면", overview.active_seat_maps],
|
||||
["Import 배치", overview.import_batches],
|
||||
["바이너리 원본", overview.binary_sources],
|
||||
].map(([label, value]) => `
|
||||
<article class="kpi">
|
||||
<span class="kpi-label">${escapeHtml(label)}</span>
|
||||
<span class="kpi-value">${formatNumber(value)}</span>
|
||||
</article>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function renderTables(items) {
|
||||
const target = document.getElementById("table-body");
|
||||
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>
|
||||
1377
incoming-files/served/ledger/MH 통합 대시보드_260320.css
Normal file
1377
incoming-files/served/ledger/MH 통합 대시보드_260320.css
Normal file
File diff suppressed because it is too large
Load Diff
21
incoming-files/served/ledger/README.md
Normal file
21
incoming-files/served/ledger/README.md
Normal 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/`를 본다.
|
||||
954
incoming-files/served/ledger/index.html
Normal file
954
incoming-files/served/ledger/index.html
Normal 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,"&").replace(/</g,"<").replace(/>/g,">");
|
||||
const escAttr=v=>esc(v).replace(/"/g,""");
|
||||
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&¤t) 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&¬e) 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>
|
||||
328
incoming-files/served/ledger/ledger-override.css
Normal file
328
incoming-files/served/ledger/ledger-override.css
Normal 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%;
|
||||
}
|
||||
}
|
||||
661
incoming-files/served/ledger/ledger-override.js
Normal file
661
incoming-files/served/ledger/ledger-override.js
Normal 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);
|
||||
});
|
||||
})();
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user