2 Commits

Author SHA1 Message Date
hyunho
40ff4f01ac docs: record 2026-04-01 refactor and db work 2026-04-01 18:10:13 +09:00
hyunho
d0e055973e refactor: promote 8081 design system and served app structure 2026-04-01 14:50:08 +09:00
51 changed files with 24504 additions and 823 deletions

View File

@@ -21,7 +21,7 @@ import ezdxf
from ezdxf import recover
from fastapi import FastAPI, File, Form, Header, HTTPException, Request, UploadFile
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 openpyxl import load_workbook
from pydantic import BaseModel, Field
@@ -42,6 +42,11 @@ app.add_middleware(
LEGACY_STATIC_DIR = LEGACY_DIR / "static"
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_CONFIGS = {
"technical-development-center": {
@@ -61,6 +66,7 @@ FIXED_OFFICE_CONFIGS = {
},
}
_fixed_office_cache: dict[str, dict[str, object]] = {}
BUSINESS_LEDGER_DEFAULT_SOURCE_KEY = "business_ledger_default"
AUTH_DEFAULT_PASSWORD = "1111"
AUTH_PASSWORD_ITERATIONS = 390000
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):
id: int | None = None
@@ -3910,6 +3976,7 @@ def startup() -> None:
init_db()
with get_conn() as conn:
with conn.cursor() as cur:
sync_default_business_ledger_source(cur)
sync_auth_users_from_members(cur)
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")
def auth_login(
request: Request,
@@ -4500,15 +4598,30 @@ def legacy_organization_backup() -> FileResponse:
@app.get("/integrations/payment")
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():
raise HTTPException(status_code=404, detail="Payment integration file not found.")
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")
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():
raise HTTPException(status_code=404, detail="MH integration file not found.")
return FileResponse(target)

View File

@@ -228,10 +228,354 @@
- [HISTORY_ASOF_DB_PLAN.md](/home/hyunho/projects/mh-dashboard-organization/docs/HISTORY_ASOF_DB_PLAN.md)
## 12. 2026-04-01 구조 안정화, DB 가시화, 자리배치도 정리
### 왜 이 작업을 했는가
이번 작업의 목적은 새 기능을 더 붙이기 전에, 지금까지 쌓인 구조를 먼저 안정적으로 정리하는 것이었다.
겉으로 보기에는 화면이 어느 정도 동작하고 있었지만, 실제 내부는 다음과 같은 위험이 있었다.
- 화면마다 구현 방식이 달라서 어디를 수정해야 하는지 바로 알기 어려움
- 원본 참고 파일과 실제 서비스 파일이 섞여 있어, 작업할수록 다시 꼬일 가능성이 큼
- DB는 이미 중요한 역할을 하고 있었지만, 비개발자 입장에서는 "정말 저장이 되고 있는가", "무엇이 들어 있는가"를 직접 확인하기 어려움
- 구조를 건드릴 때 사업관리대장처럼 예상하지 못한 회귀가 생길 수 있었음
즉, 이번 작업은 "새 기능 추가"보다 "앞으로 기능을 안전하게 추가할 수 있는 바닥공사"에 가까웠다.
### 무엇을 바꿨는가
이번에는 크게 다섯 가지 축으로 정리했다.
1. 디자인과 화면 구조 기준 정리
2. 실제 서비스 코드와 참고 원본 파일 분리
3. 백엔드 라우트 구조 분리
4. DB 상태를 눈으로 볼 수 있는 운영 화면 추가
5. 자리배치도 실사용성 개선과 회귀 방지 장치 추가
이 작업은 단순 정리처럼 보일 수 있지만, 실제로는 "어디가 진짜 기준인지"를 다시 세우는 과정이었다.
추가로, 사용자가 실제로 가장 자주 보는 상단 탭 경험도 함께 다시 손봤다.
이번에 정리한 상단 주요 화면은 다음과 같다.
- 사업관리대장
- 프로젝트별 분석
- 팀/개인별 분석
- 조직현황
이 네 화면은 이전까지는 각각 따로 발전해 온 흔적이 강했다.
즉, 같은 시스템 안에 있지만 화면마다 표정이 달랐고, 어떤 화면은 오래된 파란 톤이 남아 있었고, 어떤 화면은 새 스타일이 일부만 적용되어 있었다.
이번에는 이 네 화면을 "각자 따로 만들어진 페이지"가 아니라 "하나의 대시보드 안에 있는 연결된 기능"처럼 보이도록 맞추는 작업도 함께 진행했다.
### 리팩토링을 왜 했는가
기존에는 하나의 파일이나 하나의 화면이 너무 많은 역할을 동시에 맡고 있었다.
예를 들어 백엔드 메인 파일은 인증, 멤버, 통합 데이터, 정적 파일 서빙, 자리배치도까지 한곳에 몰려 있었고, 프런트도 화면에 따라 원본 파일을 직접 쓰는 곳과 override를 덧씌우는 곳이 섞여 있었다.
이 구조는 처음엔 빠르게 화면을 올리는 데 도움이 되지만, 일정 시점이 지나면 문제가 생긴다.
- 작은 수정이 예상치 못한 다른 화면에 영향을 줄 수 있음
- 회귀 원인을 찾는 데 시간이 오래 걸림
- 새 작업자가 들어오면 전체 구조를 이해하기 어려움
- 특정 파일이 "원본인지", "실행본인지", "참고용 복사본인지" 헷갈리게 됨
그래서 이번에는 "기능을 더 붙이기 전에 구조를 분리하는 것"을 우선했다.
### 리팩토링을 어떻게 진행했는가
#### 1. 실제 서비스 코드와 참고 원본을 분리
사업관리대장, 프로젝트별 분석, 팀/개인별 분석은 처음엔 원본 파일, 참고 파일, 실제 서비스 파일이 섞여 있는 상태였다.
이 상태에서는 수정할 때마다 "지금 내가 만지는 파일이 실제 서비스에 반영되는 파일이 맞는가"를 계속 확인해야 했다.
그래서 다음 기준으로 재정리했다.
- `reference`: 비교와 복구를 위한 참고 원본
- `served`: 실제 서비스가 읽는 런타임 파일
- `frontend/apps/*`: 앞으로 수정해야 하는 앱 소스
특히 `ledger`, `payment`, `team` 화면은 모두 `app source -> publish -> served` 구조로 다시 맞췄다.
이 의미는 다음과 같다.
- 작업자는 원본 참고 파일을 직접 수정하지 않는다
- 앱 소스에서 수정한다
- publish 스크립트로 실제 서비스 파일을 만든다
- 백엔드는 이 실제 서비스 파일만 서빙한다
이렇게 하면 나중에 유지보수할 때 "수정 원본"과 "실행 결과물"이 명확히 나뉜다.
#### 2. 디자인 기준을 공통 SSOT로 승격
이전에는 각 화면에 과거 파란 톤, 임시 색상, override 스타일이 섞여 있었다.
그래서 어떤 화면은 새 디자인 규칙을 따르는데, 어떤 화면은 예전 색이 다시 튀어나오는 문제가 반복됐다.
이번에는 이를 막기 위해 다음 기준을 승격했다.
- `design-tokens.css`
- `design-patterns.css`
- `DESIGN_SSOT.md`
즉, 앞으로 디자인 수정은 "이 화면만 예쁘게"가 아니라 "공통 디자인 규칙 안에서 일관되게" 하는 방향으로 정리했다.
비개발자 관점에서는 "화면마다 조금씩 다른 앱"처럼 보이던 것을, "하나의 시스템처럼 보이게" 만드는 작업이었다고 볼 수 있다.
이 과정에서 실제로 한 작업은 다음과 같다.
- 사업관리대장, 프로젝트별 분석, 팀/개인별 분석, 조직현황의 메인 폭을 같은 기준으로 맞춤
- 공통 카드, 버튼, KPI, 표, 팝업의 색과 대비를 비슷한 문법으로 정리
- 과거 파란 계열이 다시 드러나는 부분을 찾아 공통 토큰 기준으로 재정리
- 각 화면에서 "지금 당장 보기 좋게" 끝내지 않고, 앞으로도 같은 규칙을 따라갈 수 있도록 공통 패턴으로 승격
특히 프로젝트별 분석과 팀/개인별 분석은 원래 화면 내부에 이전 스타일 흔적이 많이 남아 있었는데, 이번에는 이 부분을 단순 덮어쓰기보다 "기준 디자인을 바라보게 만드는 방향"으로 손봤다.
#### 2-1. 왜 네 개 탭을 먼저 다시 맞췄는가
이번 세션에서는 단순 리팩토링만 한 것이 아니다.
사용자가 실제로 매일 보는 네 개 주요 탭의 경험을 먼저 안정화하는 것이 중요했다.
그 이유는 다음과 같다.
- 화면마다 스타일이 다르면 사용자는 기능이 다른 것보다 "시스템이 불안정하다"는 인상을 먼저 받음
- 새 기능을 추가할 때마다 이전 스타일이 다시 나타나면, 작업 결과가 누적되지 않고 계속 되돌아감
- 세미나나 설명 자리에서도 "정리되고 있다"는 느낌을 전달하려면, 먼저 눈에 보이는 화면이 하나의 제품처럼 보여야 함
그래서 이번에는 단순히 코드 구조를 정리하는 것과 함께, 네 개 탭의 인상과 문법을 맞추는 작업도 같이 진행했다.
#### 2-2. 사업관리대장은 어디까지 손봤는가
사업관리대장은 이번 세션에서 가장 많은 변화가 있었던 화면 중 하나다.
- 상단 탭에서 직접 열리도록 연결
- 기본 로우데이터 엑셀과 연동
- 원본 화면 구조를 참고해 연도 버튼, KPI, 본문 표, 상세 팝업까지 단계적으로 복원
- 클릭 시 프로젝트 상세 정보를 열 수 있게 연결
- 메인 화면과 상세 팝업 디자인을 현재 디자인 큐에 맞게 정리
다만 중요한 점은, 이번에 맞춘 것은 "보이는 구조와 기본 기능"까지라는 것이다.
세부 숫자와 집계 기준, 어떤 값이 어떻게 계산되는지는 원본 작성자 기준 확인이 필요해 후속으로 남겨 두었다.
즉, 이번에는 사업관리대장을 "쓸 수 있는 상태"까지 올렸고, 다음 단계에서 "정확한 상태"로 맞출 준비를 끝낸 것이다.
#### 2-3. 프로젝트별 분석과 팀/개인별 분석은 무엇이 바뀌었는가
두 화면은 모두 이미 기능은 있었지만, 디자인과 유지보수 구조가 흔들리는 상태였다.
이번에 바뀐 점은 다음과 같다.
- 프로젝트별 분석
- 메인 표, KPI, 필터, 패널, 상세 강조 색을 공통 디자인 기준으로 재정리
- 실제 서비스 파일과 수정 원본의 기준을 명확히 분리
- 팀/개인별 분석
- 배경, 카드, 보조 정보, 캘린더 note, 상태 표현 등을 공통 디자인 기준으로 재정리
- 과거 스타일 흔적을 줄이고, 앞으로도 같은 방식으로 고칠 수 있는 구조로 이동
즉, 두 화면 모두 "이번 한 번 예쁘게 고친" 것이 아니라 "앞으로도 같은 기준으로 유지될 수 있게" 손봤다는 점이 중요하다.
#### 2-4. 조직현황은 무엇이 바뀌었는가
조직현황은 기존에도 중요한 화면이었지만, 스타일과 인터랙션이 다소 오래된 느낌으로 남아 있었다.
이번에는 다음을 정리했다.
- 상세 프로필, 수정 모달, 버튼, 카드, 탭, 통계 영역의 색과 대비 조정
- 관리자 모드 버튼, 추가 버튼, 상세 정보 패널의 톤 정리
- 자리배치도와 연결되는 미리보기 카드, 조직 구조 표현 가독성 개선
즉, 조직현황은 단순 디자인 수정이 아니라 "관리자가 실제로 쓰는 화면"으로서 읽기 편하게 정리하는 방향으로 손봤다.
#### 3. 백엔드 메인 파일의 역할 분리
백엔드도 한 파일에 너무 많은 기능이 몰려 있었다.
그래서 메인 파일에서 기능별 라우트를 분리했다.
이번에 분리한 범위는 다음과 같다.
- 시스템/서빙 라우트
- 인증 라우트
- 멤버/히스토리 라우트
- 통합 데이터 라우트
- 자리배치도/업로드 라우트
이 작업을 통해 얻은 가장 큰 장점은 "문제가 났을 때 어디를 봐야 하는지가 빨라졌다"는 점이다.
예전에는 메인 파일을 전체 검색해야 했다면, الآن은 인증 문제면 인증 파일을, 자리배치도 문제면 자리배치도 라우트 파일을 먼저 보면 된다.
### DB 작업을 왜 했는가
이번 세션에서 DB 작업을 한 이유는 "DB가 이상해서"가 아니라, "DB가 이미 중요한 역할을 하고 있는데 너무 안 보였다"는 점 때문이다.
실제로는 이미 많은 데이터가 DB에 저장되고 있었다.
- 구성원 정보
- 자리배치도 정보
- 통합 원본 적재 정보
- 인증 정보
- 이력 관련 테이블
하지만 비개발자 입장에서는 이것이 잘 보이지 않았다.
즉, "DB가 있다"고만 듣고 실제로 어떤 테이블이 있고 무슨 역할인지 보지 못하면, 운영 기준을 잡기 어렵다.
그래서 이번에는 DB를 "보이지 않는 저장소"에서 "운영자가 확인할 수 있는 대상"으로 바꾸는 작업을 했다.
### DB 작업을 어떻게 했는가
#### 1. DB 상태 탭 추가
허브 안에 `DB 상태` 탭을 만들었다.
이 화면에서는 다음을 확인할 수 있다.
- 전체 테이블 수
- 등록 인원/재직 인원
- 자리배치도 도면 현황
- 핵심 운영 테이블과 전체 테이블 목록
- 테이블별 간단 설명
- 테이블 클릭 시 컬럼과 샘플 데이터 미리보기
- CSV 다운로드
즉, 이제는 SQL을 직접 몰라도 "어떤 데이터가 어디에 저장되는지"를 눈으로 볼 수 있다.
#### 2. 테이블 역할 분류
전체 테이블을 그냥 나열만 하면 오히려 더 복잡해 보이기 때문에, 역할별로 다시 분류했다.
- 유지
- 주의
- 원본/추적
- 정리 후보
이 분류를 통해 "지금 DB가 너무 큰가?"라는 질문에 대해, 단순 개수 대신 역할 기준으로 판단할 수 있게 만들었다.
#### 3. 불필요한 테이블과 과거 실험 흔적 정리
이번에 실제로 확인해보니, 현재 코드에서 쓰지 않는 테이블이 하나 있었고, 과거 DXF 시도본도 많이 쌓여 있었다.
그래서 다음 정리를 진행했다.
- 미사용 테이블 `entity_change_events` 삭제
- 과거 DXF 시도본 정리
- 최신 DXF 1개와 실제 운영용 고정 도면 3개만 유지
이 작업은 "DB를 줄였다"기보다 "운영에 필요한 것과 과거 흔적을 분리했다"는 의미에 가깝다.
#### 4. 8080과 8081의 역할도 다시 정리
이번 세션에서는 개발용 `8081`에서 검증된 코드 중, 안정적으로 승격 가능한 부분만 `8080` 기준 코드로 올리는 작업도 진행했다.
여기서 중요한 원칙은 "통째로 덮어쓰기"가 아니라 "검증된 것만 선별 승격"이었다.
즉 다음 원칙을 지켰다.
- `8081`은 계속 작업과 검증을 위한 공간으로 유지
- `8080`은 공개 기준으로 유지
- 디자인 SSOT, 앱 소스 구조, 런타임 서빙 구조처럼 안정성이 확인된 부분만 `total`로 승격
- DB 자체는 함부로 합치지 않고, 코드와 구조만 먼저 정리
이렇게 해야 운영 기준을 흔들지 않으면서도, 개선된 구조를 실제 기준 코드에 반영할 수 있다.
### 무엇이 개선되었는가
이번 작업으로 개선된 점은 매우 명확하다.
#### 1. 유지보수 포인트가 분명해졌다
예전에는 같은 기능을 수정해도 어디를 건드려야 하는지 여러 파일을 동시에 의심해야 했다.
지금은 앱 소스, 서비스 파일, 참고 원본의 역할이 나뉘어서 수정 위치가 명확해졌다.
#### 2. 화면 회귀를 더 빨리 잡을 수 있게 됐다
사업관리대장 데이터가 한 번 끊겼을 때 원인은 DB 문제가 아니라, 한글 파일명을 응답 헤더에 그대로 넣으면서 생긴 인코딩 오류였다.
이런 문제는 구조가 정리돼 있지 않으면 찾는 데 오래 걸린다.
이번에는 원인을 빠르게 좁혀서 복구했고, 같은 문제가 다시 생기지 않도록 `8081` smoke check 스크립트도 추가했다.
즉, 이제는 구조를 바꾼 뒤 바로 핵심 화면과 API를 빠르게 점검할 수 있다.
#### 3. DB를 설명 가능한 상태로 만들었다
이전에는 "DB가 있다"는 사실만 있었고, 실제로 어떤 상태인지 보기 어려웠다.
이제는 운영자가 DB 상태를 화면으로 확인하고, 테이블을 눌러 실제 샘플 데이터를 볼 수 있다.
세미나나 내부 설명 자리에서도 훨씬 설명하기 쉬운 상태가 됐다.
#### 4. 자리배치도 기능이 실사용 방향으로 조금 더 진전됐다
자리배치도에서는 다음이 개선됐다.
- 클릭한 인원의 상위 조직 트리 표시
- 검색 카드 동작 정리
- 인원 카드 정보 구조 정리
- 비관리자 모드 재렌더 안정화
- 미배치/배치 상태 시각화 기준 정리 준비
- 팀 구역 오버레이 기능 시도와 요구사항 정리
즉, 단순히 "보이는 화면"이 아니라, 실제 조직과 사람을 읽기 쉬운 화면으로 한 걸음 더 나아갔다.
#### 5. 회귀 방지 체계를 붙였다
이번 세션에서 중요한 개선 중 하나는 "문제가 생긴 뒤 찾는 방식"에서 "문제가 생겼는지 바로 확인하는 방식"으로 한 걸음 이동한 점이다.
이를 위해 `8081` smoke check 스크립트를 추가했다.
이 스크립트는 다음을 한 번에 점검한다.
- 서버 health
- DB 상태 화면
- 사업관리대장 기본 원본 API
- 프로젝트별 분석
- 팀/개인별 분석
- 사업관리대장
- 조직현황 연결
즉, 구조를 고친 뒤 "겉으로는 멀쩡해 보이는데 실제로는 한 기능이 깨져 있는 상태"를 빨리 잡을 수 있게 된 것이다.
### 오늘 확인된 문제와 한계
이번 작업이 모든 것을 끝낸 것은 아니다.
오히려 구조를 정리하면서, 앞으로 무엇을 더 손봐야 하는지도 더 분명해졌다.
#### 1. 사업관리대장 세부 데이터 정합성은 아직 보류
사업관리대장은 디자인과 기본 기능 연결은 올라왔지만, 세부 수치와 표출 규칙은 원본 작성자와 기준을 맞춰야 한다.
즉, "대충 맞아 보이는 수준"이 아니라 "원본 의도와 동일한 수준"으로 맞추려면 담당자 확인이 필요하다.
#### 2. 자리배치도 `#7`은 아직 재작업 필요
팀 구역 오버레이 기능은 의도 자체는 맞게 해석했고 데이터도 들어가지만, 화면에서 반짝 나타났다가 사라지는 문제가 남아 있다.
즉, 기능 방향은 맞지만 렌더링 타이밍이나 레이어 처리에서 다시 손봐야 한다.
#### 3. 조직현황은 아직 앱 구조로 완전히 승격되지 않음
`ledger`, `payment`, `team`은 앱 소스 구조로 정리했지만, 조직현황은 아직 레거시 구조를 유지하고 있다.
장기적으로는 이것도 같은 기준으로 승격하는 것이 맞다.
### 앞으로 남은 목표
이번 작업 이후의 목표는 다음과 같다.
#### 1. 사업관리대장 기준 정렬 후 정합성 보정
원본 작성자와 함께 세부 데이터 표출 규칙, KPI 집계 방식, 상세 팝업 기준을 확인한 뒤 정확도를 맞춘다.
#### 2. 자리배치도 `#7`, `#8` 완성
- 팀 구역 오버레이를 안정적으로 보이게 수정
- 배치/미배치 시각 규칙 정리
- 검색과 클릭 시 정보 노출 방식 마무리
#### 3. 백엔드 정리 후속
라우트 분리는 많이 진행됐지만, 장기적으로는 도메인 로직까지 더 분리해서 유지보수성을 높일 필요가 있다.
#### 4. DB 운영 문서와 상태 화면 고도화
지금은 DB를 "볼 수 있게 만든" 단계다.
앞으로는 화면별 데이터 흐름, 적재 이력, 원본 로우데이터 확인 기능까지 더 강화하면 운영 설명력이 더 올라간다.
#### 5. 네 개 주요 탭의 공통 문법을 계속 지켜야 한다
이번에 디자인과 구조를 다시 맞췄다고 해서 끝난 것은 아니다.
앞으로 새 기능을 넣을 때도 각 화면이 제각각 다른 방식으로 다시 흩어지지 않게 유지해야 한다.
즉, 이번 작업의 진짜 성과는 "한 번 예쁘게 고쳤다"가 아니라 "앞으로도 같은 방식으로 고칠 수 있는 기준을 세웠다"는 데 있다.
## Next Focus
- `#2` 영속성 운영 검증과 문서 기준
- 사업관리대장 원본 담당자와 세부 데이터 규칙
- 자리배치도 `#7`, `#8` 재작업 및 마무리
- 권한 제어와 mock login 정리
- `#9` as-of date 기반 history 구조 설계 및 점진적 도입
- 자리배치도 조직 트리, 나머지 사무실 도면 등 실사용 기능 고도화
- 프로젝트별 분석의 남은 소수점/분류 오차 정리
- 조직현황의 장기적 앱 구조 승격 검토

View File

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

View File

@@ -2,193 +2,152 @@
## Current Base
- branch: `total`
- latest checked commit: `24852d4`
- main history doc: [DEVELOPMENT_HISTORY.md](/home/hyunho/projects/mh-dashboard-organization/docs/DEVELOPMENT_HISTORY.md)
- work rulebook: [WORK_RULEBOOK.md](/home/hyunho/projects/mh-dashboard-organization/docs/WORK_RULEBOOK.md)
- execution flow: [WORK_EXECUTION_FLOW.md](/home/hyunho/projects/mh-dashboard-organization/docs/WORK_EXECUTION_FLOW.md)
- dev/prod protocol: [DEV_PROD_DB_PROTOCOL.md](/home/hyunho/projects/mh-dashboard-organization/docs/DEV_PROD_DB_PROTOCOL.md)
- regression checklist: [REGRESSION_CHECKLIST.md](/home/hyunho/projects/mh-dashboard-organization/docs/REGRESSION_CHECKLIST.md)
- today prep note: [TODAY_WORK_PREP_2026-03-30.md](/home/hyunho/projects/mh-dashboard-organization/docs/TODAY_WORK_PREP_2026-03-30.md)
- `8080` 공개 기준 브랜치: `total`
- `8081` 작업 기준 브랜치: `work-8081`
- `8080` 공개 기준 커밋: `637b390`
- `8081` worktree 경로: `/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081`
- `8081` 실제 서빙 책임 맵: [architecture/8081_SERVING_MAP.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/architecture/8081_SERVING_MAP.md)
- 메인 히스토리: [DEVELOPMENT_HISTORY.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/DEVELOPMENT_HISTORY.md)
- 작업 룰북: [WORK_RULEBOOK.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/WORK_RULEBOOK.md)
- 실행 플로우: [WORK_EXECUTION_FLOW.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/WORK_EXECUTION_FLOW.md)
- dev/prod DB 프로토콜: [DEV_PROD_DB_PROTOCOL.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/DEV_PROD_DB_PROTOCOL.md)
- 회귀 체크리스트: [REGRESSION_CHECKLIST.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/REGRESSION_CHECKLIST.md)
## Mandatory Start Rule
아침 또는 그날의 첫 작업을 시작할 때는 코드를 수정하기 전에 반드시 아래 순서를 먼저 수행해야 한다.
첫 작업 전에는 아래 순서를 먼저 확인한다.
1. Gitea 브랜치 상태 확인
1. 브랜치 기준 확인
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. 이 문서 확인
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` 기준 검증용 복제본처럼 다룬다.
- `조직 현황`, `프로젝트별 분석`, `팀/개인별 분석`, `자리배치도`를 하나의 허브에 통합
- `payment.html`, `mh.html`을 현재 프로젝트에 편입
- 공통 헤더, 탭, 로그인 정보, 공통 기간 제어 구성
## What Was Stabilized
### Integrated DB
### Branch / Worktree Safety
- `organization.xlsx`, `MH.xlsx`, `payment.csv`, `ptj.csv` 기반 통합 DB 구성
- raw/staging/standard 성격의 구조를 PostgreSQL에 반영
- `members`, `seat_maps`, `seat_slots`, `seat_positions`
- `integration_raw_*`, `integration_work_logs`, `integration_work_log_segments`, `integration_vouchers`
- 프로젝트 카테고리 매핑 반영
- 기존 `8081` 작업본은 [`.dev-worktree-8081-backup-2026-04-01`](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081-backup-2026-04-01)로 보존
- 현재 [`.dev-worktree-8081`](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081)는 `work-8081` 기준으로 재생성
- `8080` 루트 workspace는 그대로 두고 분리 운영
### Team / Member Analysis
### 8081 Design / Serving Baseline
- `omh.html` 원본 기준으로 계산식/카테고리/디자인 복원
- DB raw MH 데이터를 원본 입력 구조처럼 다시 공급하는 방식으로 정리
- 디자인 SSOT 토큰:
- [frontend/public/design-tokens.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/design-tokens.css)
- 디자인 SSOT 패턴:
- [frontend/public/design-patterns.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/design-patterns.css)
- 디자인 기준 문서:
- [architecture/DESIGN_SSOT.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/architecture/DESIGN_SSOT.md)
- 로그인 기본 스타일은 [frontend/public/styles.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/styles.css) 기준으로 유지
- `8081` 허브 전용 디자인은 [frontend/public/styles-8081-design.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/styles-8081-design.css)에서만 덮어씀
- 조직현황은 [legacy/static/common.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/legacy/static/common.css), [legacy/static/organization.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/legacy/static/organization.css), [legacy/static/organization.js](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/legacy/static/organization.js)를 사용
- 프로젝트별 분석 디자인은 [incoming-files/served/payment.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/incoming-files/served/payment.html) 내부에서 `design-tokens.css` + `design-patterns.css`를 참조
- 프로젝트별 분석 수정 원본은 [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` 원본 기준으로 화면 복원
- `payment.csv` 분류 우선, `ptj.csv` fallback 적용
- 연장근무는 `연장근무 시간(가공)` 기준으로 반영
### Organization / Seat Map
- 조직도 상세 프로필에 `재석위치` preview 연결
- 관리자/비관리자 자리배치도 화면 분리
- 저장 후 조직도와 비관리자 열람에 반영되도록 seat save 흐름 정리
- seat persistence bug 수정
- 원인: `seat_positions_map_cell_idx`가 slot 기반 도면에도 적용됨
- 조치: `seat_slot_id IS NULL`인 grid map에만 적용되도록 수정
### Member Data Governance
- 이름 alias, 퇴사 제외, 조직 override를 DB 테이블 기반으로 전환
- 사용 테이블:
- `member_aliases`
- `member_retirements`
- `member_overrides`
### Auth Baseline
- 실제 로그인 API 연결 완료
- 프런트 로그인 화면이 `/api/auth/login` 사용
- 세션/로그아웃/세션 조회 API 구성 완료
- 사용 테이블:
- `auth.users`
- `auth.sessions`
- `auth.login_audit_logs`
- 현재 남은 범위:
- mock login 정리
- 역할별 권한 체크 적용
- 쓰기 API 보호 범위 정리
### External Access
- WSL 내부 8080 리슨 확인
- 현재 다른 PC에서 접속 확인
- 현재 기준 주소:
- `http://172.16.40.144:8080`
## Important Runtime Notes
### Dev / Prod Protocol
- 코드 선행은 `8081`, 공개 반영은 `8080`
- 데이터 정본은 `8080` DB
- `8081` DB는 독립 정본이 아니라 `8080` 기준 복제본처럼 관리해야 함
- `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)
1. [frontend/public/design-tokens.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/design-tokens.css)
2. [frontend/public/design-patterns.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/design-patterns.css)
3. 화면별 실제 서빙 파일
주의:
- 현재 코드에는 조직도/자리배치도 버전 이력 기능이 아직 없음
- 월간 스냅샷 방향은 범위에서 제외
### Project Analysis Accuracy
- `incoming-files/sample style.css`는 참고 기준이지만 직접 런타임 수정 파일이 아니다.
- `incoming-files` 원본/reference 파일을 먼저 고치지 않는다.
- 새 디자인 수정은 먼저 토큰/패턴 파일에서 해결 가능한지 확인한 뒤, 불가피할 때만 화면별 파일에 내린다.
- 총합은 거의 맞았지만 일부 프로젝트 단위 소수점/분류 오차는 추가 정밀 보정 필요
- `opayment` 기준으로 특정 프로젝트 차이를 계속 줄여야 함
### 1차 구조 정리 진행분
### 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
1. `#2` 범위를 현재 코드 기준으로 재정의하고 영속성 운영 검증 완료
2. `#5`에서 권한 체크, mock login 정리, 쓰기 API 보호 적용
3. `8081` DB를 `8080` 정본 기준으로 동기화하는 반복 가능한 절차 마련
4. `#9`를 as-of date 기반 history 구조로 설계 후 `members`, `seat_positions` 부터 이력화
5. 그 다음 `#8`, 나머지 도면 추가, `#7`, 프로젝트 분석 오차 보정 순으로 진행
1. `#21` 이후 기준으로 실제 서비스 파일과 reference 파일 경계를 유지
2. 사업관리대장 세부 데이터 정합성 보정
3. 그 다음 화면별 앱 구조 승격 검토
4. 필요 시 `#19`, `#20` 잔여 정리 항목 재평가
## Quick Resume Prompt
다음 세션 시작 시 아래 기준으로 이어가면 된다.
- 브랜치 `total`에서 시작
- 최근 커밋 `1d15cf9` 확인
- `docs/DEVELOPMENT_HISTORY.md`
- `docs/NEXT_SESSION_CHECKPOINT.md`
- `docs/DEV_PROD_DB_PROTOCOL.md`
- `docs/REGRESSION_CHECKLIST.md`
- `docs/HISTORY_ASOF_DB_PLAN.md`
- Gitea 이슈 `#2`, `#5`, `#9`
그리고 먼저 현재 외부 접속, 자리배치 저장, 실제 로그인 동작을 확인한 뒤 다음 기능 개발로 넘어간다.
- `8080` 기준은 `total`
- `8081` 작업은 `work-8081` + `.dev-worktree-8081`
- 먼저 [WORK_RULEBOOK.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/WORK_RULEBOOK.md), [NEXT_SESSION_CHECKPOINT.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/NEXT_SESSION_CHECKPOINT.md), [architecture/8081_SERVING_MAP.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/architecture/8081_SERVING_MAP.md) 확인
- 디자인 수정이면 [frontend/public/design-tokens.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/design-tokens.css), [frontend/public/design-patterns.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/design-patterns.css), [architecture/DESIGN_SSOT.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/architecture/DESIGN_SSOT.md) 먼저 확인
- 현재 구조 독립화 기준 이슈는 `#21`
- 작업 전 `git status`, dev 컨테이너 상태, `/api/health`, `/legacy/organization`, `/integrations/payment`, `/integrations/ledger`, `/integrations/mh`를 먼저 확인

View File

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

View File

@@ -218,6 +218,26 @@ mock, fallback, hotfix, 임시 우회 로직은 허용할 수 있다.
## 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여야 한다.
세부 규칙:

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 organizationCompareBtn = document.getElementById("organization-compare-btn");
const navButtons = Array.from(document.querySelectorAll(".header-center [data-view]"));
const ledgerFrame = document.getElementById("ledger-frame");
const ledgerStage = document.getElementById("ledger-stage");
const organizationFrame = document.getElementById("organization-frame");
const organizationStage = document.getElementById("organization-stage");
const projectFrame = document.getElementById("project-frame");
@@ -151,7 +153,7 @@ const seatMapState = {
forceReadOnly: false,
};
let currentView = "project";
let currentView = "ledger";
const globalDateState = {
loaded: false,
startDate: "",
@@ -364,6 +366,10 @@ function buildSeatMapAsOfQuery() {
}
function notifyEmbeddedTabActivated() {
if (currentView === "ledger" && ledgerFrame?.contentWindow) {
ledgerFrame.contentWindow.postMessage({ source: "total-control", type: "embedded-host" }, window.location.origin);
ledgerFrame.contentWindow.postMessage({ source: "total-control", type: "tab-activated", tab: "business" }, window.location.origin);
}
if (currentView === "project" && projectFrame?.contentWindow) {
projectFrame.contentWindow.postMessage({ source: "total-control", type: "tab-activated", tab: "project" }, window.location.origin);
}
@@ -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() {
if (globalDateState.loaded) return;
try {
@@ -1571,10 +1620,15 @@ function setActiveView(view) {
});
const isOrganization = currentView === "organization";
const isLedger = currentView === "ledger";
const isProject = currentView === "project";
const isTeam = currentView === "team";
const isSeatMapAdmin = currentView === "seatmap-admin";
const isSeatMapReadonly = currentView === "seatmap-readonly";
if (ledgerStage) {
ledgerStage.hidden = !isLedger;
ledgerStage.style.display = isLedger ? "flex" : "none";
}
if (organizationStage) {
organizationStage.hidden = !isOrganization;
organizationStage.style.display = isOrganization ? "flex" : "none";
@@ -1596,11 +1650,15 @@ function setActiveView(view) {
seatMapReadonlyStage.style.display = isSeatMapReadonly ? "flex" : "none";
}
if (emptyStage) {
const showEmpty = !isOrganization && !isProject && !isTeam && !isSeatMapAdmin && !isSeatMapReadonly;
const showEmpty = !isLedger && !isOrganization && !isProject && !isTeam && !isSeatMapAdmin && !isSeatMapReadonly;
emptyStage.hidden = !showEmpty;
emptyStage.style.display = showEmpty ? "flex" : "none";
}
if (isLedger && previousView !== "ledger" && ledgerFrame) {
const frameSrc = ledgerFrame.dataset.src || ledgerFrame.src;
ledgerFrame.src = resolveAppUrl(frameSrc);
}
if (isOrganization && previousView !== "organization" && organizationFrame) {
const frameSrc = organizationFrame.dataset.src || organizationFrame.src;
organizationFrame.src = resolveAppUrl(frameSrc);
@@ -1671,7 +1729,7 @@ if (loginForm) {
body: formData,
});
setSession(payload);
setActiveView("project");
setActiveView("ledger");
loginForm.reset();
loginMessage.textContent = "";
renderAuth();
@@ -1728,6 +1786,13 @@ organizationFrame?.addEventListener("load", () => {
postOrganizationHistoryState();
});
ledgerFrame?.addEventListener("load", () => {
if (currentView === "ledger") {
notifyEmbeddedTabActivated();
}
void pushDefaultLedgerSourceToFrame(true);
});
projectFrame?.addEventListener("load", () => {
postGlobalDateRangeToFrame(projectFrame);
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.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Pretendard:wght@400;600;700;900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/design-tokens.css?v=20260401-01">
<link rel="stylesheet" href="/design-patterns.css?v=20260401-01">
<link rel="stylesheet" href="/legacy/static/common.css">
<!-- Keep login and common hub defaults aligned with 8080. -->
<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>
<body>
<section id="login-panel" class="login-screen">
@@ -91,18 +96,26 @@
</header>
<main class="dashboard-main">
<section id="ledger-stage" class="main-stage" hidden>
<div class="stage-frame">
<iframe id="ledger-frame" src="/integrations/ledger?v=20260401-02" data-src="/integrations/ledger?v=20260401-02" title="사업관리대장 화면"></iframe>
</div>
</section>
<section id="organization-stage" class="main-stage">
<div class="stage-frame">
<iframe id="organization-frame" src="/legacy/organization?v=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>
</section>
<section id="project-stage" class="main-stage" hidden>
<div class="stage-frame">
<!-- Integration HTML is served from incoming-files/served/payment.html. -->
<iframe id="project-frame" src="/integrations/payment" data-src="/integrations/payment" title="프로젝트별 분석 화면"></iframe>
</div>
</section>
<section id="team-stage" class="main-stage" hidden>
<div class="stage-frame">
<!-- Integration HTML is served from incoming-files/served/mh.html. -->
<iframe id="team-frame" src="/integrations/mh" data-src="/integrations/mh" title="팀/개인별 분석 화면"></iframe>
</div>
</section>
@@ -213,6 +226,6 @@
</main>
</section>
<script src="/app.js?v=20260330-01"></script>
<script src="/app.js?v=20260401-02"></script>
</body>
</html>

View File

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

View File

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

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

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