diff --git a/backend/app/main.py b/backend/app/main.py
index 6b086a4..6096819 100755
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -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_DASHBOARD_WRAPPER_PATH = BUSINESS_DASHBOARD_DIR / "MH 통합 대시보드_260320.html"
+BUSINESS_DASHBOARD_THEME_CSS = BUSINESS_DASHBOARD_DIR / "MH 통합 대시보드_260320.css"
FIXED_OFFICE_SOURCE_KEY = "technical-development-center"
FIXED_OFFICE_CONFIGS = {
"technical-development-center": {
@@ -61,6 +66,8 @@ FIXED_OFFICE_CONFIGS = {
},
}
_fixed_office_cache: dict[str, dict[str, object]] = {}
+_business_ledger_html_cache: str | None = None
+BUSINESS_LEDGER_DEFAULT_SOURCE_KEY = "business_ledger_default"
AUTH_DEFAULT_PASSWORD = "1111"
AUTH_PASSWORD_ITERATIONS = 390000
AUTH_SESSION_HOURS = 12
@@ -83,6 +90,86 @@ MH_HEADER_ORDER = [
]
+def build_business_ledger_html() -> str:
+ global _business_ledger_html_cache
+ if _business_ledger_html_cache is not None:
+ return _business_ledger_html_cache
+ if not BUSINESS_DASHBOARD_WRAPPER_PATH.exists():
+ raise FileNotFoundError("Business dashboard wrapper file not found.")
+ source = BUSINESS_DASHBOARD_WRAPPER_PATH.read_text(encoding="utf-8-sig")
+ match = re.search(r"const BUSINESS_HTML_B64='([^']+)';", source)
+ if not match:
+ raise ValueError("Embedded business ledger source was not found.")
+ decoded = base64.b64decode(match.group(1)).decode("utf-8")
+ head_injection = (
+ ''
+ ''
+ ''
+ )
+ html = decoded.replace("", f"{head_injection}", 1)
+ html = html.replace("
", '', 1)
+ html = html.replace("", '', 1)
+ _business_ledger_html_cache = html
+ return html
+
+
+def sync_default_business_ledger_source(cur) -> None:
+ if not BUSINESS_DASHBOARD_DIR.exists():
+ return
+ candidates = [
+ 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_DASHBOARD_DIR), check_dir=False),
+ name="integration-ledger-assets",
+)
+
+
class MemberPayload(BaseModel):
id: int | None = None
name: str = Field(min_length=1)
@@ -3910,6 +3997,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 +4027,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 +4619,34 @@ 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() -> HTMLResponse:
+ try:
+ html = build_business_ledger_html()
+ except FileNotFoundError:
+ raise HTTPException(status_code=404, detail="Business ledger integration file not found.")
+ except ValueError:
+ raise HTTPException(status_code=500, detail="Business ledger integration source is invalid.")
+ return HTMLResponse(
+ html,
+ headers={
+ "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
+ "Pragma": "no-cache",
+ },
+ )
+
+
@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)
diff --git a/docs/NEXT_SESSION_CHECKPOINT.md b/docs/NEXT_SESSION_CHECKPOINT.md
index 3623c45..b28d7a0 100644
--- a/docs/NEXT_SESSION_CHECKPOINT.md
+++ b/docs/NEXT_SESSION_CHECKPOINT.md
@@ -2,193 +2,144 @@
## 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`를 참조
+- 사업관리대장 상세 팝업 디자인은 [incoming-files/사업관리대장/ledger-override.js](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/incoming-files/사업관리대장/ledger-override.js)에서 `design-tokens.css` + `design-patterns.css`를 직접 링크
-### 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/mh`:
+ - [incoming-files/served/mh.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/incoming-files/served/mh.html)
+
+## Cross Checks Last Confirmed
+
+- `8080`: `curl http://localhost:8080/api/health` 정상
+- `8081` dev 컨테이너: proxy/backend/frontend/db `healthy`
+- `8081` backend 내부 확인:
+ - `/api/health` 200
+ - `/legacy/organization` 200
+ - `/integrations/payment` 200
+ - `/integrations/mh` 200
+- `incoming-files/served` 내 실제 서빙 파일 존재 확인
+
+주의:
+
+- Codex 터미널 세션에서는 `curl http://localhost:8081`가 간헐적으로 실패할 수 있다.
+- 이 경우 브라우저 확인 또는 컨테이너 내부 라우트 확인을 기준으로 판단한다.
+
+## Open Issues Relevant Now
+
+- `#14` 누적된 임시 로직 정리 및 중복 코드 제거
+- `#16` 사업관리대장 메인 연동 및 기본 원본 DB화
+- `#17` 8081 분리 worktree 기동 절차와 로컬 디자인 자산 복제 고정
+- `#18` 8081 파일 책임 맵 정리 및 프런트 서빙 경로 정돈
+- `#19` 8081 백엔드 라우터/서빙 책임 분리
+- `#20` 8081 worktree 준비 스크립트·문서·운영 규칙 정리
## Recommended Next Work Order
-1. `#2` 범위를 현재 코드 기준으로 재정의하고 영속성 운영 검증 완료
-2. `#5`에서 권한 체크, mock login 정리, 쓰기 API 보호 적용
-3. `8081` DB를 `8080` 정본 기준으로 동기화하는 반복 가능한 절차 마련
-4. `#9`를 as-of date 기반 history 구조로 설계 후 `members`, `seat_positions` 부터 이력화
-5. 그 다음 `#8`, 나머지 도면 추가, `#7`, 프로젝트 분석 오차 보정 순으로 진행
+1. `#18` 범위에서 실제 서빙 파일과 비교용 파일 경계를 더 명확히 정리
+2. 사업관리대장 탭 기능 추가 전에 수정 대상 파일을 고정
+3. 그 다음 `#19`로 backend 라우터/서빙 책임 분리
+4. 마지막으로 `#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) 먼저 확인
+- 현재 1차 구조 정리 기준 이슈는 `#18`
+- 작업 전 `git status`, dev 컨테이너 상태, `/api/health`, `/legacy/organization`, `/integrations/payment`, `/integrations/mh`를 먼저 확인
diff --git a/docs/WORK_EXECUTION_FLOW.md b/docs/WORK_EXECUTION_FLOW.md
index 9db488f..6136f17 100644
--- a/docs/WORK_EXECUTION_FLOW.md
+++ b/docs/WORK_EXECUTION_FLOW.md
@@ -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. 이슈 생성 또는 연결
작업은 이슈 없이 하지 않는다.
diff --git a/docs/WORK_RULEBOOK.md b/docs/WORK_RULEBOOK.md
index 6895ad9..7aee334 100644
--- a/docs/WORK_RULEBOOK.md
+++ b/docs/WORK_RULEBOOK.md
@@ -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여야 한다.
세부 규칙:
diff --git a/docs/architecture/8081_SERVING_MAP.md b/docs/architecture/8081_SERVING_MAP.md
new file mode 100644
index 0000000..f9d82f2
--- /dev/null
+++ b/docs/architecture/8081_SERVING_MAP.md
@@ -0,0 +1,100 @@
+# 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`
+- URL: `/integrations/mh`
+ - 현재 실제 서빙 파일: `incoming-files/served/mh.html`
+
+정리 원칙:
+
+- `incoming-files` 아래에서는 `served/`를 실제 서빙 자산용으로 사용한다.
+- `reference/`는 원본 참고 파일, 복구 참고 파일, 비교용 자산만 둔다.
+- 1차 정리에서는 기존 실제 서빙 파일을 `served/`에 복사하고, backend 서빙 경로를 먼저 `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`
+- `사업관리대장/*`
+- 원본 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`의 실제 서빙 파일 위치가 문서와 코드에서 일치한다.
+- 기존 참고 자산을 지우지 않고도 실제 서빙 경로와 참고 경로를 구분할 수 있다.
diff --git a/docs/architecture/DESIGN_SSOT.md b/docs/architecture/DESIGN_SSOT.md
new file mode 100644
index 0000000..03fa7e5
--- /dev/null
+++ b/docs/architecture/DESIGN_SSOT.md
@@ -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
diff --git a/frontend/public/app.js b/frontend/public/app.js
index 6ec6aec..88e736a 100644
--- a/frontend/public/app.js
+++ b/frontend/public/app.js
@@ -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") {
diff --git a/frontend/public/design-patterns.css b/frontend/public/design-patterns.css
new file mode 100644
index 0000000..f457752
--- /dev/null
+++ b/frontend/public/design-patterns.css
@@ -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;
+ }
+}
diff --git a/frontend/public/design-tokens.css b/frontend/public/design-tokens.css
new file mode 100644
index 0000000..be5b46d
--- /dev/null
+++ b/frontend/public/design-tokens.css
@@ -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;
+}
diff --git a/frontend/public/index.html b/frontend/public/index.html
index 0f0d244..9e2b7b5 100644
--- a/frontend/public/index.html
+++ b/frontend/public/index.html
@@ -12,8 +12,13 @@
+
+
+
+
+
@@ -91,18 +96,26 @@
+
@@ -213,6 +226,6 @@
-
+