refactor: promote 8081 design system and served app structure

This commit is contained in:
hyunho
2026-04-01 14:50:08 +09:00
parent 637b390024
commit d0e055973e
50 changed files with 24157 additions and 820 deletions

View File

@@ -21,7 +21,7 @@ import ezdxf
from ezdxf import recover from ezdxf import recover
from fastapi import FastAPI, File, Form, Header, HTTPException, Request, UploadFile from fastapi import FastAPI, File, Form, Header, HTTPException, Request, UploadFile
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, HTMLResponse from fastapi.responses import FileResponse, HTMLResponse, Response
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from openpyxl import load_workbook from openpyxl import load_workbook
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -42,6 +42,11 @@ app.add_middleware(
LEGACY_STATIC_DIR = LEGACY_DIR / "static" LEGACY_STATIC_DIR = LEGACY_DIR / "static"
INCOMING_FILES_DIR = BASE_DIR / "incoming-files" INCOMING_FILES_DIR = BASE_DIR / "incoming-files"
INCOMING_SERVED_DIR = INCOMING_FILES_DIR / "served"
INCOMING_REFERENCE_DIR = INCOMING_FILES_DIR / "reference"
BUSINESS_DASHBOARD_DIR = INCOMING_FILES_DIR / "사업관리대장"
BUSINESS_LEDGER_SERVED_DIR = INCOMING_SERVED_DIR / "ledger"
BUSINESS_LEDGER_INDEX_PATH = BUSINESS_LEDGER_SERVED_DIR / "index.html"
FIXED_OFFICE_SOURCE_KEY = "technical-development-center" FIXED_OFFICE_SOURCE_KEY = "technical-development-center"
FIXED_OFFICE_CONFIGS = { FIXED_OFFICE_CONFIGS = {
"technical-development-center": { "technical-development-center": {
@@ -61,6 +66,7 @@ FIXED_OFFICE_CONFIGS = {
}, },
} }
_fixed_office_cache: dict[str, dict[str, object]] = {} _fixed_office_cache: dict[str, dict[str, object]] = {}
BUSINESS_LEDGER_DEFAULT_SOURCE_KEY = "business_ledger_default"
AUTH_DEFAULT_PASSWORD = "1111" AUTH_DEFAULT_PASSWORD = "1111"
AUTH_PASSWORD_ITERATIONS = 390000 AUTH_PASSWORD_ITERATIONS = 390000
AUTH_SESSION_HOURS = 12 AUTH_SESSION_HOURS = 12
@@ -82,6 +88,66 @@ MH_HEADER_ORDER = [
"사업 종류", "연장근무 프로젝트 코드", "연장근무 프로젝트명", "연장근무 서브코드", "연장근무 시간(실제)", "연장근무 시간(가공)" "사업 종류", "연장근무 프로젝트 코드", "연장근무 프로젝트명", "연장근무 서브코드", "연장근무 시간(실제)", "연장근무 시간(가공)"
] ]
def sync_default_business_ledger_source(cur) -> None:
cur.execute("SELECT to_regclass('public.integration_binary_sources') IS NOT NULL AS table_exists")
row = cur.fetchone()
table_exists = bool(row["table_exists"]) if row is not None else False
if not table_exists:
return
candidates = [
BUSINESS_LEDGER_SERVED_DIR / "사업관리대장-1.xlsx",
BUSINESS_DASHBOARD_DIR / "사업관리대장-1.xlsx",
BUSINESS_DASHBOARD_DIR / "사업관리 대장-1.xlsx",
BUSINESS_DASHBOARD_DIR / "사업관리대장.xlsx",
BUSINESS_DASHBOARD_DIR / "사업관리 대장.xlsx",
]
source_path = next((candidate for candidate in candidates if candidate.exists()), None)
if source_path is None:
return
content = source_path.read_bytes()
content_sha256 = hashlib.sha256(content).hexdigest()
meta_json = {
"byte_size": len(content),
"source_path": str(source_path),
"synced_from": "startup",
}
cur.execute(
"""
INSERT INTO integration_binary_sources (
source_key, source_name, filename, mime_type, content, content_sha256, meta_json, imported_at
)
VALUES (%s, %s, %s, %s, %s, %s, %s::jsonb, NOW())
ON CONFLICT (source_key) DO UPDATE
SET source_name = EXCLUDED.source_name,
filename = EXCLUDED.filename,
mime_type = EXCLUDED.mime_type,
content = EXCLUDED.content,
content_sha256 = EXCLUDED.content_sha256,
meta_json = EXCLUDED.meta_json,
imported_at = NOW()
WHERE integration_binary_sources.content_sha256 IS DISTINCT FROM EXCLUDED.content_sha256
OR integration_binary_sources.filename IS DISTINCT FROM EXCLUDED.filename
OR integration_binary_sources.mime_type IS DISTINCT FROM EXCLUDED.mime_type
OR integration_binary_sources.meta_json IS DISTINCT FROM EXCLUDED.meta_json
""",
(
BUSINESS_LEDGER_DEFAULT_SOURCE_KEY,
"사업관리대장 기본 원본",
source_path.name,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
content,
content_sha256,
json.dumps(meta_json, ensure_ascii=False),
),
)
app.mount(
"/integrations/ledger-assets",
StaticFiles(directory=str(BUSINESS_LEDGER_SERVED_DIR), check_dir=False),
name="integration-ledger-assets",
)
class MemberPayload(BaseModel): class MemberPayload(BaseModel):
id: int | None = None id: int | None = None
@@ -3910,6 +3976,7 @@ def startup() -> None:
init_db() init_db()
with get_conn() as conn: with get_conn() as conn:
with conn.cursor() as cur: with conn.cursor() as cur:
sync_default_business_ledger_source(cur)
sync_auth_users_from_members(cur) sync_auth_users_from_members(cur)
conn.commit() conn.commit()
@@ -3939,6 +4006,37 @@ def health() -> dict[str, object]:
} }
@app.get("/api/integration/business-ledger-default")
def integration_business_ledger_default() -> Response:
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT filename, mime_type, content
FROM integration_binary_sources
WHERE source_key = %s
ORDER BY imported_at DESC
LIMIT 1
""",
(BUSINESS_LEDGER_DEFAULT_SOURCE_KEY,),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Business ledger default source not found.")
filename = str(row["filename"] or "사업관리대장-1.xlsx")
headers = {
"Content-Disposition": 'inline; filename="business-ledger-default.xlsx"',
"X-Source-Filename": "business-ledger-default.xlsx",
"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
"Pragma": "no-cache",
}
return Response(
content=bytes(row["content"]),
media_type=str(row["mime_type"] or "application/octet-stream"),
headers=headers,
)
@app.post("/api/auth/login") @app.post("/api/auth/login")
def auth_login( def auth_login(
request: Request, request: Request,
@@ -4500,15 +4598,30 @@ def legacy_organization_backup() -> FileResponse:
@app.get("/integrations/payment") @app.get("/integrations/payment")
def integration_payment() -> FileResponse: def integration_payment() -> FileResponse:
target = INCOMING_FILES_DIR / "payment.html" # 8081 phase-1 cleanup: integration HTML is served only from incoming-files/served.
target = INCOMING_SERVED_DIR / "payment.html"
if not target.exists(): if not target.exists():
raise HTTPException(status_code=404, detail="Payment integration file not found.") raise HTTPException(status_code=404, detail="Payment integration file not found.")
return FileResponse(target) return FileResponse(target)
@app.get("/integrations/ledger")
def integration_ledger() -> FileResponse:
# #21 phase-1: runtime no longer decodes reference wrapper HTML. Serve the promoted
# ledger entry file from incoming-files/served/ledger only.
target = BUSINESS_LEDGER_INDEX_PATH
if not target.exists():
raise HTTPException(status_code=404, detail="Business ledger integration file not found.")
response = FileResponse(target)
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
response.headers["Pragma"] = "no-cache"
return response
@app.get("/integrations/mh") @app.get("/integrations/mh")
def integration_mh() -> FileResponse: def integration_mh() -> FileResponse:
target = INCOMING_FILES_DIR / "mh.html" # Keep the served path explicit so comparison/reference copies are never picked up by accident.
target = INCOMING_SERVED_DIR / "mh.html"
if not target.exists(): if not target.exists():
raise HTTPException(status_code=404, detail="MH integration file not found.") raise HTTPException(status_code=404, detail="MH integration file not found.")
return FileResponse(target) return FileResponse(target)

View File

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

View File

@@ -2,193 +2,152 @@
## Current Base ## Current Base
- branch: `total` - `8080` 공개 기준 브랜치: `total`
- latest checked commit: `24852d4` - `8081` 작업 기준 브랜치: `work-8081`
- main history doc: [DEVELOPMENT_HISTORY.md](/home/hyunho/projects/mh-dashboard-organization/docs/DEVELOPMENT_HISTORY.md) - `8080` 공개 기준 커밋: `637b390`
- work rulebook: [WORK_RULEBOOK.md](/home/hyunho/projects/mh-dashboard-organization/docs/WORK_RULEBOOK.md) - `8081` worktree 경로: `/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081`
- execution flow: [WORK_EXECUTION_FLOW.md](/home/hyunho/projects/mh-dashboard-organization/docs/WORK_EXECUTION_FLOW.md) - `8081` 실제 서빙 책임 맵: [architecture/8081_SERVING_MAP.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/architecture/8081_SERVING_MAP.md)
- dev/prod protocol: [DEV_PROD_DB_PROTOCOL.md](/home/hyunho/projects/mh-dashboard-organization/docs/DEV_PROD_DB_PROTOCOL.md) - 메인 히스토리: [DEVELOPMENT_HISTORY.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/DEVELOPMENT_HISTORY.md)
- regression checklist: [REGRESSION_CHECKLIST.md](/home/hyunho/projects/mh-dashboard-organization/docs/REGRESSION_CHECKLIST.md) - 작업 룰북: [WORK_RULEBOOK.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/WORK_RULEBOOK.md)
- today prep note: [TODAY_WORK_PREP_2026-03-30.md](/home/hyunho/projects/mh-dashboard-organization/docs/TODAY_WORK_PREP_2026-03-30.md) - 실행 플로우: [WORK_EXECUTION_FLOW.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/WORK_EXECUTION_FLOW.md)
- dev/prod DB 프로토콜: [DEV_PROD_DB_PROTOCOL.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/DEV_PROD_DB_PROTOCOL.md)
- 회귀 체크리스트: [REGRESSION_CHECKLIST.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/REGRESSION_CHECKLIST.md)
## Mandatory Start Rule ## Mandatory Start Rule
아침 또는 그날의 첫 작업을 시작할 때는 코드를 수정하기 전에 반드시 아래 순서를 먼저 수행해야 한다. 첫 작업 전에는 아래 순서를 먼저 확인한다.
1. Gitea 브랜치 상태 확인 1. 브랜치 기준 확인
2. 열린 이슈 확인 2. 열린 이슈 확인
3. [WORK_RULEBOOK.md](/home/hyunho/projects/mh-dashboard-organization/docs/WORK_RULEBOOK.md) 확인 3. [WORK_RULEBOOK.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/WORK_RULEBOOK.md) 확인
4. 이 문서 확인 4. 이 문서 확인
5. 현재 워크트리의 미푸시 커밋, 변경 파일, 미추적 파일 확인 5. `git status`, 변경 파일, 미추적 파일 확인
주의: 주의:
- 위 절차를 확인하기 전에는 새 코드 작성이나 기존 코드 수정부터 시작하지 않는다. - `8080` 기준 코드는 직접 수정하지 않는다.
- 커밋과 푸시는 자동으로 하지 않고, 사용자 지시가 있을 때만 수행한다. - 새 작업은 항상 `.dev-worktree-8081`에서 진행한다.
- 커밋과 푸시는 사용자 지시가 있을 때만 수행한다.
## What Was Finished ## Confirmed Runtime Rule
### Dashboard Integration - `8080`은 루트 workspace의 `total` 기준으로 유지한다.
- `8081``.dev-worktree-8081` + `work-8081` 기준으로만 수정한다.
- `main`, `hyunho`는 보류 브랜치이며 현재 작업에 사용하지 않는다.
- `8081` 변경을 `8080`에 올릴 때는 reviewed file diff 기준으로만 반영한다.
- `8081` DB는 운영 정본이 아니라 `8080` 기준 검증용 복제본처럼 다룬다.
- `조직 현황`, `프로젝트별 분석`, `팀/개인별 분석`, `자리배치도`를 하나의 허브에 통합 ## What Was Stabilized
- `payment.html`, `mh.html`을 현재 프로젝트에 편입
- 공통 헤더, 탭, 로그인 정보, 공통 기간 제어 구성
### Integrated DB ### Branch / Worktree Safety
- `organization.xlsx`, `MH.xlsx`, `payment.csv`, `ptj.csv` 기반 통합 DB 구성 - 기존 `8081` 작업본은 [`.dev-worktree-8081-backup-2026-04-01`](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081-backup-2026-04-01)로 보존
- raw/staging/standard 성격의 구조를 PostgreSQL에 반영 - 현재 [`.dev-worktree-8081`](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081)는 `work-8081` 기준으로 재생성
- `members`, `seat_maps`, `seat_slots`, `seat_positions` - `8080` 루트 workspace는 그대로 두고 분리 운영
- `integration_raw_*`, `integration_work_logs`, `integration_work_log_segments`, `integration_vouchers`
- 프로젝트 카테고리 매핑 반영
### Team / Member Analysis ### 8081 Design / Serving Baseline
- `omh.html` 원본 기준으로 계산식/카테고리/디자인 복원 - 디자인 SSOT 토큰:
- DB raw MH 데이터를 원본 입력 구조처럼 다시 공급하는 방식으로 정리 - [frontend/public/design-tokens.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/design-tokens.css)
- 디자인 SSOT 패턴:
- [frontend/public/design-patterns.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/design-patterns.css)
- 디자인 기준 문서:
- [architecture/DESIGN_SSOT.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/architecture/DESIGN_SSOT.md)
- 로그인 기본 스타일은 [frontend/public/styles.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/styles.css) 기준으로 유지
- `8081` 허브 전용 디자인은 [frontend/public/styles-8081-design.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/styles-8081-design.css)에서만 덮어씀
- 조직현황은 [legacy/static/common.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/legacy/static/common.css), [legacy/static/organization.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/legacy/static/organization.css), [legacy/static/organization.js](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/legacy/static/organization.js)를 사용
- 프로젝트별 분석 디자인은 [incoming-files/served/payment.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/incoming-files/served/payment.html) 내부에서 `design-tokens.css` + `design-patterns.css`를 참조
- 프로젝트별 분석 수정 원본은 [frontend/apps/payment/index.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/apps/payment/index.html) 이고, 반영은 [scripts/publish_payment_app.sh](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/scripts/publish_payment_app.sh)로 한다.
- 팀/개인별 분석 수정 원본은 [frontend/apps/team/index.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/apps/team/index.html) 이고, 반영은 [scripts/publish_team_app.sh](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/scripts/publish_team_app.sh)로 한다.
- 사업관리대장 실제 서비스 코드는 [incoming-files/served/ledger](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/incoming-files/served/ledger) 기준으로 본다.
- 사업관리대장 앱 소스 기준은 [frontend/apps/ledger](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/apps/ledger) 이고, 반영은 [scripts/publish_ledger_app.sh](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/scripts/publish_ledger_app.sh)로 한다.
- 사업관리대장 상세 팝업 디자인 수정 원본은 [frontend/apps/ledger/assets/ledger-override.js](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/apps/ledger/assets/ledger-override.js) 기준으로 본다.
### Project Analysis 디자인 수정 우선순위:
- `opayment.html` 원본 기준으로 화면 복원 1. [frontend/public/design-tokens.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/design-tokens.css)
- `payment.csv` 분류 우선, `ptj.csv` fallback 적용 2. [frontend/public/design-patterns.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/design-patterns.css)
- 연장근무는 `연장근무 시간(가공)` 기준으로 반영 3. 화면별 실제 서빙 파일
### Organization / Seat Map
- 조직도 상세 프로필에 `재석위치` preview 연결
- 관리자/비관리자 자리배치도 화면 분리
- 저장 후 조직도와 비관리자 열람에 반영되도록 seat save 흐름 정리
- seat persistence bug 수정
- 원인: `seat_positions_map_cell_idx`가 slot 기반 도면에도 적용됨
- 조치: `seat_slot_id IS NULL`인 grid map에만 적용되도록 수정
### Member Data Governance
- 이름 alias, 퇴사 제외, 조직 override를 DB 테이블 기반으로 전환
- 사용 테이블:
- `member_aliases`
- `member_retirements`
- `member_overrides`
### Auth Baseline
- 실제 로그인 API 연결 완료
- 프런트 로그인 화면이 `/api/auth/login` 사용
- 세션/로그아웃/세션 조회 API 구성 완료
- 사용 테이블:
- `auth.users`
- `auth.sessions`
- `auth.login_audit_logs`
- 현재 남은 범위:
- mock login 정리
- 역할별 권한 체크 적용
- 쓰기 API 보호 범위 정리
### External Access
- WSL 내부 8080 리슨 확인
- 현재 다른 PC에서 접속 확인
- 현재 기준 주소:
- `http://172.16.40.144:8080`
## Important Runtime Notes
### Dev / Prod Protocol
- 코드 선행은 `8081`, 공개 반영은 `8080`
- 데이터 정본은 `8080` DB
- `8081` DB는 독립 정본이 아니라 `8080` 기준 복제본처럼 관리해야 함
- `8081` 코드는 `.dev-worktree-8081` 기준으로 유지
- 조직도, 멤버, 자리배치 검증 전에는 `DEV_PROD_DB_PROTOCOL.md`를 먼저 확인
- 기능 수정 후 완료 판단은 `REGRESSION_CHECKLIST.md`를 기준으로 해야 함
- 빠른 재시작은 `./scripts/start_local_dashboards.sh`
### Seat Map Save
- 저장이 안 되면 먼저 backend 로그에서 `PUT /api/seat-maps/{id}/layout` 상태코드 확인
- 과거 핵심 장애는 DB 인덱스 충돌이었다
- 현재 저장 구조는:
- `seat_positions`
- `members.seat_label`
둘 다 같이 갱신
### External Access
- Windows LAN IP가 바뀌면 접속 주소가 바뀔 수 있음
- WSL IP가 바뀌면 `portproxy connectaddress`를 다시 맞춰야 함
- 다음 확인 명령:
- Windows: `ipconfig`
- WSL: `hostname -I`
- Windows: `netsh interface portproxy show all`
## Open Issues
- `#2` 백엔드 영속 저장 구조 운영 마무리
- `#3` 사무실 좌석 배치도 조회 및 관리자 편집 기능 고도화
- `#5` 실제 인증 체계 전환
- `#7` 자리배치도 팀별 색상 오버레이 표시
- `#8` 자리배치도 좌석 클릭 시 개인 상위 조직 트리 표시
- `#9` 조직도·자리배치도 변경 이력 버전 누적 저장
현재 해석:
- `#6`은 코드 기준 사실상 완료 상태이며 Gitea 정리 대상
- `#5`는 "로그인 구현"보다 "권한 제어 마무리"가 핵심
- `#2`의 기존 "스냅샷 검증" 범위는 현재 코드와 불일치하므로 범위 재정의 필요
## Unfinished Ideas Discussed Today
### Seat Map UX
- 자리배치도 내 인원 등록 시 팀별 색상 표시
- 좌석 클릭 시 본인까지의 상위 조직 트리 표시
- 나머지 사무실 2개 도면 추가
- `한맥빌딩 7층`
- `한맥빌딩 6층`
- 비관리자 열람 화면 품질 추가 점검
### History / Versioning
- 조직도와 자리배치도 수정 이력을 버전 누적형으로 저장
- 원본 DB와 별도의 history/version 구조 설계
- `valid_from`, `valid_to` 기반 시점 조회(as-of date) 구조 적용
- 날짜 또는 revision label 기준으로 버전 묶음 관리
- 상세 설계 문서:
- [HISTORY_ASOF_DB_PLAN.md](/home/hyunho/projects/mh-dashboard-organization/docs/HISTORY_ASOF_DB_PLAN.md)
주의: 주의:
- 현재 코드에는 조직도/자리배치도 버전 이력 기능이 아직 없음
- 월간 스냅샷 방향은 범위에서 제외
### Project Analysis Accuracy - `incoming-files/sample style.css`는 참고 기준이지만 직접 런타임 수정 파일이 아니다.
- `incoming-files` 원본/reference 파일을 먼저 고치지 않는다.
- 새 디자인 수정은 먼저 토큰/패턴 파일에서 해결 가능한지 확인한 뒤, 불가피할 때만 화면별 파일에 내린다.
- 총합은 거의 맞았지만 일부 프로젝트 단위 소수점/분류 오차는 추가 정밀 보정 필요 ### 1차 구조 정리 진행분
- `opayment` 기준으로 특정 프로젝트 차이를 계속 줄여야 함
### Auth / Permission - 이슈 기준:
- `#14` 전체 구조 정리 umbrella
- `#18` 1차: 파일 책임 맵 정리 및 프런트 서빙 경로 정돈
- `#19` 2차: 백엔드 라우터/서빙 책임 분리
- `#20` 3차: worktree/스크립트/문서 정리
- 책임 맵 문서 추가:
- [architecture/8081_SERVING_MAP.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/architecture/8081_SERVING_MAP.md)
- `/integrations/payment`, `/integrations/mh`의 실제 서빙 파일을 분리:
- [incoming-files/served/payment.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/incoming-files/served/payment.html)
- [incoming-files/served/mh.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/incoming-files/served/mh.html)
- 기존 [incoming-files/payment.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/incoming-files/payment.html), [incoming-files/mh.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/incoming-files/mh.html)은 비교/복구용 복사본으로 당분간 유지
- backend 서빙 경로는 [backend/app/main.py](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/backend/app/main.py)에서 `incoming-files/served/*`를 보도록 정리 시작
- mock login을 개발용 fallback 수준으로 제한하거나 제거 ## Current Actual Serving Map
- 역할별 접근 제어 정리
- 조직도/자리배치도/분석 화면 권한 경계 재정리 - `/`:
- [frontend/public/index.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/index.html)
- `/styles.css`:
- [frontend/public/styles.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/styles.css)
- `/styles-8081-design.css`:
- [frontend/public/styles-8081-design.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/styles-8081-design.css)
- `/legacy/organization`:
- [legacy/static/DashBoard-organization.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/legacy/static/DashBoard-organization.html)
- `/integrations/payment`:
- [incoming-files/served/payment.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/incoming-files/served/payment.html)
- `/integrations/ledger`:
- [incoming-files/served/ledger/index.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/incoming-files/served/ledger/index.html)
- `/integrations/mh`:
- [incoming-files/served/mh.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/incoming-files/served/mh.html)
## Cross Checks Last Confirmed
- `8080`: `curl http://localhost:8080/api/health` 정상
- `8081` dev 컨테이너: proxy/backend/frontend/db `healthy`
- `8081` backend 내부 확인:
- `/api/health` 200
- `/legacy/organization` 200
- `/integrations/payment` 200
- `/integrations/ledger` 200
- `/integrations/mh` 200
- `incoming-files/served` 내 실제 서빙 파일 존재 확인
주의:
- Codex 터미널 세션에서는 `curl http://localhost:8081`가 간헐적으로 실패할 수 있다.
- 이 경우 브라우저 확인 또는 컨테이너 내부 라우트 확인을 기준으로 판단한다.
## Open Issues Relevant Now
- `#14` 누적된 임시 로직 정리 및 중복 코드 제거
- `#16` 사업관리대장 메인 연동 및 기본 원본 DB화
- `#17` 8081 분리 worktree 기동 절차와 로컬 디자인 자산 복제 고정
- `#18` 8081 파일 책임 맵 정리 및 프런트 서빙 경로 정돈
- `#19` 8081 백엔드 라우터/서빙 책임 분리
- `#20` 8081 worktree 준비 스크립트·문서·운영 규칙 정리
- `#21` reference 의존 제거 및 8081 실제 서비스 코드 독립화
## Recommended Next Work Order ## Recommended Next Work Order
1. `#2` 범위를 현재 코드 기준으로 재정의하고 영속성 운영 검증 완료 1. `#21` 이후 기준으로 실제 서비스 파일과 reference 파일 경계를 유지
2. `#5`에서 권한 체크, mock login 정리, 쓰기 API 보호 적용 2. 사업관리대장 세부 데이터 정합성 보정
3. `8081` DB를 `8080` 정본 기준으로 동기화하는 반복 가능한 절차 마련 3. 그 다음 화면별 앱 구조 승격 검토
4. `#9`를 as-of date 기반 history 구조로 설계 후 `members`, `seat_positions` 부터 이력화 4. 필요 시 `#19`, `#20` 잔여 정리 항목 재평가
5. 그 다음 `#8`, 나머지 도면 추가, `#7`, 프로젝트 분석 오차 보정 순으로 진행
## Quick Resume Prompt ## Quick Resume Prompt
다음 세션 시작 시 아래 기준으로 이어가면 된다. 다음 세션 시작 시 아래 기준으로 이어가면 된다.
- 브랜치 `total`에서 시작 - `8080` 기준은 `total`
- 최근 커밋 `1d15cf9` 확인 - `8081` 작업은 `work-8081` + `.dev-worktree-8081`
- `docs/DEVELOPMENT_HISTORY.md` - 먼저 [WORK_RULEBOOK.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/WORK_RULEBOOK.md), [NEXT_SESSION_CHECKPOINT.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/NEXT_SESSION_CHECKPOINT.md), [architecture/8081_SERVING_MAP.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/architecture/8081_SERVING_MAP.md) 확인
- `docs/NEXT_SESSION_CHECKPOINT.md` - 디자인 수정이면 [frontend/public/design-tokens.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/design-tokens.css), [frontend/public/design-patterns.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/design-patterns.css), [architecture/DESIGN_SSOT.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/architecture/DESIGN_SSOT.md) 먼저 확인
- `docs/DEV_PROD_DB_PROTOCOL.md` - 현재 구조 독립화 기준 이슈는 `#21`
- `docs/REGRESSION_CHECKLIST.md` - 작업 전 `git status`, dev 컨테이너 상태, `/api/health`, `/legacy/organization`, `/integrations/payment`, `/integrations/ledger`, `/integrations/mh`를 먼저 확인
- `docs/HISTORY_ASOF_DB_PLAN.md`
- Gitea 이슈 `#2`, `#5`, `#9`
그리고 먼저 현재 외부 접속, 자리배치 저장, 실제 로그인 동작을 확인한 뒤 다음 기능 개발로 넘어간다.

View File

@@ -40,6 +40,8 @@
- 기능 검증 기준: `8081` - 기능 검증 기준: `8081`
- 사업관리대장 디자인 기준: `MH 통합 대시보드_260320.html` - 사업관리대장 디자인 기준: `MH 통합 대시보드_260320.html`
- 허브 공통 시각 언어 기준: `sample style.css` - 허브 공통 시각 언어 기준: `sample style.css`
- 런타임 디자인 토큰 기준: `frontend/public/design-tokens.css`
- 런타임 디자인 패턴 기준: `frontend/public/design-patterns.css`
- 현재 작업 지시 기준: 연결된 Gitea 이슈 - 현재 작업 지시 기준: 연결된 Gitea 이슈
작업 시작 전에 먼저 정해야 하는 질문: 작업 시작 전에 먼저 정해야 하는 질문:
@@ -51,6 +53,14 @@
이걸 모르고 코드를 건드리면 높은 확률로 엉뚱한 파일을 수정하게 된다. 이걸 모르고 코드를 건드리면 높은 확률로 엉뚱한 파일을 수정하게 된다.
디자인 작업 추가 규칙:
- 디자인 수정은 항상 `design-tokens.css``design-patterns.css`를 먼저 확인한다.
- 색/패널/버튼/테이블/팝업이 공통 규칙으로 해결 가능한지 먼저 본다.
- 해결 가능하면 화면별 파일을 고치지 않고 토큰/패턴 파일에서 수정한다.
- 화면별 실제 서빙 파일은 마지막 단계에서만 조정한다.
- 원본/reference 파일은 비교용이지 직접 수정 우선 대상이 아니다.
## 2. 이슈 생성 또는 연결 ## 2. 이슈 생성 또는 연결
작업은 이슈 없이 하지 않는다. 작업은 이슈 없이 하지 않는다.

View File

@@ -218,6 +218,26 @@ mock, fallback, hotfix, 임시 우회 로직은 허용할 수 있다.
## Rule 13. 8081 Must Start From The Isolated Worktree ## Rule 13. 8081 Must Start From The Isolated Worktree
`8081` 작업은 항상 `.dev-worktree-8081` 기준으로 시작한다.
세부 규칙:
- 디자인 작업도 예외가 아니다.
- 허브/조직현황/프로젝트별 분석/사업관리대장 수정 전에 현재 실제 서빙 파일과 SSOT 파일을 먼저 확인한다.
디자인 작업 강제 우선순위:
1. `frontend/public/design-tokens.css`
2. `frontend/public/design-patterns.css`
3. `docs/architecture/DESIGN_SSOT.md`
4. 그 다음 화면별 실제 서빙 파일
금지:
- reference/original 파일을 먼저 수정하기
- 예전 파란톤/indigo/slate 계열을 새 기본값으로 다시 넣기
- 토큰/패턴으로 해결 가능한 문제를 화면별 임시 하드코딩으로 처리하기
`8081` 작업용은 포트만 다른 복제 서버가 아니라, 코드 소스까지 분리된 전용 worktree여야 한다. `8081` 작업용은 포트만 다른 복제 서버가 아니라, 코드 소스까지 분리된 전용 worktree여야 한다.
세부 규칙: 세부 규칙:

View File

@@ -0,0 +1,112 @@
# 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`
정리 원칙:
- `incoming-files` 아래에서는 `served/`를 실제 서빙 자산용으로 사용한다.
- `reference/`는 원본 참고 파일, 복구 참고 파일, 비교용 자산만 둔다.
- 1차 정리에서는 기존 실제 서빙 파일을 `served/`에 복사하고, backend 서빙 경로를 먼저 `served/`로 갱신한다.
- `사업관리대장``#21`부터 wrapper decode 방식 대신 `served/ledger/index.html``served/ledger/*`를 직접 서빙한다.
- `사업관리대장` 수정 원본은 `#21` 다음 단계부터 `frontend/apps/ledger/*`를 먼저 보고, `scripts/publish_ledger_app.sh`로 runtime served 파일에 반영한다.
- 기존 루트 `incoming-files/payment.html`, `incoming-files/mh.html`는 안전한 비교/복구를 위해 당분간 남겨둔다.
## Seat Map
- 허브 화면 구성:
- `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`
- `opayment.html`
- `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`의 실제 서빙 파일 위치가 문서와 코드에서 일치한다.
- 기존 참고 자산을 지우지 않고도 실제 서빙 경로와 참고 경로를 구분할 수 있다.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -15,6 +15,8 @@ const organizationHistoryControls = document.getElementById("organization-histor
const organizationMonthSelect = document.getElementById("organization-month-select"); const organizationMonthSelect = document.getElementById("organization-month-select");
const organizationCompareBtn = document.getElementById("organization-compare-btn"); const organizationCompareBtn = document.getElementById("organization-compare-btn");
const navButtons = Array.from(document.querySelectorAll(".header-center [data-view]")); 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 organizationFrame = document.getElementById("organization-frame");
const organizationStage = document.getElementById("organization-stage"); const organizationStage = document.getElementById("organization-stage");
const projectFrame = document.getElementById("project-frame"); const projectFrame = document.getElementById("project-frame");
@@ -151,7 +153,7 @@ const seatMapState = {
forceReadOnly: false, forceReadOnly: false,
}; };
let currentView = "project"; let currentView = "ledger";
const globalDateState = { const globalDateState = {
loaded: false, loaded: false,
startDate: "", startDate: "",
@@ -364,6 +366,10 @@ function buildSeatMapAsOfQuery() {
} }
function notifyEmbeddedTabActivated() { 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) { if (currentView === "project" && projectFrame?.contentWindow) {
projectFrame.contentWindow.postMessage({ source: "total-control", type: "tab-activated", tab: "project" }, window.location.origin); projectFrame.contentWindow.postMessage({ source: "total-control", type: "tab-activated", tab: "project" }, window.location.origin);
} }
@@ -372,6 +378,49 @@ function notifyEmbeddedTabActivated() {
} }
} }
let ledgerDefaultSourcePromise = null;
async function fetchDefaultLedgerSource() {
if (!ledgerDefaultSourcePromise) {
ledgerDefaultSourcePromise = fetch("/api/integration/business-ledger-default")
.then(async (response) => {
if (!response.ok) {
throw new Error("기본 사업관리대장 원본을 불러오지 못했습니다.");
}
const fileName = response.headers.get("x-source-filename") || "사업관리대장-1.xlsx";
const buffer = await response.arrayBuffer();
if (!buffer || !buffer.byteLength) {
throw new Error("기본 사업관리대장 원본 데이터가 비어 있습니다.");
}
return { fileName, buffer };
})
.catch((error) => {
ledgerDefaultSourcePromise = null;
throw error;
});
}
return ledgerDefaultSourcePromise;
}
async function pushDefaultLedgerSourceToFrame(force = false) {
if (!ledgerFrame?.contentWindow) return;
if (ledgerFrame.dataset.defaultLedgerLoaded === "true" && !force) return;
try {
const { fileName, buffer } = await fetchDefaultLedgerSource();
ledgerFrame.contentWindow.postMessage(
{ source: "total-control", type: "embedded-host" },
window.location.origin,
);
ledgerFrame.contentWindow.postMessage(
{ source: "total-upload", type: "business", fileName, buffer },
window.location.origin,
);
ledgerFrame.dataset.defaultLedgerLoaded = "true";
} catch (error) {
console.error("사업관리대장 기본 원본 전달에 실패했습니다.", error);
}
}
async function ensureGlobalDateRangeLoaded() { async function ensureGlobalDateRangeLoaded() {
if (globalDateState.loaded) return; if (globalDateState.loaded) return;
try { try {
@@ -1571,10 +1620,15 @@ function setActiveView(view) {
}); });
const isOrganization = currentView === "organization"; const isOrganization = currentView === "organization";
const isLedger = currentView === "ledger";
const isProject = currentView === "project"; const isProject = currentView === "project";
const isTeam = currentView === "team"; const isTeam = currentView === "team";
const isSeatMapAdmin = currentView === "seatmap-admin"; const isSeatMapAdmin = currentView === "seatmap-admin";
const isSeatMapReadonly = currentView === "seatmap-readonly"; const isSeatMapReadonly = currentView === "seatmap-readonly";
if (ledgerStage) {
ledgerStage.hidden = !isLedger;
ledgerStage.style.display = isLedger ? "flex" : "none";
}
if (organizationStage) { if (organizationStage) {
organizationStage.hidden = !isOrganization; organizationStage.hidden = !isOrganization;
organizationStage.style.display = isOrganization ? "flex" : "none"; organizationStage.style.display = isOrganization ? "flex" : "none";
@@ -1596,11 +1650,15 @@ function setActiveView(view) {
seatMapReadonlyStage.style.display = isSeatMapReadonly ? "flex" : "none"; seatMapReadonlyStage.style.display = isSeatMapReadonly ? "flex" : "none";
} }
if (emptyStage) { if (emptyStage) {
const showEmpty = !isOrganization && !isProject && !isTeam && !isSeatMapAdmin && !isSeatMapReadonly; const showEmpty = !isLedger && !isOrganization && !isProject && !isTeam && !isSeatMapAdmin && !isSeatMapReadonly;
emptyStage.hidden = !showEmpty; emptyStage.hidden = !showEmpty;
emptyStage.style.display = showEmpty ? "flex" : "none"; 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) { if (isOrganization && previousView !== "organization" && organizationFrame) {
const frameSrc = organizationFrame.dataset.src || organizationFrame.src; const frameSrc = organizationFrame.dataset.src || organizationFrame.src;
organizationFrame.src = resolveAppUrl(frameSrc); organizationFrame.src = resolveAppUrl(frameSrc);
@@ -1671,7 +1729,7 @@ if (loginForm) {
body: formData, body: formData,
}); });
setSession(payload); setSession(payload);
setActiveView("project"); setActiveView("ledger");
loginForm.reset(); loginForm.reset();
loginMessage.textContent = ""; loginMessage.textContent = "";
renderAuth(); renderAuth();
@@ -1728,6 +1786,13 @@ organizationFrame?.addEventListener("load", () => {
postOrganizationHistoryState(); postOrganizationHistoryState();
}); });
ledgerFrame?.addEventListener("load", () => {
if (currentView === "ledger") {
notifyEmbeddedTabActivated();
}
void pushDefaultLedgerSourceToFrame(true);
});
projectFrame?.addEventListener("load", () => { projectFrame?.addEventListener("load", () => {
postGlobalDateRangeToFrame(projectFrame); postGlobalDateRangeToFrame(projectFrame);
if (currentView === "project") { if (currentView === "project") {

View File

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

View File

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

View File

@@ -12,8 +12,13 @@
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Pretendard:wght@400;600;700;900&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Pretendard:wght@400;600;700;900&display=swap" rel="stylesheet">
<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="/legacy/static/common.css">
<!-- Keep login and common hub defaults aligned with 8080. -->
<link rel="stylesheet" href="/styles.css?v=20260330-01"> <link rel="stylesheet" href="/styles.css?v=20260330-01">
<!-- 8081-only hub overrides must not restyle the login screen. -->
<link rel="stylesheet" href="/styles-8081-design.css?v=20260401-01">
</head> </head>
<body> <body>
<section id="login-panel" class="login-screen"> <section id="login-panel" class="login-screen">
@@ -91,18 +96,26 @@
</header> </header>
<main class="dashboard-main"> <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"> <section id="organization-stage" class="main-stage">
<div class="stage-frame"> <div class="stage-frame">
<iframe id="organization-frame" src="/legacy/organization?v=20260331-01" data-src="/legacy/organization?v=20260331-01" title="조직도 메인 화면"></iframe> <!-- Legacy organization keeps its own CSS/JS responsibility under /legacy/static. -->
<iframe id="organization-frame" src="/legacy/organization?v=20260330-02" data-src="/legacy/organization?v=20260330-02" title="조직도 메인 화면"></iframe>
</div> </div>
</section> </section>
<section id="project-stage" class="main-stage" hidden> <section id="project-stage" class="main-stage" hidden>
<div class="stage-frame"> <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> <iframe id="project-frame" src="/integrations/payment" data-src="/integrations/payment" title="프로젝트별 분석 화면"></iframe>
</div> </div>
</section> </section>
<section id="team-stage" class="main-stage" hidden> <section id="team-stage" class="main-stage" hidden>
<div class="stage-frame"> <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> <iframe id="team-frame" src="/integrations/mh" data-src="/integrations/mh" title="팀/개인별 분석 화면"></iframe>
</div> </div>
</section> </section>
@@ -213,6 +226,6 @@
</main> </main>
</section> </section>
<script src="/app.js?v=20260330-01"></script> <script src="/app.js?v=20260401-02"></script>
</body> </body>
</html> </html>

View File

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

View File

@@ -1,3 +1,30 @@
:root {
--color-bg: var(--ds-bg);
--color-surface: var(--ds-panel);
--color-surface-soft: var(--ds-panel-soft);
--color-surface-strong: var(--ds-panel-strong);
--color-text: var(--ds-ink);
--color-text-soft: var(--ds-text-soft);
--color-text-muted: var(--ds-text-muted);
--color-border: var(--ds-line);
--color-border-soft: var(--ds-line-soft);
--color-brand: var(--ds-brand);
--color-brand-deep: var(--ds-brand-deep);
--color-brand-soft: var(--ds-brand-soft);
--color-accent: var(--ds-accent);
--color-accent-soft: var(--ds-accent-soft);
--color-success: var(--ds-status-success);
--color-danger: var(--ds-status-danger);
--radius-sm: var(--ds-radius-sm);
--radius-md: var(--ds-radius-md);
--radius-lg: var(--ds-radius-lg);
--radius-xl: var(--ds-radius-xl);
--radius-pill: var(--ds-radius-pill);
--shadow-soft: var(--ds-shadow-soft);
--shadow-card: var(--ds-shadow-card);
--shadow-float: var(--ds-shadow-float);
}
.dashboard-shell, .dashboard-shell,
.dashboard-main, .dashboard-main,
.main-stage, .main-stage,
@@ -31,7 +58,7 @@ body {
min-height: 100vh; min-height: 100vh;
padding: 24px; padding: 24px;
background: 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") url("https://images.unsplash.com/photo-1486406146926-c627a92ad1ab?auto=format&fit=crop&w=1800&q=80")
center center / cover no-repeat; center center / cover no-repeat;
} }
@@ -54,10 +81,10 @@ body {
display: grid; display: grid;
grid-template-columns: 1.3fr 0.7fr; grid-template-columns: 1.3fr 0.7fr;
overflow: hidden; overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.14); border: 1px solid var(--ds-glass-line);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
background: rgba(71, 85, 105, 0.34); background: var(--ds-glass-dark);
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.24); box-shadow: var(--ds-shadow-hero);
backdrop-filter: blur(14px); backdrop-filter: blur(14px);
} }
@@ -68,8 +95,8 @@ body {
padding: 30px 30px; padding: 30px 30px;
border-right: 1px solid rgba(255, 255, 255, 0.08); border-right: 1px solid rgba(255, 255, 255, 0.08);
background: background:
linear-gradient(90deg, rgba(15, 23, 42, 0.08), rgba(255, 255, 255, 0.02)), 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(15, 23, 42, 0.08)); linear-gradient(180deg, rgba(255, 255, 255, 0.02), rgba(10, 42, 34, 0.08));
} }
.login-brand .eyebrow { .login-brand .eyebrow {
@@ -83,7 +110,7 @@ body {
font-size: clamp(1.7rem, 3.2vw, 2.5rem); font-size: clamp(1.7rem, 3.2vw, 2.5rem);
line-height: 0.96; line-height: 0.96;
letter-spacing: -0.04em; letter-spacing: -0.04em;
color: #f8fafc; color: #f7f0e4;
} }
.login-form-wrap { .login-form-wrap {
@@ -91,7 +118,7 @@ body {
display: grid; display: grid;
align-content: center; align-content: center;
gap: 10px; gap: 10px;
background: rgba(15, 23, 42, 0.12); background: var(--ds-glass-dark-soft);
} }
.login-card label { .login-card label {
@@ -140,8 +167,8 @@ body {
margin-top: 2px; margin-top: 2px;
border: none; border: none;
color: #fff; color: #fff;
background: rgba(31, 41, 55, 0.82); background: rgba(10, 42, 34, 0.82);
box-shadow: 0 14px 30px rgba(15, 23, 42, 0.22); box-shadow: var(--shadow-float);
min-height: 34px; min-height: 34px;
border-radius: 999px; border-radius: 999px;
font-size: 11px; font-size: 11px;
@@ -167,9 +194,9 @@ body {
.dashboard-header { .dashboard-header {
min-height: 68px; min-height: 68px;
background: rgba(255, 255, 255, 0.94); background: rgba(255, 250, 243, 0.94);
color: var(--color-text); color: var(--color-text);
border-bottom: 1px solid #d7dee8; border-bottom: 1px solid var(--color-border);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
@@ -241,7 +268,7 @@ body {
border: none; border: none;
border-bottom: 3px solid transparent; border-bottom: 3px solid transparent;
background: transparent; background: transparent;
color: #64748b; color: var(--color-text-muted);
font-size: 15px; font-size: 15px;
font-weight: 700; font-weight: 700;
cursor: pointer; cursor: pointer;
@@ -255,7 +282,7 @@ body {
} }
.nav-pill.muted { .nav-pill.muted {
color: #94a3b8; color: rgba(102, 117, 109, 0.64);
} }
.nav-pill:hover { .nav-pill:hover {
@@ -269,7 +296,7 @@ body {
gap: 6px; gap: 6px;
position: relative; position: relative;
padding-left: 18px; padding-left: 18px;
border-left: 1px solid #dbe2ea; border-left: 1px solid var(--color-border);
} }
.header-date-controls { .header-date-controls {
@@ -284,7 +311,7 @@ body {
.header-date-label { .header-date-label {
font-size: 12px; font-size: 12px;
font-weight: 800; font-weight: 800;
color: #64748b; color: var(--color-text-muted);
} }
.header-date-field { .header-date-field {
@@ -292,9 +319,9 @@ body {
align-items: center; align-items: center;
min-height: 36px; min-height: 36px;
padding: 0 10px; padding: 0 10px;
border: 1px solid #dbe2ea; border: 1px solid var(--color-border);
border-radius: 999px; border-radius: 999px;
background: #fff; background: var(--color-surface);
} }
.header-date-field input { .header-date-field input {
@@ -318,15 +345,15 @@ body {
} }
.header-date-sep { .header-date-sep {
color: #94a3b8; color: var(--color-text-muted);
font-size: 12px; font-size: 12px;
font-weight: 800; font-weight: 800;
} }
.ghost-button { .ghost-button {
min-height: 34px; min-height: 34px;
border: 1px solid #dbe2ea; border: 1px solid var(--color-border);
background: #fff; background: var(--color-surface);
color: var(--color-text); color: var(--color-text);
padding: 0 12px; padding: 0 12px;
border-radius: 999px; border-radius: 999px;
@@ -342,12 +369,12 @@ body {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: #f8fafc; background: var(--color-surface-soft);
} }
.icon-button:hover { .icon-button:hover {
background: #f1f5f9; background: var(--ds-bg-soft);
border-color: #cbd5e1; border-color: var(--color-border);
color: var(--color-accent); color: var(--color-accent);
transform: translateY(-1px); transform: translateY(-1px);
} }
@@ -363,7 +390,7 @@ body {
} }
.ghost-button-soft { .ghost-button-soft {
background: #f8fafc; background: var(--color-surface-soft);
} }
.user-chip { .user-chip {
@@ -381,8 +408,8 @@ body {
width: 18px; width: 18px;
height: 18px; height: 18px;
border-radius: 50%; border-radius: 50%;
background: #e2e8f0; background: var(--color-surface-strong);
color: #475569; color: var(--color-text-soft);
font-size: 10px; font-size: 10px;
font-weight: 900; font-weight: 900;
flex: 0 0 auto; flex: 0 0 auto;
@@ -421,10 +448,10 @@ body {
right: 0; right: 0;
min-width: 220px; min-width: 220px;
padding: 14px; padding: 14px;
border: 1px solid #dbe2ea; border: 1px solid var(--color-border);
border-radius: 16px; border-radius: 16px;
background: rgba(255, 255, 255, 0.96); background: rgba(255, 250, 243, 0.96);
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.14); box-shadow: var(--shadow-float);
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
z-index: 30; z-index: 30;
} }
@@ -440,7 +467,7 @@ body {
} }
.user-popover-row + .user-popover-row { .user-popover-row + .user-popover-row {
border-top: 1px solid #eef2f7; border-top: 1px solid rgba(217, 197, 168, 0.4);
} }
.user-popover-label { .user-popover-label {
@@ -454,7 +481,7 @@ body {
min-height: 38px; min-height: 38px;
border: none; border: none;
border-radius: 12px; border-radius: 12px;
background: #0f172a; background: var(--color-brand);
color: #fff; color: #fff;
font-size: 11px; font-size: 11px;
font-weight: 800; font-weight: 800;
@@ -485,7 +512,7 @@ body {
width: 100%; width: 100%;
height: 100%; height: 100%;
border: 0; border: 0;
background: #fff; background: var(--color-surface);
} }
.stage-empty { .stage-empty {
@@ -502,9 +529,7 @@ body {
gap: 12px; gap: 12px;
padding: 18px; padding: 18px;
overflow: hidden; overflow: hidden;
background: background: var(--ds-bg-gradient);
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%);
} }
.seatmap-topbar { .seatmap-topbar {
@@ -561,6 +586,54 @@ body {
display: none !important; 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 { .seatmap-status {
min-height: 20px; min-height: 20px;
margin: 0; margin: 0;

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

@@ -0,0 +1,38 @@
# 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/*`만 읽는다.
- 새 기능을 붙일 때도 실제 서비스 파일은 `served/` 기준으로 수정한다.
## Reference
- 원본 참고 자산
- 복구 비교용 자산
- 직접 서빙하지 않는 파일
예:
- 원본 `xlsx`, `csv`
- 샘플 스타일 파일
- 원본/백업 HTML
- 디자인 비교용 파일
- `reference/ledger/MH 통합 대시보드_260320.html`
- `reference/ledger/MH 통합 대시보드_260320.css`
## Temporary Comparison Copies
- 현재 루트의 `payment.html`, `mh.html`은 당장 삭제하지 않는다.
- 이 두 파일은 기존 recovery 작업본과 현재 `served/*`를 비교하거나 되돌릴 때만 본다.
- 다음 차수에서 안전성이 확보되면 `reference/` 하위로 재배치 여부를 검토한다.

File diff suppressed because it is too large Load Diff

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -860,18 +860,20 @@ function openUnitAddModal(event) {
</select> </select>
</div> </div>
<div class="col-span-2"> <div class="col-span-2">
<label class="text-[11px] font-black text-slate-600 block">상위 위치 선택</label> <label class="member-form-label block">상위 위치 선택</label>
<select id="new-unit-parent" class="w-full bg-white p-3 rounded-xl border text-sm font-bold outline-none"></select> <select id="new-unit-parent" class="member-form-select"></select>
</div> </div>
<div class="col-span-2"> <div class="col-span-2">
<label class="text-[11px] font-black text-slate-600 block">신규 명칭 입력</label> <label class="member-form-label block">신규 명칭 입력</label>
<input id="new-unit-name" placeholder="예: 신규개발팀" class="w-full bg-slate-50 p-3 rounded-xl border font-bold text-sm outline-none"> <input id="new-unit-name" placeholder="예: 신규개발팀" class="member-form-input">
</div> </div>
`; `;
updateParentList(); updateParentList();
document.getElementById('modal-footer-area').innerHTML = ` document.getElementById('modal-footer-area').innerHTML = `
<button onclick="closeModal()" class="flex-1 bg-slate-100 py-3.5 rounded-xl font-bold text-sm">취소</button> <div class="modal-footer-actions">
<button onclick="saveNewUnit()" class="flex-1 bg-indigo-600 text-white py-3.5 rounded-xl font-bold text-sm">저장</button> <button onclick="closeModal()" class="modal-btn modal-btn-cancel">취소</button>
<button onclick="saveNewUnit()" class="modal-btn modal-btn-save">저장</button>
</div>
`; `;
modal.style.display = 'flex'; modal.style.display = 'flex';
} }
@@ -934,14 +936,16 @@ function openOrgEditModal(level, oldName) {
fieldsArea.style.maxHeight = 'none'; fieldsArea.style.maxHeight = 'none';
fieldsArea.innerHTML = ` fieldsArea.innerHTML = `
<div class="col-span-2"> <div class="col-span-2">
<label class="text-[11px] font-black text-slate-400 block">새로운 ${level} 명칭</label> <label class="member-form-label block">새로운 ${level} 명칭</label>
<input id="new-org-name" value="${oldName}" class="w-full bg-slate-50 p-3 rounded-xl border font-bold text-sm outline-none"> <input id="new-org-name" value="${oldName}" class="member-form-input">
</div> </div>
`; `;
document.getElementById('modal-footer-area').innerHTML = ` document.getElementById('modal-footer-area').innerHTML = `
<button onclick="deleteOrg('${jsString(level)}', '${jsString(oldName)}')" class="bg-red-50 text-red-600 py-3.5 px-6 rounded-xl font-bold text-sm border border-red-100 hover:bg-red-100 transition-colors">삭제</button> <button onclick="deleteOrg('${jsString(level)}', '${jsString(oldName)}')" class="modal-btn modal-btn-delete">삭제</button>
<button onclick="closeModal()" class="flex-1 bg-slate-100 py-3.5 rounded-xl font-bold text-sm">취소</button> <div class="modal-footer-actions">
<button onclick="saveOrgName('${jsString(level)}', '${jsString(oldName)}')" class="flex-1 bg-indigo-600 text-white py-3.5 rounded-xl font-bold text-sm">저장</button> <button onclick="closeModal()" class="modal-btn modal-btn-cancel">취소</button>
<button onclick="saveOrgName('${jsString(level)}', '${jsString(oldName)}')" class="modal-btn modal-btn-save">저장</button>
</div>
`; `;
modal.style.display = 'flex'; modal.style.display = 'flex';
} }
@@ -1102,12 +1106,8 @@ function switchModalTab(tab) {
const isBasic = tab === 'basic'; const isBasic = tab === 'basic';
document.getElementById('modal-sec-basic').classList.toggle('hidden', !isBasic); document.getElementById('modal-sec-basic').classList.toggle('hidden', !isBasic);
document.getElementById('modal-sec-org').classList.toggle('hidden', isBasic); document.getElementById('modal-sec-org').classList.toggle('hidden', isBasic);
document.getElementById('modal-tab-basic').className = isBasic document.getElementById('modal-tab-basic').className = isBasic ? 'member-modal-tab is-active' : 'member-modal-tab';
? 'flex-1 py-3 font-bold border-b-2 border-indigo-600 text-indigo-600 text-sm transition-all' document.getElementById('modal-tab-org').className = !isBasic ? 'member-modal-tab is-active' : 'member-modal-tab';
: 'flex-1 py-3 font-bold border-b-2 border-transparent text-slate-400 text-sm transition-all';
document.getElementById('modal-tab-org').className = !isBasic
? 'flex-1 py-3 font-bold border-b-2 border-indigo-600 text-indigo-600 text-sm transition-all'
: 'flex-1 py-3 font-bold border-b-2 border-transparent text-slate-400 text-sm transition-all';
} }
function openModal(id) { function openModal(id) {
@@ -1124,14 +1124,14 @@ function openModal(id) {
fieldsArea.style.maxHeight = 'none'; fieldsArea.style.maxHeight = 'none';
fieldsArea.innerHTML = ` fieldsArea.innerHTML = `
<div class="member-detail-top-row"> <div class="member-detail-top-row">
<div class="relative w-32 h-32 rounded-full overflow-hidden border-4 border-indigo-100 shadow-lg"> <div class="relative w-32 h-32 rounded-full overflow-hidden border-4 shadow-lg" style="border-color: var(--color-surface-strong);">
<img src="${member['사진'] || 'https://via.placeholder.com/120?text=Profile'}" class="w-full h-full object-cover"> <img src="${member['사진'] || 'https://via.placeholder.com/120?text=Profile'}" class="w-full h-full object-cover">
</div> </div>
<div class="member-detail-summary"> <div class="member-detail-summary">
<div> <div>
<h2 class="text-2xl font-black text-slate-800">${member['이름'] || ''}</h2> <h2 class="text-2xl font-black text-slate-800">${member['이름'] || ''}</h2>
<p class="text-indigo-600 font-bold">${member['직급'] || '-'} / ${member['직책'] || '팀원'}</p> <p class="font-bold" style="color: var(--color-header);">${member['직급'] || '-'} / ${member['직책'] || '팀원'}</p>
<p class="text-slate-400 text-xs mt-1 font-medium">${(member._path || []).map((path) => path.name).join(' > ')}</p> <p class="text-xs mt-1 font-medium" style="color: var(--color-text-muted);">${(member._path || []).map((path) => path.name).join(' > ')}</p>
</div> </div>
<div class="member-inline-info-grid"> <div class="member-inline-info-grid">
<div class="member-inline-info-card"> <div class="member-inline-info-card">
@@ -1149,7 +1149,7 @@ function openModal(id) {
<div id="member-seat-preview">${renderSeatPreviewCard({ assigned: false, seatLabel: member['자리위치'] || '', seatMapName: '자리배치도', slotKey: '' })}</div> <div id="member-seat-preview">${renderSeatPreviewCard({ assigned: false, seatLabel: member['자리위치'] || '', seatMapName: '자리배치도', slotKey: '' })}</div>
</div> </div>
`; `;
footer.innerHTML = '<button onclick="closeModal()" class="w-full bg-slate-800 text-white py-4 rounded-xl font-bold text-sm shadow-lg">닫기</button>'; footer.innerHTML = '<button onclick="closeModal()" class="modal-btn modal-btn-close">닫기</button>';
modal.style.display = 'flex'; modal.style.display = 'flex';
hydrateMemberSeatPreview(member); hydrateMemberSeatPreview(member);
return; return;
@@ -1168,14 +1168,14 @@ function openModal(id) {
const currentValue = member[field] || ''; const currentValue = member[field] || '';
orgFields += ` orgFields += `
<div class="col-span-1"> <div class="col-span-1">
<label class="text-[11px] font-black text-slate-600 block">${field}</label> <label class="member-form-label block">${field}</label>
<select id="sel-${field}" onchange="toggleManualInput('${field}')" class="w-full bg-white p-3 rounded-xl border text-sm font-bold text-slate-700 outline-none"> <select id="sel-${field}" onchange="toggleManualInput('${field}')" class="member-form-select">
<option value="__NEW__" class="text-indigo-600 font-bold">+ 직접/신규 입력</option> <option value="__NEW__" class="member-form-new-option">+ 직접/신규 입력</option>
<option value="__NONE__" ${currentValue === '' ? 'selected' : ''}>-- 선택 안 함 --</option> <option value="__NONE__" ${currentValue === '' ? 'selected' : ''}>-- 선택 안 함 --</option>
${uniqueValues.map((value) => `<option value="${value}" ${value === currentValue ? 'selected' : ''}>${value}</option>`).join('')} ${uniqueValues.map((value) => `<option value="${value}" ${value === currentValue ? 'selected' : ''}>${value}</option>`).join('')}
</select> </select>
<div id="manual-${field}" class="hidden mt-2"> <div id="manual-${field}" class="hidden member-form-manual">
<input id="input-${field}" placeholder="직접 입력" class="w-full bg-indigo-50 p-3 rounded-xl border-indigo-200 border text-sm font-bold"> <input id="input-${field}" placeholder="직접 입력" class="member-form-input">
</div> </div>
</div> </div>
`; `;
@@ -1184,30 +1184,30 @@ function openModal(id) {
const isFlexible = member['근무시간'] === '유연근무제'; const isFlexible = member['근무시간'] === '유연근무제';
orgFields += ` orgFields += `
<div class="col-span-1"> <div class="col-span-1">
<label class="text-[11px] font-black text-slate-600 block">근무 상태</label> <label class="member-form-label block">근무 상태</label>
<select id="m-status" class="w-full bg-white p-3 rounded-xl border text-sm font-bold outline-none"> <select id="m-status" class="member-form-select">
<option value="근무" ${member['근무상태'] !== '휴직' && member['근무상태'] !== '퇴직' ? 'selected' : ''}>근무</option> <option value="근무" ${member['근무상태'] !== '휴직' && member['근무상태'] !== '퇴직' ? 'selected' : ''}>근무</option>
<option value="휴직" ${member['근무상태'] === '휴직' ? 'selected' : ''}>휴직</option> <option value="휴직" ${member['근무상태'] === '휴직' ? 'selected' : ''}>휴직</option>
<option value="퇴직" ${member['근무상태'] === '퇴직' ? 'selected' : ''}>퇴직</option> <option value="퇴직" ${member['근무상태'] === '퇴직' ? 'selected' : ''}>퇴직</option>
</select> </select>
</div> </div>
<div class="col-span-1"> <div class="col-span-1">
<label class="text-[11px] font-black text-slate-600 block">근무 시간</label> <label class="member-form-label block">근무 시간</label>
<select id="m-worktime" onchange="toggleFlexibleTime(this.value)" class="w-full bg-white p-3 rounded-xl border text-sm font-bold outline-none"> <select id="m-worktime" onchange="toggleFlexibleTime(this.value)" class="member-form-select">
<option value="09~18" ${!isFlexible ? 'selected' : ''}>09~18</option> <option value="09~18" ${!isFlexible ? 'selected' : ''}>09~18</option>
<option value="유연근무제" ${isFlexible ? 'selected' : ''}>유연근무제</option> <option value="유연근무제" ${isFlexible ? 'selected' : ''}>유연근무제</option>
</select> </select>
<div id="flexible-time-area" class="${isFlexible ? '' : 'hidden'} mt-2 flex items-center gap-2"> <div id="flexible-time-area" class="${isFlexible ? '' : 'hidden'} mt-2 flex items-center gap-2">
<input type="time" id="m-work-start" value="${member['유연근무_시작'] || '09:00'}" class="bg-indigo-50 p-2 rounded-lg border border-indigo-100 text-xs font-bold w-full"> <input type="time" id="m-work-start" value="${member['유연근무_시작'] || '09:00'}" class="member-form-time">
<input type="time" id="m-work-end" value="${member['유연근무_종료'] || '18:00'}" class="bg-indigo-50 p-2 rounded-lg border border-indigo-100 text-xs font-bold w-full"> <input type="time" id="m-work-end" value="${member['유연근무_종료'] || '18:00'}" class="member-form-time">
</div> </div>
</div> </div>
</div>`; </div>`;
fieldsArea.innerHTML = ` fieldsArea.innerHTML = `
<div class="flex border-b mb-6 sticky top-0 bg-white z-10"> <div class="member-modal-tabs">
<button id="modal-tab-basic" onclick="switchModalTab('basic')" class="flex-1 py-3 font-bold border-b-2 border-indigo-600 text-indigo-600 text-sm transition-all">기본 정보</button> <button id="modal-tab-basic" onclick="switchModalTab('basic')" class="member-modal-tab is-active">기본 정보</button>
<button id="modal-tab-org" onclick="switchModalTab('org')" class="flex-1 py-3 font-bold border-b-2 border-transparent text-slate-400 text-sm transition-all">조직 및 근무</button> <button id="modal-tab-org" onclick="switchModalTab('org')" class="member-modal-tab">조직 및 근무</button>
</div> </div>
<div id="modal-sec-basic" class="modal-form-grid member-basic-editor"> <div id="modal-sec-basic" class="modal-form-grid member-basic-editor">
<input type="hidden" id="m-id" value="${id || ''}"> <input type="hidden" id="m-id" value="${id || ''}">
@@ -1216,6 +1216,7 @@ function openModal(id) {
<div class="member-basic-split"> <div class="member-basic-split">
<div class="member-basic-left"> <div class="member-basic-left">
<div class="member-photo-panel"> <div class="member-photo-panel">
<p class="member-modal-panel-title">기본 정보</p>
<div class="member-photo-upload-card member-photo-upload-card-inline"> <div class="member-photo-upload-card member-photo-upload-card-inline">
<div class="member-photo-card-title">프로필 사진</div> <div class="member-photo-card-title">프로필 사진</div>
<div class="member-photo-preview-wrap"> <div class="member-photo-preview-wrap">
@@ -1230,42 +1231,46 @@ function openModal(id) {
</div> </div>
</div> </div>
</div> </div>
<div class="member-basic-fields"> <div class="member-basic-fields member-modal-panel">
<p class="member-modal-panel-title">기본 정보</p>
<div class="member-basic-field"> <div class="member-basic-field">
<label class="text-[11px] font-black text-slate-600 block">이름 (필수)</label> <label class="member-form-label block">이름 (필수)</label>
<input id="m-name" value="${member['이름'] || ''}" oninput="syncPhotoPreviewFromUrl()" class="w-full bg-slate-50 p-3 rounded-xl border font-bold text-sm outline-none"> <input id="m-name" value="${member['이름'] || ''}" oninput="syncPhotoPreviewFromUrl()" class="member-form-input">
</div> </div>
<div class="member-basic-field"> <div class="member-basic-field">
<label class="text-[11px] font-black text-slate-600 block">사번</label> <label class="member-form-label block">사번</label>
<input id="m-employee-id" value="${member['사번'] || ''}" class="w-full bg-white p-3 rounded-xl border font-bold text-sm outline-none"> <input id="m-employee-id" value="${member['사번'] || ''}" class="member-form-input">
</div> </div>
<div class="member-basic-field"> <div class="member-basic-field">
<label class="text-[11px] font-black text-slate-600 block">전화번호</label> <label class="member-form-label block">전화번호</label>
<input id="m-phone" value="${member['전화번호'] || ''}" class="w-full bg-white p-3 rounded-xl border font-bold text-sm outline-none"> <input id="m-phone" value="${member['전화번호'] || ''}" class="member-form-input">
</div> </div>
<div class="member-basic-field"> <div class="member-basic-field">
<label class="text-[11px] font-black text-slate-600 block">이메일</label> <label class="member-form-label block">이메일</label>
<input id="m-email" value="${member['이메일'] || ''}" class="w-full bg-white p-3 rounded-xl border font-bold text-sm outline-none"> <input id="m-email" value="${member['이메일'] || ''}" class="member-form-input">
</div> </div>
</div> </div>
</div> </div>
<div class="member-basic-right"> <div class="member-basic-right">
<p class="member-modal-panel-title" style="padding:16px 16px 0;">조직 및 근무</p>
<div class="member-seat-field member-seat-field-compact"> <div class="member-seat-field member-seat-field-compact">
<div id="member-seat-preview">${renderSeatPreviewCard({ assigned: false, seatLabel: member['자리위치'] || '', seatMapName: '자리배치도', slotKey: '' })}</div> <div id="member-seat-preview">${renderSeatPreviewCard({ assigned: false, seatLabel: member['자리위치'] || '', seatMapName: '자리배치도', slotKey: '' })}</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
${orgFields} <div class="member-modal-panel">${orgFields}</div>
`; `;
resetPhotoPreviewObjectUrl(); resetPhotoPreviewObjectUrl();
const deleteBtn = id ? `<button onclick="deleteMember('${id}')" class="bg-red-50 text-red-600 py-3.5 px-6 rounded-xl font-bold text-sm border border-red-100 hover:bg-red-100 transition-colors">삭제</button>` : ''; const deleteBtn = id ? `<button onclick="deleteMember('${id}')" class="modal-btn modal-btn-delete">삭제</button>` : '';
footer.innerHTML = ` footer.innerHTML = `
${deleteBtn} ${deleteBtn}
<button onclick="closeModal()" class="flex-1 bg-slate-100 py-3.5 rounded-xl font-bold text-sm">취소</button> <div class="modal-footer-actions">
<button onclick="saveMember()" class="flex-1 bg-indigo-600 text-white py-3.5 rounded-xl font-bold text-sm">저장</button> <button onclick="closeModal()" class="modal-btn modal-btn-cancel">취소</button>
<button onclick="saveMember()" class="modal-btn modal-btn-save">저장</button>
</div>
`; `;
modal.style.display = 'flex'; modal.style.display = 'flex';
if (id) { if (id) {
@@ -1420,8 +1425,8 @@ function openListViewModal(event) {
</div> </div>
</div> </div>
<div class="list-toolbar-row"> <div class="list-toolbar-row">
<input type="text" id="list-search-input" placeholder="이름 또는 부서/팀 검색 (Enter 시 이동)" class="flex-1 bg-slate-50 border-2 border-slate-100 p-3 rounded-xl text-sm outline-none font-bold focus:border-indigo-400 transition-all" onkeydown="if(event.key==='Enter') handleListSearch(this.value)"> <input type="text" id="list-search-input" placeholder="이름 또는 부서/팀 검색 (Enter 시 이동)" class="flex-1 bg-[#f6eddd] border-2 border-[#e0d0b4] p-3 rounded-xl text-sm outline-none font-bold text-[#1f2f25] focus:border-[#d68a3a] transition-all" onkeydown="if(event.key==='Enter') handleListSearch(this.value)">
<button type="button" onclick="handleListSearch(document.getElementById('list-search-input').value)" class="bg-indigo-600 text-white px-5 rounded-xl font-bold text-sm">검색</button> <button type="button" onclick="handleListSearch(document.getElementById('list-search-input').value)" class="bg-[#214634] text-white px-5 rounded-xl font-bold text-sm">검색</button>
</div> </div>
<div id="list-view-status" class="list-view-status"></div> <div id="list-view-status" class="list-view-status"></div>
</div> </div>
@@ -1453,19 +1458,19 @@ function renderListViewFooter() {
footer.innerHTML = ` footer.innerHTML = `
<div class="flex gap-2 w-full justify-between items-center"> <div class="flex gap-2 w-full justify-between items-center">
<div class="flex gap-2"> <div class="flex gap-2">
<button onclick="openAddModal(event)" class="bg-indigo-50 text-indigo-600 px-4 py-2 rounded-lg text-xs font-bold border border-indigo-100">+ 구성원 추가</button> <button onclick="openAddModal(event)" class="bg-[#f6eddd] text-[#214634] px-4 py-2 rounded-lg text-xs font-bold border border-[#e0d0b4]">+ 구성원 추가</button>
<button onclick="openUnitAddModal(event)" class="bg-indigo-50 text-indigo-600 px-4 py-2 rounded-lg text-xs font-bold border border-indigo-100">+ 조직 추가</button> <button onclick="openUnitAddModal(event)" class="bg-[#f6eddd] text-[#214634] px-4 py-2 rounded-lg text-xs font-bold border border-[#e0d0b4]">+ 조직 추가</button>
</div> </div>
<div class="flex gap-2 items-center"> <div class="flex gap-2 items-center">
<p class="text-[10px] text-slate-400 font-bold mr-4">항목을 드래그하여 순서를 바꿀 수 있습니다.</p> <p class="text-[10px] text-[#8b8a77] font-bold mr-4">항목을 드래그하여 순서를 바꿀 수 있습니다.</p>
<button onclick="closeModal()" class="bg-slate-100 text-slate-600 px-6 py-2 rounded-lg text-xs font-bold">취소</button> <button onclick="closeModal()" class="bg-[#efe4d0] text-[#5b665a] px-6 py-2 rounded-lg text-xs font-bold">취소</button>
<button onclick="applyListViewChanges()" class="bg-indigo-600 text-white px-8 py-2 rounded-lg text-xs font-bold shadow-lg shadow-indigo-200">반영하기</button> <button onclick="applyListViewChanges()" class="bg-[#214634] text-white px-8 py-2 rounded-lg text-xs font-bold shadow-lg shadow-[#d6c1a3]">반영하기</button>
</div> </div>
</div> </div>
`; `;
return; return;
} }
footer.innerHTML = '<div class="flex gap-2 w-full justify-end items-center"><button onclick="closeModal()" class="bg-indigo-600 text-white px-10 py-2.5 rounded-lg text-xs font-bold shadow-lg shadow-indigo-200">닫기</button></div>'; footer.innerHTML = '<div class="flex gap-2 w-full justify-end items-center"><button onclick="closeModal()" class="bg-[#214634] text-white px-10 py-2.5 rounded-lg text-xs font-bold shadow-lg shadow-[#d6c1a3]">닫기</button></div>';
} }
function getRenderableListMembers() { function getRenderableListMembers() {

View File

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

22
scripts/publish_ledger_app.sh Executable file
View File

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

13
scripts/publish_payment_app.sh Executable file
View File

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

13
scripts/publish_team_app.sh Executable file
View File

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