Compare commits
11 Commits
total
...
c0564ee326
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0564ee326 | ||
|
|
f8ea345882 | ||
|
|
8125193378 | ||
|
|
a4480c3435 | ||
|
|
19c8c6ade1 | ||
|
|
c3afc0c772 | ||
|
|
03e90d18a3 | ||
|
|
57d9f630bc | ||
|
|
1e82572e15 | ||
|
|
e58e584a15 | ||
|
|
fb5b0f00c2 |
55
CONTRIBUTING.md
Normal file
55
CONTRIBUTING.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# Contributing
|
||||||
|
|
||||||
|
## 기본 규칙
|
||||||
|
|
||||||
|
- `main`은 팀 기준 브랜치로 사용합니다.
|
||||||
|
- 기능 개발과 버그 수정은 각자 작업 브랜치에서 진행합니다.
|
||||||
|
- 직접 `8080` 기준 파일을 수정하지 않습니다.
|
||||||
|
- 검증은 먼저 `8081` 개발 환경에서 수행합니다.
|
||||||
|
- 커밋은 한 기능 또는 한 버그 단위로 작게 나눕니다.
|
||||||
|
- 작업 시작 전 [docs/TEAM_GUIDE.md](docs/TEAM_GUIDE.md)를 먼저 읽습니다.
|
||||||
|
|
||||||
|
## 권장 브랜치 이름
|
||||||
|
|
||||||
|
- `feature/<name>-<topic>`
|
||||||
|
- `fix/<name>-<topic>`
|
||||||
|
- `chore/<name>-<topic>`
|
||||||
|
|
||||||
|
예:
|
||||||
|
|
||||||
|
- `fix/alex-organization-save`
|
||||||
|
- `feature/minsu-ledger-filter`
|
||||||
|
|
||||||
|
## 작업 순서
|
||||||
|
|
||||||
|
1. `main` 최신 상태를 받습니다.
|
||||||
|
2. 작업 브랜치를 만듭니다.
|
||||||
|
3. 필요한 경우 `./scripts/prepare_dev_worktree.sh`로 격리된 개발 워크스페이스를 준비합니다.
|
||||||
|
4. `8081`에서 수정과 검증을 진행합니다.
|
||||||
|
5. 관련 publish 스크립트가 있는 화면은 publish 후 실제 런타임 파일까지 확인합니다.
|
||||||
|
6. `docs/REGRESSION_CHECKLIST.md` 기준으로 필요한 시나리오를 점검합니다.
|
||||||
|
7. 커밋 후 PR을 생성합니다.
|
||||||
|
|
||||||
|
## PR 규칙
|
||||||
|
|
||||||
|
- PR 하나에는 한 주제만 담습니다.
|
||||||
|
- PR 본문에 아래 내용을 포함합니다.
|
||||||
|
- 작업 목적
|
||||||
|
- 변경 범위
|
||||||
|
- 검증 방법
|
||||||
|
- DB 영향 여부
|
||||||
|
- 공용 구조 파일 수정 시 영향 화면을 명시합니다.
|
||||||
|
|
||||||
|
## 파일 수정 기준
|
||||||
|
|
||||||
|
- 탭 화면 수정은 먼저 `frontend/apps/*`를 봅니다.
|
||||||
|
- 조직현황은 `frontend/apps/organization`와 `legacy/static/*` 구조를 함께 확인합니다.
|
||||||
|
- integration 화면 런타임은 `incoming-files/served/*`지만, 직접 수정 원본은 `frontend/apps/*`입니다.
|
||||||
|
- 원본 참고 파일은 `incoming-files/reference/*`에만 둡니다.
|
||||||
|
|
||||||
|
## 문서 기준
|
||||||
|
|
||||||
|
- 저장소 진입: [README.md](README.md)
|
||||||
|
- 작업 시작 기준: [docs/TEAM_GUIDE.md](docs/TEAM_GUIDE.md)
|
||||||
|
- 개발/운영 DB 원칙: [docs/DEV_PROD_DB_PROTOCOL.md](docs/DEV_PROD_DB_PROTOCOL.md)
|
||||||
|
- 실제 서빙 책임 맵: [docs/architecture/8081_SERVING_MAP.md](docs/architecture/8081_SERVING_MAP.md)
|
||||||
@@ -8,8 +8,8 @@
|
|||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Pretendard:wght@400;600;700;900&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=Pretendard:wght@400;600;700;900&display=swap" rel="stylesheet" />
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
<link rel="stylesheet" href="/legacy/static/common.css?v=20260331-01" />
|
<link rel="stylesheet" href="/legacy/static/common.css?v=20260402-02" />
|
||||||
<link rel="stylesheet" href="/legacy/static/organization.css?v=20260331-01" />
|
<link rel="stylesheet" href="/legacy/static/organization.css?v=20260402-02" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<input type="file" id="upload-excel" class="hidden" accept=".xlsx, .csv" />
|
<input type="file" id="upload-excel" class="hidden" accept=".xlsx, .csv" />
|
||||||
@@ -60,6 +60,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/legacy/static/organization.js?v=20260331-01"></script>
|
<script src="/legacy/static/organization.js?v=20260402-02"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
59
README.md
Normal file
59
README.md
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# MH Dashboard Organization
|
||||||
|
|
||||||
|
조직현황, 자리배치도, 프로젝트별 분석, 팀/개인별 분석을 하나의 대시보드로 제공하는 사내 웹 애플리케이션입니다.
|
||||||
|
|
||||||
|
## 구성
|
||||||
|
|
||||||
|
- `frontend/`
|
||||||
|
- 허브 화면과 공통 스타일
|
||||||
|
- `frontend/apps/`
|
||||||
|
- 화면별 source-of-truth 앱 소스
|
||||||
|
- `legacy/static/`
|
||||||
|
- 조직현황 레거시 런타임 자산
|
||||||
|
- `incoming-files/served/`
|
||||||
|
- integration 화면의 실제 런타임 서빙 자산
|
||||||
|
- `incoming-files/reference/`
|
||||||
|
- 원본 참고 자산
|
||||||
|
- `backend/app/`
|
||||||
|
- FastAPI 백엔드
|
||||||
|
- `scripts/`
|
||||||
|
- 실행, publish, 검증, 동기화 스크립트
|
||||||
|
|
||||||
|
## 핵심 원칙
|
||||||
|
|
||||||
|
- `frontend/apps/*`가 탭별 수정 원본입니다.
|
||||||
|
- `incoming-files/served/*`와 `legacy/static/*`는 런타임 자산입니다.
|
||||||
|
- 조직현황/멤버/자리배치 관련 검증은 `8081` 개발 환경에서 먼저 수행합니다.
|
||||||
|
- `8080`은 기준 데이터와 공개 환경, `8081`은 검증 환경으로 다룹니다.
|
||||||
|
|
||||||
|
## 시작 문서
|
||||||
|
|
||||||
|
- 첫 문서: [docs/TEAM_GUIDE.md](docs/TEAM_GUIDE.md)
|
||||||
|
- 협업 규칙: [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||||
|
- 개발/운영 DB 원칙: [docs/DEV_PROD_DB_PROTOCOL.md](docs/DEV_PROD_DB_PROTOCOL.md)
|
||||||
|
- 실제 서빙 책임 맵: [docs/architecture/8081_SERVING_MAP.md](docs/architecture/8081_SERVING_MAP.md)
|
||||||
|
- 디자인 기준: [docs/architecture/DESIGN_SSOT.md](docs/architecture/DESIGN_SSOT.md)
|
||||||
|
|
||||||
|
## 빠른 실행
|
||||||
|
|
||||||
|
기본 공개 환경:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
격리된 `8081` 개발 환경:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/prepare_dev_worktree.sh
|
||||||
|
cd .dev-worktree-8081
|
||||||
|
docker compose -p mh-dashboard-organization-dev --env-file .env -f docker-compose.8081.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
## publish 스크립트
|
||||||
|
|
||||||
|
- 조직현황: `./scripts/publish_organization_app.sh`
|
||||||
|
- 프로젝트별 분석: `./scripts/publish_payment_app.sh`
|
||||||
|
- 팀/개인별 분석: `./scripts/publish_team_app.sh`
|
||||||
|
- 사업관리대장: `./scripts/publish_ledger_app.sh`
|
||||||
|
- DB 상태: `./scripts/publish_db_status_app.sh`
|
||||||
497
backend/app/admin_db_status.py
Normal file
497
backend/app/admin_db_status.py
Normal file
@@ -0,0 +1,497 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from .db import get_conn
|
||||||
|
|
||||||
|
|
||||||
|
DB_STATUS_TABLES = [
|
||||||
|
{
|
||||||
|
"table_ref": "public.members",
|
||||||
|
"label": "구성원 마스터",
|
||||||
|
"domain": "organization",
|
||||||
|
"timestamp_column": "updated_at",
|
||||||
|
"related_views": ["조직 현황", "자리배치도"],
|
||||||
|
"description": "조직/구성원 화면의 기준이 되는 현재 인원 마스터",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table_ref": "public.member_versions",
|
||||||
|
"label": "구성원 이력",
|
||||||
|
"domain": "history",
|
||||||
|
"timestamp_column": "created_at",
|
||||||
|
"related_views": ["조직 현황", "이력 비교"],
|
||||||
|
"description": "as-of 조회와 변경 이력을 위한 시점 버전",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table_ref": "public.seat_maps",
|
||||||
|
"label": "자리배치도 도면",
|
||||||
|
"domain": "seatmap",
|
||||||
|
"timestamp_column": "updated_at",
|
||||||
|
"related_views": ["자리배치도"],
|
||||||
|
"description": "오피스별 도면 메타데이터와 활성 상태",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table_ref": "public.seat_positions",
|
||||||
|
"label": "현재 좌석 배치",
|
||||||
|
"domain": "seatmap",
|
||||||
|
"timestamp_column": "updated_at",
|
||||||
|
"related_views": ["자리배치도"],
|
||||||
|
"description": "현재 인원의 실제 배치 좌표/슬롯 연결",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table_ref": "public.seat_assignment_versions",
|
||||||
|
"label": "좌석 배치 이력",
|
||||||
|
"domain": "history",
|
||||||
|
"timestamp_column": "created_at",
|
||||||
|
"related_views": ["자리배치도", "이력 비교"],
|
||||||
|
"description": "자리 이동 이력과 시점 조회용 배치 버전",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table_ref": "public.integration_import_batches",
|
||||||
|
"label": "원본 업로드 배치",
|
||||||
|
"domain": "integration",
|
||||||
|
"timestamp_column": "imported_at",
|
||||||
|
"related_views": ["프로젝트별 분석", "팀/개인별 분석", "조직 현황"],
|
||||||
|
"description": "원본 파일 적재 단위와 최근 import 기록",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table_ref": "public.integration_projects",
|
||||||
|
"label": "통합 프로젝트 표준화",
|
||||||
|
"domain": "integration",
|
||||||
|
"timestamp_column": "updated_at",
|
||||||
|
"related_views": ["프로젝트별 분석", "팀/개인별 분석"],
|
||||||
|
"description": "프로젝트 코드/이름/카테고리 정규화 결과",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table_ref": "public.integration_work_logs",
|
||||||
|
"label": "근무 로그 표준화",
|
||||||
|
"domain": "integration",
|
||||||
|
"timestamp_column": "updated_at",
|
||||||
|
"related_views": ["팀/개인별 분석"],
|
||||||
|
"description": "MH workbook 기준 일자별 근무 로그 본체",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table_ref": "public.integration_work_log_segments",
|
||||||
|
"label": "근무 로그 세그먼트",
|
||||||
|
"domain": "integration",
|
||||||
|
"timestamp_column": "created_at",
|
||||||
|
"related_views": ["팀/개인별 분석"],
|
||||||
|
"description": "근무 로그를 프로젝트/활동 기준으로 분해한 상세 세그먼트",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table_ref": "public.integration_vouchers",
|
||||||
|
"label": "전표 표준화",
|
||||||
|
"domain": "integration",
|
||||||
|
"timestamp_column": "created_at",
|
||||||
|
"related_views": ["프로젝트별 분석"],
|
||||||
|
"description": "payment CSV 기준 프로젝트별 수입/지출 전표",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table_ref": "public.integration_binary_sources",
|
||||||
|
"label": "바이너리 원본 보관",
|
||||||
|
"domain": "integration",
|
||||||
|
"timestamp_column": "imported_at",
|
||||||
|
"related_views": ["사업관리대장"],
|
||||||
|
"description": "엑셀/바이너리 원본을 DB에 보관하는 저장소",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table_ref": "auth.users",
|
||||||
|
"label": "인증 사용자",
|
||||||
|
"domain": "auth",
|
||||||
|
"timestamp_column": "updated_at",
|
||||||
|
"related_views": ["로그인", "권한"],
|
||||||
|
"description": "로그인 계정, role, 활성 상태",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table_ref": "auth.sessions",
|
||||||
|
"label": "인증 세션",
|
||||||
|
"domain": "auth",
|
||||||
|
"timestamp_column": "created_at",
|
||||||
|
"related_views": ["로그인", "권한"],
|
||||||
|
"description": "현재/과거 로그인 세션과 만료 상태",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table_ref": "auth.login_audit_logs",
|
||||||
|
"label": "로그인 감사 로그",
|
||||||
|
"domain": "auth",
|
||||||
|
"timestamp_column": "created_at",
|
||||||
|
"related_views": ["로그인", "권한"],
|
||||||
|
"description": "로그인 성공/실패 기록",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
DB_STATUS_TABLE_META = {str(item["table_ref"]): item for item in DB_STATUS_TABLES}
|
||||||
|
DB_STATUS_TABLE_GROUPS = {
|
||||||
|
"public.members": "유지",
|
||||||
|
"public.member_versions": "유지",
|
||||||
|
"public.seat_maps": "유지",
|
||||||
|
"public.seat_positions": "유지",
|
||||||
|
"public.seat_slots": "유지",
|
||||||
|
"public.seat_assignment_versions": "유지",
|
||||||
|
"public.history_revisions": "유지",
|
||||||
|
"public.integration_import_batches": "유지",
|
||||||
|
"public.integration_projects": "유지",
|
||||||
|
"public.integration_work_logs": "유지",
|
||||||
|
"public.integration_work_log_segments": "유지",
|
||||||
|
"public.integration_vouchers": "유지",
|
||||||
|
"public.integration_binary_sources": "유지",
|
||||||
|
"auth.users": "유지",
|
||||||
|
"auth.sessions": "유지",
|
||||||
|
"auth.login_audit_logs": "유지",
|
||||||
|
"public.member_overrides": "주의",
|
||||||
|
"public.member_retirements": "주의",
|
||||||
|
"public.member_aliases": "주의",
|
||||||
|
"public.integration_project_aliases": "주의",
|
||||||
|
"public.integration_project_category_mappings": "주의",
|
||||||
|
"public.integration_project_pm_assignments": "주의",
|
||||||
|
"public.integration_raw_organization_rows": "원본·추적",
|
||||||
|
"public.integration_raw_mh_rows": "원본·추적",
|
||||||
|
"public.integration_raw_mh_pm_rows": "원본·추적",
|
||||||
|
"public.integration_raw_payment_rows": "원본·추적",
|
||||||
|
}
|
||||||
|
|
||||||
|
DB_STATUS_PRODUCT_GROUPS = {
|
||||||
|
"탭 데이터": [
|
||||||
|
"public.members",
|
||||||
|
"public.seat_maps",
|
||||||
|
"public.seat_slots",
|
||||||
|
"public.seat_positions",
|
||||||
|
"public.integration_projects",
|
||||||
|
"public.integration_work_logs",
|
||||||
|
"public.integration_work_log_segments",
|
||||||
|
"public.integration_vouchers",
|
||||||
|
"public.integration_binary_sources",
|
||||||
|
],
|
||||||
|
"로그인·권한": [
|
||||||
|
"auth.users",
|
||||||
|
"auth.sessions",
|
||||||
|
"auth.login_audit_logs",
|
||||||
|
],
|
||||||
|
"히스토리": [
|
||||||
|
"public.history_revisions",
|
||||||
|
"public.member_versions",
|
||||||
|
"public.seat_assignment_versions",
|
||||||
|
],
|
||||||
|
"로우데이터·적재": [
|
||||||
|
"public.integration_import_batches",
|
||||||
|
"public.integration_raw_organization_rows",
|
||||||
|
"public.integration_raw_mh_rows",
|
||||||
|
"public.integration_raw_mh_pm_rows",
|
||||||
|
"public.integration_raw_payment_rows",
|
||||||
|
],
|
||||||
|
"보정·보조": [
|
||||||
|
"public.member_overrides",
|
||||||
|
"public.member_retirements",
|
||||||
|
"public.member_aliases",
|
||||||
|
"public.integration_project_aliases",
|
||||||
|
"public.integration_project_category_mappings",
|
||||||
|
"public.integration_project_pm_assignments",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
DB_STATUS_SCREEN_MAP = [
|
||||||
|
{
|
||||||
|
"screen": "조직 현황",
|
||||||
|
"tables": [
|
||||||
|
"public.members",
|
||||||
|
"public.member_overrides",
|
||||||
|
"public.member_retirements",
|
||||||
|
"public.member_aliases",
|
||||||
|
"public.member_versions",
|
||||||
|
"public.history_revisions",
|
||||||
|
],
|
||||||
|
"write_flow": "원본 조직 데이터 import 후 members 계열을 갱신하고, 수정/이력 기능은 revision 기반으로 누적합니다.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"screen": "자리배치도",
|
||||||
|
"tables": [
|
||||||
|
"public.seat_maps",
|
||||||
|
"public.seat_slots",
|
||||||
|
"public.seat_positions",
|
||||||
|
"public.seat_assignment_versions",
|
||||||
|
"public.history_revisions",
|
||||||
|
"public.members",
|
||||||
|
],
|
||||||
|
"write_flow": "고정 오피스 도면과 현재 좌석 배치를 읽고, 저장 시 현재 배치와 배치 이력을 함께 기록합니다.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"screen": "프로젝트별 분석",
|
||||||
|
"tables": [
|
||||||
|
"public.integration_import_batches",
|
||||||
|
"public.integration_projects",
|
||||||
|
"public.integration_vouchers",
|
||||||
|
"public.integration_project_aliases",
|
||||||
|
"public.integration_project_category_mappings",
|
||||||
|
"public.integration_project_pm_assignments",
|
||||||
|
],
|
||||||
|
"write_flow": "payment 원본 import 결과와 프로젝트 보정 테이블을 조합해 프로젝트 집계를 만듭니다.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"screen": "팀/개인별 분석",
|
||||||
|
"tables": [
|
||||||
|
"public.integration_import_batches",
|
||||||
|
"public.integration_projects",
|
||||||
|
"public.integration_work_logs",
|
||||||
|
"public.integration_work_log_segments",
|
||||||
|
"public.integration_raw_mh_rows",
|
||||||
|
"public.integration_raw_mh_pm_rows",
|
||||||
|
],
|
||||||
|
"write_flow": "MH 원본 row를 적재한 뒤 표준화 로그와 세그먼트로 분해해 화면 집계에 사용합니다.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"screen": "사업관리대장",
|
||||||
|
"tables": [
|
||||||
|
"public.integration_binary_sources",
|
||||||
|
],
|
||||||
|
"write_flow": "현재는 기본 바이너리 원본 보관 상태만 DB에 유지하며, 상세 계산 규칙은 별도 기준 정렬이 필요합니다.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"screen": "로그인 / 권한",
|
||||||
|
"tables": [
|
||||||
|
"auth.users",
|
||||||
|
"auth.sessions",
|
||||||
|
"auth.login_audit_logs",
|
||||||
|
],
|
||||||
|
"write_flow": "사용자 계정, 세션, 로그인 감사 로그를 auth 스키마에서 분리 운영합니다.",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def make_json_safe(value: object) -> object:
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return value.isoformat()
|
||||||
|
if isinstance(value, date):
|
||||||
|
return value.isoformat()
|
||||||
|
if isinstance(value, Decimal):
|
||||||
|
return float(value)
|
||||||
|
if isinstance(value, bytes):
|
||||||
|
return f"<{len(value)} bytes>"
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {str(key): make_json_safe(val) for key, val in value.items()}
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [make_json_safe(item) for item in value]
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_db_status_snapshot() -> dict[str, object]:
|
||||||
|
table_items: list[dict[str, object]] = []
|
||||||
|
with get_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT schemaname, tablename
|
||||||
|
FROM pg_tables
|
||||||
|
WHERE schemaname IN ('public', 'auth')
|
||||||
|
ORDER BY schemaname, tablename
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
all_tables = cur.fetchall()
|
||||||
|
for row in all_tables:
|
||||||
|
schema_name = str(row["schemaname"])
|
||||||
|
table_name = str(row["tablename"])
|
||||||
|
table_ref = f"{schema_name}.{table_name}"
|
||||||
|
spec = DB_STATUS_TABLE_META.get(table_ref, {})
|
||||||
|
cur.execute("SELECT to_regclass(%s) IS NOT NULL AS table_exists", (table_ref,))
|
||||||
|
exists_row = cur.fetchone()
|
||||||
|
exists = bool(exists_row["table_exists"]) if exists_row is not None else False
|
||||||
|
row_count = 0
|
||||||
|
last_event_at = None
|
||||||
|
if exists:
|
||||||
|
timestamp_column = str(spec.get("timestamp_column") or "")
|
||||||
|
query = f"SELECT COUNT(*)::bigint AS row_count"
|
||||||
|
if timestamp_column:
|
||||||
|
query += f", MAX({timestamp_column}) AS last_event_at"
|
||||||
|
else:
|
||||||
|
query += ", NULL::timestamptz AS last_event_at"
|
||||||
|
query += f" FROM {schema_name}.{table_name}"
|
||||||
|
cur.execute(query)
|
||||||
|
metric_row = cur.fetchone() or {}
|
||||||
|
row_count = int(metric_row.get("row_count") or 0)
|
||||||
|
last_event_at = metric_row.get("last_event_at")
|
||||||
|
table_items.append(
|
||||||
|
{
|
||||||
|
"table_ref": table_ref,
|
||||||
|
"schema": schema_name,
|
||||||
|
"table_name": table_name,
|
||||||
|
"label": str(spec.get("label") or table_name),
|
||||||
|
"domain": str(spec.get("domain") or "other"),
|
||||||
|
"description": str(spec.get("description") or "세부 보조/원본/운영 테이블"),
|
||||||
|
"related_views": spec.get("related_views") or [],
|
||||||
|
"group": DB_STATUS_TABLE_GROUPS.get(table_ref, "주의"),
|
||||||
|
"exists": exists,
|
||||||
|
"row_count": row_count,
|
||||||
|
"last_event_at": last_event_at.isoformat() if last_event_at else None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT source_key, source_name, row_count, source_path, imported_at
|
||||||
|
FROM integration_import_batches
|
||||||
|
ORDER BY imported_at DESC, id DESC
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
import_batches = [
|
||||||
|
{
|
||||||
|
"source_key": str(row["source_key"] or ""),
|
||||||
|
"source_name": str(row["source_name"] or ""),
|
||||||
|
"row_count": int(row["row_count"] or 0),
|
||||||
|
"source_path": str(row["source_path"] or ""),
|
||||||
|
"imported_at": row["imported_at"].isoformat() if row.get("imported_at") else None,
|
||||||
|
}
|
||||||
|
for row in cur.fetchall()
|
||||||
|
]
|
||||||
|
|
||||||
|
binary_sources: list[dict[str, object]] = []
|
||||||
|
cur.execute("SELECT to_regclass('public.integration_binary_sources') IS NOT NULL AS table_exists")
|
||||||
|
binary_exists_row = cur.fetchone()
|
||||||
|
binary_exists = bool(binary_exists_row["table_exists"]) if binary_exists_row is not None else False
|
||||||
|
if binary_exists:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT source_key, source_name, filename, mime_type, OCTET_LENGTH(content) AS byte_size,
|
||||||
|
content_sha256, imported_at
|
||||||
|
FROM integration_binary_sources
|
||||||
|
ORDER BY imported_at DESC, id DESC
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
binary_sources = [
|
||||||
|
{
|
||||||
|
"source_key": str(row["source_key"] or ""),
|
||||||
|
"source_name": str(row["source_name"] or ""),
|
||||||
|
"filename": str(row["filename"] or ""),
|
||||||
|
"mime_type": str(row["mime_type"] or ""),
|
||||||
|
"byte_size": int(row["byte_size"] or 0),
|
||||||
|
"content_sha256": str(row["content_sha256"] or ""),
|
||||||
|
"imported_at": row["imported_at"].isoformat() if row.get("imported_at") else None,
|
||||||
|
}
|
||||||
|
for row in cur.fetchall()
|
||||||
|
]
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*)::bigint AS total_members,
|
||||||
|
COUNT(*) FILTER (
|
||||||
|
WHERE COALESCE(BTRIM(work_status), '') <> '퇴직'
|
||||||
|
)::bigint AS active_members
|
||||||
|
FROM members
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
member_row = cur.fetchone() or {}
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*)::bigint AS active_seat_maps
|
||||||
|
FROM seat_maps
|
||||||
|
WHERE is_active = TRUE
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
seat_map_row = cur.fetchone() or {}
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*)::bigint AS fixed_office_maps
|
||||||
|
FROM seat_maps
|
||||||
|
WHERE source_type = 'fixed_html'
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
fixed_office_row = cur.fetchone() or {}
|
||||||
|
|
||||||
|
overview = {
|
||||||
|
"visible_tables": len(DB_STATUS_TABLES),
|
||||||
|
"total_tables": len(table_items),
|
||||||
|
"existing_tables": sum(1 for item in table_items if item["exists"]),
|
||||||
|
"registered_members": int(member_row.get("total_members") or 0),
|
||||||
|
"active_members": int(member_row.get("active_members") or 0),
|
||||||
|
"active_seat_maps": int(seat_map_row.get("active_seat_maps") or 0),
|
||||||
|
"fixed_office_maps": int(fixed_office_row.get("fixed_office_maps") or 0),
|
||||||
|
"import_batches": len(import_batches),
|
||||||
|
"binary_sources": len(binary_sources),
|
||||||
|
}
|
||||||
|
group_summary = {
|
||||||
|
"유지": [item["table_ref"] for item in table_items if item["group"] == "유지"],
|
||||||
|
"주의": [item["table_ref"] for item in table_items if item["group"] == "주의"],
|
||||||
|
"원본·추적": [item["table_ref"] for item in table_items if item["group"] == "원본·추적"],
|
||||||
|
"정리 후보": [item["table_ref"] for item in table_items if item["group"] == "정리 후보"],
|
||||||
|
}
|
||||||
|
product_summary = {
|
||||||
|
group_name: table_refs
|
||||||
|
for group_name, table_refs in DB_STATUS_PRODUCT_GROUPS.items()
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"generated_at": datetime.utcnow().isoformat() + "Z",
|
||||||
|
"overview": overview,
|
||||||
|
"tables": table_items,
|
||||||
|
"import_batches": import_batches,
|
||||||
|
"binary_sources": binary_sources,
|
||||||
|
"group_summary": group_summary,
|
||||||
|
"product_summary": product_summary,
|
||||||
|
"screen_map": DB_STATUS_SCREEN_MAP,
|
||||||
|
"notes": [
|
||||||
|
"members / seat_positions / seat_maps 는 현재 운영 상태를 나타냅니다.",
|
||||||
|
"member_versions / seat_assignment_versions / history_revisions 는 시점 조회와 변경 이력을 위한 테이블입니다.",
|
||||||
|
"integration_raw_* / integration_* 는 원본 적재와 표준화 결과를 분리해서 보관합니다.",
|
||||||
|
"integration_binary_sources 는 사업관리대장 같은 바이너리 원본 보관용입니다.",
|
||||||
|
"DB를 물리적으로 합치기보다, 화면/권한/이력/로우데이터 관점으로 묶어 보는 것이 현재 운영에 더 적합합니다.",
|
||||||
|
"재직 인원은 조직현황과 동일하게 work_status 값이 '퇴직'이 아닌 구성원 기준입니다.",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_db_table_preview(schema_name: str, table_name: str, limit: int = 50) -> dict[str, object]:
|
||||||
|
if schema_name not in {"public", "auth"}:
|
||||||
|
raise HTTPException(status_code=404, detail="Unknown schema.")
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT tablename
|
||||||
|
FROM pg_tables
|
||||||
|
WHERE schemaname = %s
|
||||||
|
AND tablename = %s
|
||||||
|
""",
|
||||||
|
(schema_name, table_name),
|
||||||
|
)
|
||||||
|
exists_row = cur.fetchone()
|
||||||
|
if exists_row is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Unknown table.")
|
||||||
|
|
||||||
|
table_ref = f"{schema_name}.{table_name}"
|
||||||
|
spec = DB_STATUS_TABLE_META.get(table_ref, {})
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT column_name, data_type
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = %s
|
||||||
|
AND table_name = %s
|
||||||
|
ORDER BY ordinal_position
|
||||||
|
""",
|
||||||
|
(schema_name, table_name),
|
||||||
|
)
|
||||||
|
columns = [{"name": str(row["column_name"]), "type": str(row["data_type"])} for row in cur.fetchall()]
|
||||||
|
|
||||||
|
cur.execute(f"SELECT COUNT(*)::bigint AS row_count FROM {schema_name}.{table_name}")
|
||||||
|
row_count = int((cur.fetchone() or {}).get("row_count") or 0)
|
||||||
|
|
||||||
|
safe_limit = max(1, min(int(limit), 50))
|
||||||
|
cur.execute(f"SELECT * FROM {schema_name}.{table_name} LIMIT {safe_limit}")
|
||||||
|
rows = [make_json_safe(dict(row)) for row in cur.fetchall()]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"table_ref": table_ref,
|
||||||
|
"schema": schema_name,
|
||||||
|
"table_name": table_name,
|
||||||
|
"label": str(spec.get("label") or table_name),
|
||||||
|
"domain": str(spec.get("domain") or "other"),
|
||||||
|
"description": str(spec.get("description") or "세부 보조/원본/운영 테이블"),
|
||||||
|
"related_views": spec.get("related_views") or [],
|
||||||
|
"row_count": row_count,
|
||||||
|
"limit": safe_limit,
|
||||||
|
"columns": columns,
|
||||||
|
"rows": rows,
|
||||||
|
}
|
||||||
172
backend/app/auth_routes.py
Normal file
172
backend/app/auth_routes.py
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
import uuid
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
from fastapi import FastAPI, Form, Header, HTTPException, Request
|
||||||
|
|
||||||
|
|
||||||
|
def register_auth_routes(
|
||||||
|
app: FastAPI,
|
||||||
|
*,
|
||||||
|
get_conn,
|
||||||
|
verify_password: Callable[[str, str], bool],
|
||||||
|
build_auth_session_payload: Callable[[dict[str, object], uuid.UUID, datetime], dict[str, object]],
|
||||||
|
extract_bearer_token: Callable[[str | None], str | None],
|
||||||
|
mock_login_enabled: bool,
|
||||||
|
auth_session_hours: int,
|
||||||
|
) -> None:
|
||||||
|
@app.post("/api/auth/login")
|
||||||
|
def auth_login(
|
||||||
|
request: Request,
|
||||||
|
username: str = Form(...),
|
||||||
|
password: str = Form(...),
|
||||||
|
) -> dict[str, object]:
|
||||||
|
normalized_username = username.strip().lower()
|
||||||
|
if not normalized_username or not password.strip():
|
||||||
|
raise HTTPException(status_code=400, detail="사번과 비밀번호를 입력해주세요.")
|
||||||
|
|
||||||
|
ip_address = request.client.host if request.client else None
|
||||||
|
user_agent = request.headers.get("user-agent", "")
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT u.id, u.username, u.password_hash, u.display_name, u.role, u.member_id, u.is_active,
|
||||||
|
m.rank
|
||||||
|
FROM auth.users u
|
||||||
|
LEFT JOIN members m ON m.id = u.member_id
|
||||||
|
WHERE LOWER(u.username) = %s
|
||||||
|
""",
|
||||||
|
(normalized_username,),
|
||||||
|
)
|
||||||
|
user = cur.fetchone()
|
||||||
|
|
||||||
|
if user is None:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO auth.login_audit_logs (username, success, failure_reason, ip_address, user_agent)
|
||||||
|
VALUES (%s, FALSE, %s, %s, %s)
|
||||||
|
""",
|
||||||
|
(normalized_username, "unknown_user", ip_address, user_agent),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
raise HTTPException(status_code=401, detail="사번 또는 비밀번호가 올바르지 않습니다.")
|
||||||
|
|
||||||
|
if not bool(user.get("is_active")):
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO auth.login_audit_logs (username, user_id, success, failure_reason, ip_address, user_agent)
|
||||||
|
VALUES (%s, %s, FALSE, %s, %s, %s)
|
||||||
|
""",
|
||||||
|
(normalized_username, int(user["id"]), "inactive_user", ip_address, user_agent),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
raise HTTPException(status_code=403, detail="비활성화된 계정입니다.")
|
||||||
|
|
||||||
|
if not verify_password(password, str(user.get("password_hash") or "")):
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO auth.login_audit_logs (username, user_id, success, failure_reason, ip_address, user_agent)
|
||||||
|
VALUES (%s, %s, FALSE, %s, %s, %s)
|
||||||
|
""",
|
||||||
|
(normalized_username, int(user["id"]), "invalid_password", ip_address, user_agent),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
raise HTTPException(status_code=401, detail="사번 또는 비밀번호가 올바르지 않습니다.")
|
||||||
|
|
||||||
|
expires_at = datetime.now(timezone.utc) + timedelta(hours=auth_session_hours)
|
||||||
|
session_id = uuid.uuid4()
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO auth.sessions (id, user_id, expires_at, ip_address, user_agent)
|
||||||
|
VALUES (%s, %s, %s, %s, %s)
|
||||||
|
""",
|
||||||
|
(session_id, int(user["id"]), expires_at, ip_address, user_agent),
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE auth.users SET last_login_at = NOW(), updated_at = NOW() WHERE id = %s",
|
||||||
|
(int(user["id"]),),
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO auth.login_audit_logs (username, user_id, success, failure_reason, ip_address, user_agent)
|
||||||
|
VALUES (%s, %s, TRUE, NULL, %s, %s)
|
||||||
|
""",
|
||||||
|
(normalized_username, int(user["id"]), ip_address, user_agent),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return build_auth_session_payload(user, session_id, expires_at)
|
||||||
|
|
||||||
|
@app.post("/api/auth/logout")
|
||||||
|
def auth_logout(authorization: str | None = Header(default=None)) -> dict[str, bool]:
|
||||||
|
token = extract_bearer_token(authorization)
|
||||||
|
if not token:
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE auth.sessions
|
||||||
|
SET revoked_at = NOW()
|
||||||
|
WHERE id = %s
|
||||||
|
AND revoked_at IS NULL
|
||||||
|
""",
|
||||||
|
(token,),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
@app.get("/api/auth/me")
|
||||||
|
def auth_me(authorization: str | None = Header(default=None)) -> dict[str, object]:
|
||||||
|
token = extract_bearer_token(authorization)
|
||||||
|
if not token:
|
||||||
|
raise HTTPException(status_code=401, detail="인증 정보가 없습니다.")
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT s.id AS session_id, s.expires_at, s.revoked_at,
|
||||||
|
u.id, u.username, u.display_name, u.role, u.member_id, u.is_active,
|
||||||
|
m.rank
|
||||||
|
FROM auth.sessions s
|
||||||
|
JOIN auth.users u ON u.id = s.user_id
|
||||||
|
LEFT JOIN members m ON m.id = u.member_id
|
||||||
|
WHERE s.id = %s
|
||||||
|
""",
|
||||||
|
(token,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
|
||||||
|
if row is None or row.get("revoked_at") is not None:
|
||||||
|
raise HTTPException(status_code=401, detail="세션이 유효하지 않습니다.")
|
||||||
|
|
||||||
|
expires_at = row["expires_at"]
|
||||||
|
now_utc = datetime.now(timezone.utc)
|
||||||
|
if expires_at is None or expires_at <= now_utc:
|
||||||
|
raise HTTPException(status_code=401, detail="세션이 만료되었습니다.")
|
||||||
|
|
||||||
|
if not bool(row.get("is_active")):
|
||||||
|
raise HTTPException(status_code=403, detail="비활성화된 계정입니다.")
|
||||||
|
|
||||||
|
return build_auth_session_payload(row, uuid.UUID(str(row["session_id"])), expires_at)
|
||||||
|
|
||||||
|
@app.post("/api/mock-login")
|
||||||
|
def mock_login(username: str = Form(...), password: str = Form(...)) -> dict[str, object]:
|
||||||
|
if not mock_login_enabled:
|
||||||
|
raise HTTPException(status_code=403, detail="Mock login is disabled.")
|
||||||
|
if not username.strip() or not password.strip():
|
||||||
|
raise HTTPException(status_code=400, detail="Username and password are required.")
|
||||||
|
return {
|
||||||
|
"user": {
|
||||||
|
"username": username.strip(),
|
||||||
|
"display_name": username.strip(),
|
||||||
|
"role": "admin",
|
||||||
|
},
|
||||||
|
"session_expires_at": datetime.utcnow().isoformat() + "Z",
|
||||||
|
}
|
||||||
@@ -281,6 +281,19 @@ CREATE TABLE IF NOT EXISTS integration_vouchers (
|
|||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS integration_binary_sources (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
source_key TEXT NOT NULL UNIQUE,
|
||||||
|
source_name TEXT NOT NULL,
|
||||||
|
filename TEXT NOT NULL DEFAULT '',
|
||||||
|
mime_type TEXT NOT NULL DEFAULT 'application/octet-stream',
|
||||||
|
content BYTEA NOT NULL,
|
||||||
|
content_sha256 TEXT NOT NULL DEFAULT '',
|
||||||
|
meta_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS history_revisions (
|
CREATE TABLE IF NOT EXISTS history_revisions (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
scope TEXT NOT NULL DEFAULT 'organization',
|
scope TEXT NOT NULL DEFAULT 'organization',
|
||||||
@@ -329,18 +342,6 @@ CREATE TABLE IF NOT EXISTS seat_assignment_versions (
|
|||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS entity_change_events (
|
|
||||||
id BIGSERIAL PRIMARY KEY,
|
|
||||||
entity_type TEXT NOT NULL,
|
|
||||||
entity_id BIGINT NOT NULL,
|
|
||||||
action_type TEXT NOT NULL,
|
|
||||||
revision_no BIGINT NOT NULL,
|
|
||||||
changed_by_user_id BIGINT,
|
|
||||||
changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
change_reason TEXT NOT NULL DEFAULT '',
|
|
||||||
patch_json JSONB NOT NULL DEFAULT '{}'::jsonb
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE SCHEMA IF NOT EXISTS auth;
|
CREATE SCHEMA IF NOT EXISTS auth;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS auth.users (
|
CREATE TABLE IF NOT EXISTS auth.users (
|
||||||
@@ -534,6 +535,9 @@ ON integration_vouchers (project_code, project_name);
|
|||||||
CREATE UNIQUE INDEX IF NOT EXISTS integration_project_category_mappings_key_idx
|
CREATE UNIQUE INDEX IF NOT EXISTS integration_project_category_mappings_key_idx
|
||||||
ON integration_project_category_mappings (source_key, normalized_project_key);
|
ON integration_project_category_mappings (source_key, normalized_project_key);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS integration_binary_sources_source_key_idx
|
||||||
|
ON integration_binary_sources (source_key);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS member_versions_member_time_idx
|
CREATE INDEX IF NOT EXISTS member_versions_member_time_idx
|
||||||
ON member_versions (member_id, valid_from, valid_to);
|
ON member_versions (member_id, valid_from, valid_to);
|
||||||
|
|
||||||
@@ -543,9 +547,6 @@ ON seat_assignment_versions (member_id, valid_from, valid_to);
|
|||||||
CREATE INDEX IF NOT EXISTS history_revisions_scope_created_idx
|
CREATE INDEX IF NOT EXISTS history_revisions_scope_created_idx
|
||||||
ON history_revisions (scope, created_at DESC);
|
ON history_revisions (scope, created_at DESC);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS entity_change_events_entity_idx
|
|
||||||
ON entity_change_events (entity_type, entity_id, changed_at DESC);
|
|
||||||
|
|
||||||
DO $$
|
DO $$
|
||||||
BEGIN
|
BEGIN
|
||||||
IF NOT EXISTS (
|
IF NOT EXISTS (
|
||||||
@@ -611,6 +612,9 @@ ALTER TABLE auth.login_audit_logs ADD COLUMN IF NOT EXISTS user_id BIGINT NULL R
|
|||||||
ALTER TABLE auth.login_audit_logs ADD COLUMN IF NOT EXISTS failure_reason TEXT;
|
ALTER TABLE auth.login_audit_logs ADD COLUMN IF NOT EXISTS failure_reason TEXT;
|
||||||
ALTER TABLE auth.login_audit_logs ADD COLUMN IF NOT EXISTS ip_address INET;
|
ALTER TABLE auth.login_audit_logs ADD COLUMN IF NOT EXISTS ip_address INET;
|
||||||
ALTER TABLE auth.login_audit_logs ADD COLUMN IF NOT EXISTS user_agent TEXT;
|
ALTER TABLE auth.login_audit_logs ADD COLUMN IF NOT EXISTS user_agent TEXT;
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS entity_change_events_entity_idx;
|
||||||
|
DROP TABLE IF EXISTS entity_change_events;
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
50
backend/app/integration_routes.py
Normal file
50
backend/app/integration_routes.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
|
||||||
|
|
||||||
|
def register_integration_routes(
|
||||||
|
app: FastAPI,
|
||||||
|
*,
|
||||||
|
import_integration_sources,
|
||||||
|
fetch_integration_summary,
|
||||||
|
fetch_project_metrics,
|
||||||
|
fetch_member_metrics,
|
||||||
|
fetch_team_metrics,
|
||||||
|
fetch_project_breakdowns,
|
||||||
|
fetch_payment_source_rows,
|
||||||
|
fetch_mh_source_rows,
|
||||||
|
) -> None:
|
||||||
|
@app.post("/api/integration/import")
|
||||||
|
def import_integration_data() -> dict[str, object]:
|
||||||
|
return import_integration_sources()
|
||||||
|
|
||||||
|
@app.get("/api/integration/summary")
|
||||||
|
def integration_summary() -> dict[str, object]:
|
||||||
|
return fetch_integration_summary()
|
||||||
|
|
||||||
|
@app.get("/api/integration/projects")
|
||||||
|
def integration_projects(limit: int = 500, start_date: str | None = None, end_date: str | None = None) -> dict[str, list[dict[str, object]]]:
|
||||||
|
safe_limit = max(1, min(limit, 5000))
|
||||||
|
return {"items": fetch_project_metrics(safe_limit, start_date=start_date, end_date=end_date)}
|
||||||
|
|
||||||
|
@app.get("/api/integration/members")
|
||||||
|
def integration_members(limit: int = 500, start_date: str | None = None, end_date: str | None = None) -> dict[str, list[dict[str, object]]]:
|
||||||
|
safe_limit = max(1, min(limit, 5000))
|
||||||
|
return {"items": fetch_member_metrics(safe_limit, start_date=start_date, end_date=end_date)}
|
||||||
|
|
||||||
|
@app.get("/api/integration/teams")
|
||||||
|
def integration_teams(start_date: str | None = None, end_date: str | None = None) -> dict[str, list[dict[str, object]]]:
|
||||||
|
return {"items": fetch_team_metrics(start_date=start_date, end_date=end_date)}
|
||||||
|
|
||||||
|
@app.get("/api/integration/project-breakdowns")
|
||||||
|
def integration_project_breakdowns(start_date: str | None = None, end_date: str | None = None) -> dict[str, list[dict[str, object]]]:
|
||||||
|
return fetch_project_breakdowns(start_date=start_date, end_date=end_date)
|
||||||
|
|
||||||
|
@app.get("/api/integration/payment-source")
|
||||||
|
def integration_payment_source() -> dict[str, object]:
|
||||||
|
return fetch_payment_source_rows()
|
||||||
|
|
||||||
|
@app.get("/api/integration/mh-source")
|
||||||
|
def integration_mh_source() -> dict[str, object]:
|
||||||
|
return fetch_mh_source_rows()
|
||||||
111
backend/app/ledger_runtime.py
Normal file
111
backend/app/ledger_runtime.py
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from fastapi.responses import FileResponse, Response
|
||||||
|
|
||||||
|
|
||||||
|
BUSINESS_LEDGER_DEFAULT_SOURCE_KEY = "business_ledger_default"
|
||||||
|
|
||||||
|
|
||||||
|
def sync_default_business_ledger_source(cur, incoming_files_dir: Path, served_dir: Path) -> None:
|
||||||
|
cur.execute("SELECT to_regclass('public.integration_binary_sources') IS NOT NULL AS table_exists")
|
||||||
|
row = cur.fetchone()
|
||||||
|
table_exists = bool(row["table_exists"]) if row is not None else False
|
||||||
|
if not table_exists:
|
||||||
|
return
|
||||||
|
|
||||||
|
business_dashboard_dir = incoming_files_dir / "사업관리대장"
|
||||||
|
business_ledger_served_dir = served_dir / "ledger"
|
||||||
|
candidates = [
|
||||||
|
business_ledger_served_dir / "사업관리대장-1.xlsx",
|
||||||
|
business_dashboard_dir / "사업관리대장-1.xlsx",
|
||||||
|
business_dashboard_dir / "사업관리 대장-1.xlsx",
|
||||||
|
business_dashboard_dir / "사업관리대장.xlsx",
|
||||||
|
business_dashboard_dir / "사업관리 대장.xlsx",
|
||||||
|
]
|
||||||
|
source_path = next((candidate for candidate in candidates if candidate.exists()), None)
|
||||||
|
if source_path is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
content = source_path.read_bytes()
|
||||||
|
content_sha256 = hashlib.sha256(content).hexdigest()
|
||||||
|
meta_json = {
|
||||||
|
"byte_size": len(content),
|
||||||
|
"source_path": str(source_path),
|
||||||
|
"synced_from": "startup",
|
||||||
|
}
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO integration_binary_sources (
|
||||||
|
source_key, source_name, filename, mime_type, content, content_sha256, meta_json, imported_at
|
||||||
|
)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s::jsonb, NOW())
|
||||||
|
ON CONFLICT (source_key) DO UPDATE
|
||||||
|
SET source_name = EXCLUDED.source_name,
|
||||||
|
filename = EXCLUDED.filename,
|
||||||
|
mime_type = EXCLUDED.mime_type,
|
||||||
|
content = EXCLUDED.content,
|
||||||
|
content_sha256 = EXCLUDED.content_sha256,
|
||||||
|
meta_json = EXCLUDED.meta_json,
|
||||||
|
imported_at = NOW()
|
||||||
|
WHERE integration_binary_sources.content_sha256 IS DISTINCT FROM EXCLUDED.content_sha256
|
||||||
|
OR integration_binary_sources.filename IS DISTINCT FROM EXCLUDED.filename
|
||||||
|
OR integration_binary_sources.mime_type IS DISTINCT FROM EXCLUDED.mime_type
|
||||||
|
OR integration_binary_sources.meta_json IS DISTINCT FROM EXCLUDED.meta_json
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
BUSINESS_LEDGER_DEFAULT_SOURCE_KEY,
|
||||||
|
"사업관리대장 기본 원본",
|
||||||
|
source_path.name,
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
content,
|
||||||
|
content_sha256,
|
||||||
|
json.dumps(meta_json, ensure_ascii=False),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_business_ledger_default_response(cur) -> Response:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT filename, mime_type, content
|
||||||
|
FROM integration_binary_sources
|
||||||
|
WHERE source_key = %s
|
||||||
|
ORDER BY imported_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(BUSINESS_LEDGER_DEFAULT_SOURCE_KEY,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="Business ledger default source not found.")
|
||||||
|
|
||||||
|
filename = str(row["filename"] or "사업관리대장-1.xlsx")
|
||||||
|
headers = {
|
||||||
|
"Content-Disposition": 'inline; filename="business-ledger-default.xlsx"',
|
||||||
|
"X-Source-Filename": "business-ledger-default.xlsx",
|
||||||
|
"X-Original-Filename": quote(filename),
|
||||||
|
"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
|
||||||
|
"Pragma": "no-cache",
|
||||||
|
}
|
||||||
|
return Response(
|
||||||
|
content=bytes(row["content"]),
|
||||||
|
media_type=str(
|
||||||
|
row["mime_type"] or "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||||
|
),
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_ledger_index_response(ledger_index_path: Path) -> FileResponse:
|
||||||
|
if not ledger_index_path.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="Business ledger integration file not found.")
|
||||||
|
response = FileResponse(ledger_index_path)
|
||||||
|
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
|
||||||
|
response.headers["Pragma"] = "no-cache"
|
||||||
|
return response
|
||||||
1398
backend/app/main.py
1398
backend/app/main.py
File diff suppressed because it is too large
Load Diff
147
backend/app/member_routes.py
Normal file
147
backend/app/member_routes.py
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
from fastapi import Body, FastAPI, File, HTTPException, UploadFile
|
||||||
|
|
||||||
|
|
||||||
|
def register_member_routes(
|
||||||
|
app: FastAPI,
|
||||||
|
*,
|
||||||
|
get_conn,
|
||||||
|
member_payload_cls,
|
||||||
|
member_bulk_payload_cls,
|
||||||
|
parse_as_of: Callable[[str | None], datetime | None],
|
||||||
|
fetch_members: Callable[[], list[dict[str, object]]],
|
||||||
|
fetch_members_as_of: Callable[[object, datetime], list[dict[str, object]]],
|
||||||
|
build_member_compare_items: Callable[[list[dict[str, object]], list[dict[str, object]]], list[dict[str, object]]],
|
||||||
|
serialize_member_payload: Callable[[object, int], tuple[object, ...]],
|
||||||
|
sync_auth_users_from_members: Callable[[object], None],
|
||||||
|
create_history_revision: Callable[[object, str, str], int],
|
||||||
|
fetch_history_revision_created_at: Callable[[object, int], datetime],
|
||||||
|
sync_member_versions: Callable[[object, list[int], str, int], None],
|
||||||
|
sync_seat_assignment_versions: Callable[[object, list[int], str, int], None],
|
||||||
|
replace_members: Callable[[list[object]], list[dict[str, object]]],
|
||||||
|
parse_import_rows: Callable[[UploadFile, bytes], list[object]],
|
||||||
|
) -> None:
|
||||||
|
@app.get("/api/members")
|
||||||
|
def list_members(as_of: str | None = None) -> dict[str, list[dict[str, object]]]:
|
||||||
|
parsed_as_of = parse_as_of(as_of)
|
||||||
|
if parsed_as_of is None:
|
||||||
|
return {"items": fetch_members()}
|
||||||
|
with get_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
return {"items": fetch_members_as_of(cur, parsed_as_of)}
|
||||||
|
|
||||||
|
@app.get("/api/history/members/compare")
|
||||||
|
def compare_members_history(from_date: str, to_date: str) -> dict[str, list[dict[str, object]]]:
|
||||||
|
parsed_from = parse_as_of(from_date)
|
||||||
|
parsed_to = parse_as_of(to_date)
|
||||||
|
if parsed_from is None or parsed_to is None:
|
||||||
|
raise HTTPException(status_code=400, detail="from_date and to_date are required.")
|
||||||
|
with get_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
from_items = fetch_members_as_of(cur, parsed_from)
|
||||||
|
to_items = fetch_members_as_of(cur, parsed_to)
|
||||||
|
return {"items": build_member_compare_items(from_items, to_items)}
|
||||||
|
|
||||||
|
@app.post("/api/members")
|
||||||
|
def create_member(payload: dict = Body(...)) -> dict[str, object]:
|
||||||
|
payload = member_payload_cls.model_validate(payload)
|
||||||
|
with get_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("SELECT COALESCE(MAX(sort_order), -1) + 1 AS next_order FROM members")
|
||||||
|
next_order = int(cur.fetchone()["next_order"])
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO members (
|
||||||
|
name, employee_id, company, rank, role, department, grp, division, team, cell,
|
||||||
|
work_status, work_time, phone, email, seat_label, photo_url, sort_order
|
||||||
|
)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
|
RETURNING id, name, employee_id, company, rank, role, department, grp, division, team, cell,
|
||||||
|
work_status, work_time, phone, email, seat_label, photo_url,
|
||||||
|
sort_order, created_at, updated_at
|
||||||
|
""",
|
||||||
|
serialize_member_payload(payload, payload.sort_order if payload.sort_order is not None else next_order),
|
||||||
|
)
|
||||||
|
member = cur.fetchone()
|
||||||
|
sync_auth_users_from_members(cur)
|
||||||
|
revision_no = create_history_revision(cur, "member-create", f"Member created id={int(member['id'])}")
|
||||||
|
revision_created_at = fetch_history_revision_created_at(cur, revision_no)
|
||||||
|
sync_member_versions(cur, [int(member["id"])], "member-create", revision_no, revision_created_at)
|
||||||
|
conn.commit()
|
||||||
|
return {"item": member}
|
||||||
|
|
||||||
|
@app.put("/api/members/bulk-sync")
|
||||||
|
def bulk_sync_members(payload: dict = Body(...)) -> dict[str, list[dict[str, object]]]:
|
||||||
|
payload = member_bulk_payload_cls.model_validate(payload)
|
||||||
|
return {"items": replace_members(payload.items)}
|
||||||
|
|
||||||
|
@app.put("/api/members/{member_id}")
|
||||||
|
def update_member(member_id: int, payload: dict = Body(...)) -> dict[str, object]:
|
||||||
|
payload = member_payload_cls.model_validate(payload)
|
||||||
|
with get_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE members
|
||||||
|
SET name = %s,
|
||||||
|
employee_id = %s,
|
||||||
|
company = %s,
|
||||||
|
rank = %s,
|
||||||
|
role = %s,
|
||||||
|
department = %s,
|
||||||
|
grp = %s,
|
||||||
|
division = %s,
|
||||||
|
team = %s,
|
||||||
|
cell = %s,
|
||||||
|
work_status = %s,
|
||||||
|
work_time = %s,
|
||||||
|
phone = %s,
|
||||||
|
email = %s,
|
||||||
|
seat_label = %s,
|
||||||
|
photo_url = %s,
|
||||||
|
sort_order = COALESCE(%s, sort_order),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = %s
|
||||||
|
RETURNING id, name, employee_id, company, rank, role, department, grp, division, team, cell,
|
||||||
|
work_status, work_time, phone, email, seat_label, photo_url,
|
||||||
|
sort_order, created_at, updated_at
|
||||||
|
""",
|
||||||
|
(*serialize_member_payload(payload, payload.sort_order or 0)[:-1], payload.sort_order, member_id),
|
||||||
|
)
|
||||||
|
member = cur.fetchone()
|
||||||
|
if member is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Member not found.")
|
||||||
|
sync_auth_users_from_members(cur)
|
||||||
|
revision_no = create_history_revision(cur, "member-update", f"Member updated id={member_id}")
|
||||||
|
revision_created_at = fetch_history_revision_created_at(cur, revision_no)
|
||||||
|
sync_member_versions(cur, [member_id], "member-update", revision_no, revision_created_at)
|
||||||
|
sync_seat_assignment_versions(cur, [member_id], "member-update", revision_no, revision_created_at)
|
||||||
|
conn.commit()
|
||||||
|
return {"item": member}
|
||||||
|
|
||||||
|
@app.delete("/api/members/{member_id}")
|
||||||
|
def delete_member(member_id: int) -> dict[str, bool]:
|
||||||
|
with get_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("DELETE FROM members WHERE id = %s", (member_id,))
|
||||||
|
deleted = cur.rowcount > 0
|
||||||
|
if deleted:
|
||||||
|
sync_auth_users_from_members(cur)
|
||||||
|
revision_no = create_history_revision(cur, "member-delete", f"Member deleted id={member_id}")
|
||||||
|
revision_created_at = fetch_history_revision_created_at(cur, revision_no)
|
||||||
|
sync_member_versions(cur, [member_id], "member-delete", revision_no, revision_created_at)
|
||||||
|
sync_seat_assignment_versions(cur, [member_id], "member-delete", revision_no, revision_created_at)
|
||||||
|
conn.commit()
|
||||||
|
if not deleted:
|
||||||
|
raise HTTPException(status_code=404, detail="Member not found.")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
@app.post("/api/members/import")
|
||||||
|
async def import_members(file: UploadFile = File(...)) -> dict[str, list[dict[str, object]]]:
|
||||||
|
content = await file.read()
|
||||||
|
items = parse_import_rows(file, content)
|
||||||
|
return {"items": replace_members(items)}
|
||||||
19
backend/app/repositories/__init__.py
Normal file
19
backend/app/repositories/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
from .organization import (
|
||||||
|
fetch_current_member_state,
|
||||||
|
fetch_current_seat_assignments,
|
||||||
|
fetch_history_revision,
|
||||||
|
fetch_history_revision_created_at,
|
||||||
|
fetch_history_revisions,
|
||||||
|
fetch_members,
|
||||||
|
fetch_members_as_of,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"fetch_current_member_state",
|
||||||
|
"fetch_current_seat_assignments",
|
||||||
|
"fetch_history_revision",
|
||||||
|
"fetch_history_revision_created_at",
|
||||||
|
"fetch_history_revisions",
|
||||||
|
"fetch_members",
|
||||||
|
"fetch_members_as_of",
|
||||||
|
]
|
||||||
145
backend/app/repositories/organization.py
Normal file
145
backend/app/repositories/organization.py
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, datetime, time, timedelta
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_members(get_conn) -> list[dict[str, object]]:
|
||||||
|
with get_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, name, employee_id, company, rank, role, department, grp, division, team, cell,
|
||||||
|
work_status, work_time, phone, email, seat_label, photo_url,
|
||||||
|
sort_order, created_at, updated_at
|
||||||
|
FROM members
|
||||||
|
ORDER BY sort_order ASC, id ASC
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
return cur.fetchall()
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_history_revision(cur, revision_id: int) -> dict[str, object] | None:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, revision_label, created_at, note
|
||||||
|
FROM history_revisions
|
||||||
|
WHERE scope = 'organization'
|
||||||
|
AND id = %s
|
||||||
|
""",
|
||||||
|
(revision_id,),
|
||||||
|
)
|
||||||
|
return cur.fetchone()
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_history_revisions(
|
||||||
|
cur,
|
||||||
|
*,
|
||||||
|
app_timezone,
|
||||||
|
day: date | None = None,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> list[dict[str, object]]:
|
||||||
|
safe_limit = max(1, min(int(limit), 500))
|
||||||
|
if day is None:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, revision_label, created_at, note
|
||||||
|
FROM history_revisions
|
||||||
|
WHERE scope = 'organization'
|
||||||
|
ORDER BY created_at DESC, id DESC
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(safe_limit,),
|
||||||
|
)
|
||||||
|
return cur.fetchall()
|
||||||
|
|
||||||
|
day_start = datetime.combine(day, time.min, tzinfo=app_timezone)
|
||||||
|
day_end = day_start + timedelta(days=1)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, revision_label, created_at, note
|
||||||
|
FROM history_revisions
|
||||||
|
WHERE scope = 'organization'
|
||||||
|
AND created_at >= %s
|
||||||
|
AND created_at < %s
|
||||||
|
ORDER BY created_at ASC, id ASC
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(day_start, day_end, safe_limit),
|
||||||
|
)
|
||||||
|
return cur.fetchall()
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_history_revision_created_at(cur, revision_id: int) -> datetime:
|
||||||
|
revision = fetch_history_revision(cur, revision_id)
|
||||||
|
if revision is None:
|
||||||
|
raise HTTPException(status_code=404, detail="History revision not found.")
|
||||||
|
return revision["created_at"]
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_current_member_state(cur) -> dict[int, dict[str, object]]:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, name, employee_id, company, rank, role, department, grp, division, team, cell,
|
||||||
|
work_status, work_time, phone, email, seat_label, photo_url, sort_order,
|
||||||
|
created_at, updated_at
|
||||||
|
FROM members
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
return {int(row["id"]): row for row in cur.fetchall()}
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_current_seat_assignments(cur) -> dict[int, dict[str, object]]:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT member_id, seat_map_id, seat_slot_id, seat_label, updated_at
|
||||||
|
FROM seat_positions
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
return {int(row["member_id"]): row for row in cur.fetchall()}
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_members_as_of(cur, as_of: datetime) -> list[dict[str, object]]:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT mv.member_id AS id,
|
||||||
|
mv.name,
|
||||||
|
COALESCE(m.employee_id, '') AS employee_id,
|
||||||
|
mv.company,
|
||||||
|
mv.rank,
|
||||||
|
mv.role,
|
||||||
|
mv.department,
|
||||||
|
mv.grp,
|
||||||
|
mv.division,
|
||||||
|
mv.team,
|
||||||
|
mv.cell,
|
||||||
|
mv.work_status,
|
||||||
|
mv.work_time,
|
||||||
|
mv.phone,
|
||||||
|
mv.email,
|
||||||
|
COALESCE(sav.seat_label, '') AS seat_label,
|
||||||
|
mv.photo_url,
|
||||||
|
COALESCE(m.sort_order, 2147483647) AS sort_order,
|
||||||
|
mv.created_at,
|
||||||
|
mv.valid_from AS updated_at,
|
||||||
|
mv.valid_to AS history_valid_to,
|
||||||
|
mv.revision_no,
|
||||||
|
hr.created_at AS revision_created_at
|
||||||
|
FROM member_versions mv
|
||||||
|
LEFT JOIN members m
|
||||||
|
ON m.id = mv.member_id
|
||||||
|
LEFT JOIN history_revisions hr
|
||||||
|
ON hr.id = mv.revision_no
|
||||||
|
LEFT JOIN seat_assignment_versions sav
|
||||||
|
ON sav.member_id = mv.member_id
|
||||||
|
AND sav.valid_from <= %s
|
||||||
|
AND (sav.valid_to IS NULL OR sav.valid_to > %s)
|
||||||
|
WHERE mv.valid_from <= %s
|
||||||
|
AND (mv.valid_to IS NULL OR mv.valid_to > %s)
|
||||||
|
ORDER BY COALESCE(m.sort_order, 2147483647) ASC, mv.member_id ASC
|
||||||
|
""",
|
||||||
|
(as_of, as_of, as_of, as_of),
|
||||||
|
)
|
||||||
|
return cur.fetchall()
|
||||||
13
backend/app/routes/__init__.py
Normal file
13
backend/app/routes/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
from .auth import register_auth_routes
|
||||||
|
from .integration import register_integration_routes
|
||||||
|
from .organization import register_member_routes
|
||||||
|
from .seatmap import register_seatmap_routes
|
||||||
|
from .system import register_system_routes
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"register_auth_routes",
|
||||||
|
"register_integration_routes",
|
||||||
|
"register_member_routes",
|
||||||
|
"register_seatmap_routes",
|
||||||
|
"register_system_routes",
|
||||||
|
]
|
||||||
3
backend/app/routes/auth.py
Normal file
3
backend/app/routes/auth.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from ..auth_routes import register_auth_routes
|
||||||
|
|
||||||
|
__all__ = ["register_auth_routes"]
|
||||||
3
backend/app/routes/integration.py
Normal file
3
backend/app/routes/integration.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from ..integration_routes import register_integration_routes
|
||||||
|
|
||||||
|
__all__ = ["register_integration_routes"]
|
||||||
3
backend/app/routes/organization.py
Normal file
3
backend/app/routes/organization.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from ..member_routes import register_member_routes
|
||||||
|
|
||||||
|
__all__ = ["register_member_routes"]
|
||||||
3
backend/app/routes/seatmap.py
Normal file
3
backend/app/routes/seatmap.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from ..seatmap_routes import register_seatmap_routes
|
||||||
|
|
||||||
|
__all__ = ["register_seatmap_routes"]
|
||||||
3
backend/app/routes/system.py
Normal file
3
backend/app/routes/system.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from ..system_routes import register_system_routes
|
||||||
|
|
||||||
|
__all__ = ["register_system_routes"]
|
||||||
15
backend/app/schemas/__init__.py
Normal file
15
backend/app/schemas/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
from .organization import (
|
||||||
|
MemberBulkPayload,
|
||||||
|
MemberPayload,
|
||||||
|
SeatLayoutPayload,
|
||||||
|
SeatLayoutPlacementPayload,
|
||||||
|
SeatMapPayload,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"MemberBulkPayload",
|
||||||
|
"MemberPayload",
|
||||||
|
"SeatLayoutPayload",
|
||||||
|
"SeatLayoutPlacementPayload",
|
||||||
|
"SeatMapPayload",
|
||||||
|
]
|
||||||
58
backend/app/schemas/organization.py
Normal file
58
backend/app/schemas/organization.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class MemberPayload(BaseModel):
|
||||||
|
id: int | None = None
|
||||||
|
name: str = Field(min_length=1)
|
||||||
|
employee_id: str = ""
|
||||||
|
company: str = ""
|
||||||
|
rank: str = ""
|
||||||
|
role: str = ""
|
||||||
|
department: str = ""
|
||||||
|
grp: str = ""
|
||||||
|
division: str = ""
|
||||||
|
team: str = ""
|
||||||
|
cell: str = ""
|
||||||
|
work_status: str = ""
|
||||||
|
work_time: str = ""
|
||||||
|
phone: str = ""
|
||||||
|
email: str = ""
|
||||||
|
seat_label: str = ""
|
||||||
|
photo_url: str = ""
|
||||||
|
sort_order: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class MemberBulkPayload(BaseModel):
|
||||||
|
items: list[MemberPayload]
|
||||||
|
|
||||||
|
|
||||||
|
class SeatMapPayload(BaseModel):
|
||||||
|
name: str = Field(min_length=1)
|
||||||
|
image_url: str = ""
|
||||||
|
source_type: str = "image"
|
||||||
|
source_url: str = ""
|
||||||
|
preview_svg: str = ""
|
||||||
|
view_box_min_x: float | None = None
|
||||||
|
view_box_min_y: float | None = None
|
||||||
|
view_box_width: float | None = None
|
||||||
|
view_box_height: float | None = None
|
||||||
|
image_width: int | None = None
|
||||||
|
image_height: int | None = None
|
||||||
|
grid_rows: int = Field(default=1, ge=1, le=200)
|
||||||
|
grid_cols: int = Field(default=1, ge=1, le=200)
|
||||||
|
cell_gap: int = Field(default=0, ge=0, le=24)
|
||||||
|
is_active: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class SeatLayoutPlacementPayload(BaseModel):
|
||||||
|
member_id: int
|
||||||
|
seat_slot_id: int | None = None
|
||||||
|
row_index: int = Field(default=0, ge=0)
|
||||||
|
col_index: int = Field(default=0, ge=0)
|
||||||
|
seat_label: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class SeatLayoutPayload(BaseModel):
|
||||||
|
placements: list[SeatLayoutPlacementPayload]
|
||||||
224
backend/app/seatmap_routes.py
Normal file
224
backend/app/seatmap_routes.py
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import uuid
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
|
||||||
|
|
||||||
|
def register_seatmap_routes(
|
||||||
|
app: FastAPI,
|
||||||
|
*,
|
||||||
|
upload_dir: Path,
|
||||||
|
get_conn,
|
||||||
|
seat_map_payload_cls,
|
||||||
|
seat_layout_payload_cls,
|
||||||
|
fixed_office_source_key: str,
|
||||||
|
parse_dxf_layout: Callable[[Path], tuple[dict[str, object], list[dict[str, object]]]],
|
||||||
|
serialize_seat_map_payload: Callable[[object], tuple[object, ...]],
|
||||||
|
fetch_seat_layout: Callable[[int, object], dict[str, object]],
|
||||||
|
parse_as_of: Callable[[str | None], object],
|
||||||
|
ensure_fixed_office_seat_map: Callable[[str, bool], dict[str, object] | None],
|
||||||
|
build_center_chair_viewer_html: Callable[[dict[str, object]], str],
|
||||||
|
save_seat_layout: Callable[[int, object], list[dict[str, object]]],
|
||||||
|
) -> None:
|
||||||
|
@app.post("/api/uploads/profile-photo")
|
||||||
|
def upload_profile_photo(file: UploadFile = File(...), member_name: str = Form("")) -> dict[str, str]:
|
||||||
|
suffix = Path(file.filename or "").suffix.lower()
|
||||||
|
if suffix not in {".png", ".jpg", ".jpeg", ".webp", ".gif"}:
|
||||||
|
raise HTTPException(status_code=400, detail="Only image files are allowed.")
|
||||||
|
stem = member_name.strip().replace(" ", "-") or "member"
|
||||||
|
filename = f"{datetime.utcnow().strftime('%Y%m%d%H%M%S')}-{stem}-{uuid.uuid4().hex[:8]}{suffix}"
|
||||||
|
target = upload_dir / filename
|
||||||
|
with target.open("wb") as out_file:
|
||||||
|
shutil.copyfileobj(file.file, out_file)
|
||||||
|
return {"url": f"/uploads/{filename}"}
|
||||||
|
|
||||||
|
@app.post("/api/uploads/seat-map-image")
|
||||||
|
def upload_seat_map_image(file: UploadFile = File(...), seat_map_name: str = Form("")) -> dict[str, str]:
|
||||||
|
suffix = Path(file.filename or "").suffix.lower()
|
||||||
|
if suffix not in {".png", ".jpg", ".jpeg", ".webp", ".gif"}:
|
||||||
|
raise HTTPException(status_code=400, detail="Only image files are allowed.")
|
||||||
|
stem = seat_map_name.strip().replace(" ", "-") or "seat-map"
|
||||||
|
filename = f"seat-map-{datetime.utcnow().strftime('%Y%m%d%H%M%S')}-{stem}-{uuid.uuid4().hex[:8]}{suffix}"
|
||||||
|
target = upload_dir / filename
|
||||||
|
with target.open("wb") as out_file:
|
||||||
|
shutil.copyfileobj(file.file, out_file)
|
||||||
|
return {"url": f"/uploads/{filename}"}
|
||||||
|
|
||||||
|
@app.post("/api/seat-maps/dxf")
|
||||||
|
async def create_dxf_seat_map(file: UploadFile = File(...), name: str = Form(...)) -> dict[str, object]:
|
||||||
|
suffix = Path(file.filename or "").suffix.lower()
|
||||||
|
if suffix != ".dxf":
|
||||||
|
raise HTTPException(status_code=400, detail="DXF 파일만 업로드할 수 있습니다.")
|
||||||
|
|
||||||
|
stem = name.strip().replace(" ", "-") or "seat-map"
|
||||||
|
filename = f"seat-map-{datetime.utcnow().strftime('%Y%m%d%H%M%S')}-{stem}-{uuid.uuid4().hex[:8]}{suffix}"
|
||||||
|
target = upload_dir / filename
|
||||||
|
content = await file.read()
|
||||||
|
with target.open("wb") as out_file:
|
||||||
|
out_file.write(content)
|
||||||
|
|
||||||
|
metadata, slots = parse_dxf_layout(target)
|
||||||
|
|
||||||
|
payload = seat_map_payload_cls(
|
||||||
|
name=name.strip(),
|
||||||
|
source_type="dxf",
|
||||||
|
source_url=f"/uploads/{filename}",
|
||||||
|
image_url="",
|
||||||
|
preview_svg=metadata["preview_svg"],
|
||||||
|
view_box_min_x=metadata["view_box_min_x"],
|
||||||
|
view_box_min_y=metadata["view_box_min_y"],
|
||||||
|
view_box_width=metadata["view_box_width"],
|
||||||
|
view_box_height=metadata["view_box_height"],
|
||||||
|
image_width=None,
|
||||||
|
image_height=None,
|
||||||
|
grid_rows=1,
|
||||||
|
grid_cols=1,
|
||||||
|
cell_gap=0,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("UPDATE seat_maps SET is_active = FALSE, updated_at = NOW() WHERE is_active = TRUE")
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO seat_maps (
|
||||||
|
name, source_type, source_url, preview_svg,
|
||||||
|
view_box_min_x, view_box_min_y, view_box_width, view_box_height,
|
||||||
|
image_url, image_width, image_height, grid_rows, grid_cols, cell_gap, is_active
|
||||||
|
)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
|
RETURNING id, name, source_type, source_url, preview_svg,
|
||||||
|
view_box_min_x, view_box_min_y, view_box_width, view_box_height,
|
||||||
|
image_url, image_width, image_height, grid_rows, grid_cols,
|
||||||
|
cell_gap, is_active, created_at, updated_at
|
||||||
|
""",
|
||||||
|
serialize_seat_map_payload(payload),
|
||||||
|
)
|
||||||
|
seat_map = cur.fetchone()
|
||||||
|
for slot in slots:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO seat_slots (seat_map_id, slot_key, label, x, y, rotation, layer_name)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
seat_map["id"],
|
||||||
|
slot["slot_key"],
|
||||||
|
slot["label"],
|
||||||
|
slot["x"],
|
||||||
|
slot["y"],
|
||||||
|
slot["rotation"],
|
||||||
|
slot["layer_name"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return fetch_seat_layout(int(seat_map["id"]))
|
||||||
|
|
||||||
|
@app.get("/api/seat-maps/active")
|
||||||
|
def get_active_seat_map(office_key: str | None = None) -> dict[str, dict[str, object]]:
|
||||||
|
requested_key = (office_key or "").strip() or fixed_office_source_key
|
||||||
|
seat_map = ensure_fixed_office_seat_map(requested_key, activate=requested_key == fixed_office_source_key)
|
||||||
|
if seat_map is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Active seat map not found.")
|
||||||
|
return {"item": seat_map}
|
||||||
|
|
||||||
|
@app.post("/api/seat-maps")
|
||||||
|
def create_seat_map(payload: seat_map_payload_cls) -> dict[str, dict[str, object]]:
|
||||||
|
with get_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
if payload.is_active:
|
||||||
|
cur.execute("UPDATE seat_maps SET is_active = FALSE, updated_at = NOW() WHERE is_active = TRUE")
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO seat_maps (
|
||||||
|
name, source_type, source_url, preview_svg,
|
||||||
|
view_box_min_x, view_box_min_y, view_box_width, view_box_height,
|
||||||
|
image_url, image_width, image_height, grid_rows, grid_cols, cell_gap, is_active
|
||||||
|
)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
|
RETURNING id, name, source_type, source_url, preview_svg,
|
||||||
|
view_box_min_x, view_box_min_y, view_box_width, view_box_height,
|
||||||
|
image_url, image_width, image_height, grid_rows, grid_cols,
|
||||||
|
cell_gap, is_active, created_at, updated_at
|
||||||
|
""",
|
||||||
|
serialize_seat_map_payload(payload),
|
||||||
|
)
|
||||||
|
seat_map = cur.fetchone()
|
||||||
|
conn.commit()
|
||||||
|
return {"item": seat_map}
|
||||||
|
|
||||||
|
@app.put("/api/seat-maps/{seat_map_id}")
|
||||||
|
def update_seat_map(seat_map_id: int, payload: seat_map_payload_cls) -> dict[str, dict[str, object]]:
|
||||||
|
with get_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
if payload.source_type != "dxf":
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) AS count
|
||||||
|
FROM seat_positions
|
||||||
|
WHERE seat_map_id = %s
|
||||||
|
AND (row_index >= %s OR col_index >= %s)
|
||||||
|
""",
|
||||||
|
(seat_map_id, payload.grid_rows, payload.grid_cols),
|
||||||
|
)
|
||||||
|
out_of_bounds_count = int(cur.fetchone()["count"])
|
||||||
|
if out_of_bounds_count > 0:
|
||||||
|
raise HTTPException(status_code=400, detail="현재 배치된 좌석이 새 그리드 범위를 벗어납니다. 먼저 좌석 배치를 정리하세요.")
|
||||||
|
if payload.is_active:
|
||||||
|
cur.execute("UPDATE seat_maps SET is_active = FALSE, updated_at = NOW() WHERE is_active = TRUE AND id <> %s", (seat_map_id,))
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE seat_maps
|
||||||
|
SET name = %s,
|
||||||
|
source_type = %s,
|
||||||
|
source_url = %s,
|
||||||
|
preview_svg = %s,
|
||||||
|
view_box_min_x = %s,
|
||||||
|
view_box_min_y = %s,
|
||||||
|
view_box_width = %s,
|
||||||
|
view_box_height = %s,
|
||||||
|
image_url = %s,
|
||||||
|
image_width = %s,
|
||||||
|
image_height = %s,
|
||||||
|
grid_rows = %s,
|
||||||
|
grid_cols = %s,
|
||||||
|
cell_gap = %s,
|
||||||
|
is_active = %s,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = %s
|
||||||
|
RETURNING id, name, source_type, source_url, preview_svg,
|
||||||
|
view_box_min_x, view_box_min_y, view_box_width, view_box_height,
|
||||||
|
image_url, image_width, image_height, grid_rows, grid_cols,
|
||||||
|
cell_gap, is_active, created_at, updated_at
|
||||||
|
""",
|
||||||
|
(*serialize_seat_map_payload(payload), seat_map_id),
|
||||||
|
)
|
||||||
|
seat_map = cur.fetchone()
|
||||||
|
if seat_map is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Seat map not found.")
|
||||||
|
conn.commit()
|
||||||
|
return {"item": seat_map}
|
||||||
|
|
||||||
|
@app.get("/api/seat-maps/{seat_map_id}/layout")
|
||||||
|
def get_seat_layout(seat_map_id: int, as_of: str | None = None) -> dict[str, object]:
|
||||||
|
return fetch_seat_layout(seat_map_id, parse_as_of(as_of))
|
||||||
|
|
||||||
|
@app.get("/api/seat-maps/{seat_map_id}/viewer")
|
||||||
|
def get_seat_map_viewer(seat_map_id: int, as_of: str | None = None) -> HTMLResponse:
|
||||||
|
layout = fetch_seat_layout(seat_map_id, parse_as_of(as_of))
|
||||||
|
seat_map = layout.get("seat_map") or {}
|
||||||
|
if seat_map.get("source_type") not in {"dxf", "fixed_html"}:
|
||||||
|
raise HTTPException(status_code=400, detail="Viewer is only available for supported seat maps.")
|
||||||
|
return HTMLResponse(build_center_chair_viewer_html(layout))
|
||||||
|
|
||||||
|
@app.put("/api/seat-maps/{seat_map_id}/layout")
|
||||||
|
def update_seat_layout(seat_map_id: int, payload: seat_layout_payload_cls) -> dict[str, list[dict[str, object]]]:
|
||||||
|
return {"items": save_seat_layout(seat_map_id, payload)}
|
||||||
21
backend/app/services/__init__.py
Normal file
21
backend/app/services/__init__.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
from .organization import (
|
||||||
|
create_history_revision,
|
||||||
|
merge_import_member,
|
||||||
|
normalize_phone,
|
||||||
|
pick_existing_member,
|
||||||
|
replace_members,
|
||||||
|
serialize_member_payload,
|
||||||
|
sync_member_versions,
|
||||||
|
sync_seat_assignment_versions,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"create_history_revision",
|
||||||
|
"merge_import_member",
|
||||||
|
"normalize_phone",
|
||||||
|
"pick_existing_member",
|
||||||
|
"replace_members",
|
||||||
|
"serialize_member_payload",
|
||||||
|
"sync_member_versions",
|
||||||
|
"sync_seat_assignment_versions",
|
||||||
|
]
|
||||||
350
backend/app/services/organization.py
Normal file
350
backend/app/services/organization.py
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
from ..repositories import (
|
||||||
|
fetch_current_member_state,
|
||||||
|
fetch_current_seat_assignments,
|
||||||
|
fetch_history_revision_created_at,
|
||||||
|
fetch_members,
|
||||||
|
)
|
||||||
|
from ..schemas import MemberPayload
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_phone(value: object) -> str:
|
||||||
|
raw = str(value or "").strip()
|
||||||
|
digits = "".join(ch for ch in raw if ch.isdigit())
|
||||||
|
if not digits:
|
||||||
|
return ""
|
||||||
|
if len(digits) == 10 and not digits.startswith("0"):
|
||||||
|
digits = f"0{digits}"
|
||||||
|
if len(digits) == 11 and digits.startswith("0"):
|
||||||
|
return f"{digits[:3]}-{digits[3:7]}-{digits[7:]}"
|
||||||
|
if len(digits) == 10 and digits.startswith("0"):
|
||||||
|
return f"{digits[:3]}-{digits[3:6]}-{digits[6:]}"
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_member_payload(item, sort_order: int) -> tuple[object, ...]:
|
||||||
|
return (
|
||||||
|
item.name.strip(),
|
||||||
|
item.employee_id.strip(),
|
||||||
|
item.company.strip(),
|
||||||
|
item.rank.strip(),
|
||||||
|
item.role.strip(),
|
||||||
|
item.department.strip(),
|
||||||
|
item.grp.strip(),
|
||||||
|
item.division.strip(),
|
||||||
|
item.team.strip(),
|
||||||
|
item.cell.strip(),
|
||||||
|
item.work_status.strip(),
|
||||||
|
item.work_time.strip(),
|
||||||
|
normalize_phone(item.phone),
|
||||||
|
item.email.strip(),
|
||||||
|
item.seat_label.strip(),
|
||||||
|
item.photo_url.strip(),
|
||||||
|
sort_order,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_history_revision(cur, label_prefix: str, note: str, *, app_timezone) -> int:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO history_revisions (scope, revision_label, note)
|
||||||
|
VALUES ('organization', %s, %s)
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
(f"{label_prefix}-{datetime.now(app_timezone).strftime('%Y%m%d-%H%M%S-%f')}", note),
|
||||||
|
)
|
||||||
|
return int(cur.fetchone()["id"])
|
||||||
|
|
||||||
|
|
||||||
|
def sync_member_versions(
|
||||||
|
cur,
|
||||||
|
member_ids: list[int],
|
||||||
|
change_reason: str,
|
||||||
|
revision_no: int,
|
||||||
|
effective_at: datetime | None = None,
|
||||||
|
) -> None:
|
||||||
|
if not member_ids:
|
||||||
|
return
|
||||||
|
event_at = effective_at or datetime.now(timezone.utc)
|
||||||
|
unique_ids = sorted(set(int(member_id) for member_id in member_ids))
|
||||||
|
current_members = fetch_current_member_state(cur)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, member_id, name, company, rank, role, department, grp, division, team, cell,
|
||||||
|
work_status, work_time, phone, email, photo_url, valid_from, valid_to
|
||||||
|
FROM member_versions
|
||||||
|
WHERE member_id = ANY(%s)
|
||||||
|
AND valid_to IS NULL
|
||||||
|
""",
|
||||||
|
(unique_ids,),
|
||||||
|
)
|
||||||
|
active_versions = {int(row["member_id"]): row for row in cur.fetchall()}
|
||||||
|
|
||||||
|
for member_id in unique_ids:
|
||||||
|
current = current_members.get(member_id)
|
||||||
|
active = active_versions.get(member_id)
|
||||||
|
if current is None:
|
||||||
|
if active is not None:
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE member_versions SET valid_to = %s WHERE id = %s AND valid_to IS NULL",
|
||||||
|
(event_at, int(active["id"])),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
current_tuple = (
|
||||||
|
str(current.get("name") or ""),
|
||||||
|
str(current.get("company") or ""),
|
||||||
|
str(current.get("rank") or ""),
|
||||||
|
str(current.get("role") or ""),
|
||||||
|
str(current.get("department") or ""),
|
||||||
|
str(current.get("grp") or ""),
|
||||||
|
str(current.get("division") or ""),
|
||||||
|
str(current.get("team") or ""),
|
||||||
|
str(current.get("cell") or ""),
|
||||||
|
str(current.get("work_status") or ""),
|
||||||
|
str(current.get("work_time") or ""),
|
||||||
|
str(current.get("phone") or ""),
|
||||||
|
str(current.get("email") or ""),
|
||||||
|
str(current.get("photo_url") or ""),
|
||||||
|
)
|
||||||
|
active_tuple = None
|
||||||
|
if active is not None:
|
||||||
|
active_tuple = (
|
||||||
|
str(active.get("name") or ""),
|
||||||
|
str(active.get("company") or ""),
|
||||||
|
str(active.get("rank") or ""),
|
||||||
|
str(active.get("role") or ""),
|
||||||
|
str(active.get("department") or ""),
|
||||||
|
str(active.get("grp") or ""),
|
||||||
|
str(active.get("division") or ""),
|
||||||
|
str(active.get("team") or ""),
|
||||||
|
str(active.get("cell") or ""),
|
||||||
|
str(active.get("work_status") or ""),
|
||||||
|
str(active.get("work_time") or ""),
|
||||||
|
str(active.get("phone") or ""),
|
||||||
|
str(active.get("email") or ""),
|
||||||
|
str(active.get("photo_url") or ""),
|
||||||
|
)
|
||||||
|
if active_tuple == current_tuple:
|
||||||
|
continue
|
||||||
|
if active is not None:
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE member_versions SET valid_to = %s WHERE id = %s AND valid_to IS NULL",
|
||||||
|
(event_at, int(active["id"])),
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO member_versions (
|
||||||
|
member_id, name, company, rank, role, department, grp, division, team, cell,
|
||||||
|
work_status, work_time, phone, email, photo_url,
|
||||||
|
valid_from, valid_to, revision_no, changed_by_user_id, change_reason
|
||||||
|
)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NULL, %s, NULL, %s)
|
||||||
|
""",
|
||||||
|
(member_id, *current_tuple, event_at, revision_no, change_reason),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def sync_seat_assignment_versions(
|
||||||
|
cur,
|
||||||
|
member_ids: list[int],
|
||||||
|
change_reason: str,
|
||||||
|
revision_no: int,
|
||||||
|
effective_at: datetime | None = None,
|
||||||
|
) -> None:
|
||||||
|
if not member_ids:
|
||||||
|
return
|
||||||
|
event_at = effective_at or datetime.now(timezone.utc)
|
||||||
|
unique_ids = sorted(set(int(member_id) for member_id in member_ids))
|
||||||
|
current_assignments = fetch_current_seat_assignments(cur)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, member_id, seat_map_id, seat_slot_id, seat_label
|
||||||
|
FROM seat_assignment_versions
|
||||||
|
WHERE member_id = ANY(%s)
|
||||||
|
AND valid_to IS NULL
|
||||||
|
""",
|
||||||
|
(unique_ids,),
|
||||||
|
)
|
||||||
|
active_versions = {int(row["member_id"]): row for row in cur.fetchall()}
|
||||||
|
|
||||||
|
for member_id in unique_ids:
|
||||||
|
current = current_assignments.get(member_id)
|
||||||
|
active = active_versions.get(member_id)
|
||||||
|
current_tuple = None
|
||||||
|
if current is not None:
|
||||||
|
current_tuple = (
|
||||||
|
current.get("seat_map_id"),
|
||||||
|
current.get("seat_slot_id"),
|
||||||
|
str(current.get("seat_label") or ""),
|
||||||
|
)
|
||||||
|
active_tuple = None
|
||||||
|
if active is not None:
|
||||||
|
active_tuple = (
|
||||||
|
active.get("seat_map_id"),
|
||||||
|
active.get("seat_slot_id"),
|
||||||
|
str(active.get("seat_label") or ""),
|
||||||
|
)
|
||||||
|
if active_tuple == current_tuple:
|
||||||
|
continue
|
||||||
|
if active is not None:
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE seat_assignment_versions SET valid_to = %s WHERE id = %s AND valid_to IS NULL",
|
||||||
|
(event_at, int(active["id"])),
|
||||||
|
)
|
||||||
|
if current is None:
|
||||||
|
continue
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO seat_assignment_versions (
|
||||||
|
member_id, seat_map_id, seat_slot_id, seat_label,
|
||||||
|
valid_from, valid_to, revision_no, changed_by_user_id, change_reason
|
||||||
|
)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, NULL, %s, NULL, %s)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
member_id,
|
||||||
|
current.get("seat_map_id"),
|
||||||
|
current.get("seat_slot_id"),
|
||||||
|
str(current.get("seat_label") or ""),
|
||||||
|
event_at,
|
||||||
|
revision_no,
|
||||||
|
change_reason,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def merge_import_member(item: MemberPayload, existing: dict[str, object] | None) -> MemberPayload:
|
||||||
|
if existing is None:
|
||||||
|
return item
|
||||||
|
|
||||||
|
payload = item.model_copy(deep=True)
|
||||||
|
if not payload.photo_url.strip():
|
||||||
|
payload.photo_url = str(existing.get("photo_url") or "")
|
||||||
|
if not payload.seat_label.strip():
|
||||||
|
payload.seat_label = str(existing.get("seat_label") or "")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def pick_existing_member(
|
||||||
|
item: MemberPayload,
|
||||||
|
existing_by_employee_id: dict[str, list[dict[str, object]]],
|
||||||
|
existing_by_name: dict[str, list[dict[str, object]]],
|
||||||
|
matched_ids: set[int],
|
||||||
|
) -> dict[str, object] | None:
|
||||||
|
employee_id = item.employee_id.strip()
|
||||||
|
if employee_id:
|
||||||
|
for candidate in existing_by_employee_id.get(employee_id, []):
|
||||||
|
candidate_id = int(candidate["id"])
|
||||||
|
if candidate_id not in matched_ids:
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
name = item.name.strip()
|
||||||
|
if name:
|
||||||
|
available = [
|
||||||
|
candidate
|
||||||
|
for candidate in existing_by_name.get(name, [])
|
||||||
|
if int(candidate["id"]) not in matched_ids
|
||||||
|
]
|
||||||
|
if len(available) == 1:
|
||||||
|
return available[0]
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def replace_members(
|
||||||
|
items: list[MemberPayload],
|
||||||
|
*,
|
||||||
|
get_conn,
|
||||||
|
sync_auth_users_from_members: Callable[[object], None],
|
||||||
|
app_timezone,
|
||||||
|
) -> list[dict[str, object]]:
|
||||||
|
with get_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, name, employee_id, company, rank, role, department, grp, division, team, cell,
|
||||||
|
work_status, work_time, phone, email, seat_label, photo_url,
|
||||||
|
sort_order, created_at, updated_at
|
||||||
|
FROM members
|
||||||
|
ORDER BY id ASC
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
existing_members = cur.fetchall()
|
||||||
|
|
||||||
|
existing_by_employee_id: dict[str, list[dict[str, object]]] = {}
|
||||||
|
existing_by_name: dict[str, list[dict[str, object]]] = {}
|
||||||
|
for member in existing_members:
|
||||||
|
employee_id = str(member.get("employee_id") or "").strip()
|
||||||
|
name = str(member.get("name") or "").strip()
|
||||||
|
if employee_id:
|
||||||
|
existing_by_employee_id.setdefault(employee_id, []).append(member)
|
||||||
|
if name:
|
||||||
|
existing_by_name.setdefault(name, []).append(member)
|
||||||
|
|
||||||
|
matched_ids: set[int] = set()
|
||||||
|
for index, item in enumerate(items):
|
||||||
|
existing = pick_existing_member(item, existing_by_employee_id, existing_by_name, matched_ids)
|
||||||
|
merged_item = merge_import_member(item, existing)
|
||||||
|
if existing is None:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO members (
|
||||||
|
name, employee_id, company, rank, role, department, grp, division, team, cell,
|
||||||
|
work_status, work_time, phone, email, seat_label, photo_url, sort_order
|
||||||
|
)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
|
""",
|
||||||
|
serialize_member_payload(merged_item, index),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
matched_ids.add(int(existing["id"]))
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE members
|
||||||
|
SET name = %s,
|
||||||
|
employee_id = %s,
|
||||||
|
company = %s,
|
||||||
|
rank = %s,
|
||||||
|
role = %s,
|
||||||
|
department = %s,
|
||||||
|
grp = %s,
|
||||||
|
division = %s,
|
||||||
|
team = %s,
|
||||||
|
cell = %s,
|
||||||
|
work_status = %s,
|
||||||
|
work_time = %s,
|
||||||
|
phone = %s,
|
||||||
|
email = %s,
|
||||||
|
seat_label = %s,
|
||||||
|
photo_url = %s,
|
||||||
|
sort_order = %s,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(*serialize_member_payload(merged_item, index), int(existing["id"])),
|
||||||
|
)
|
||||||
|
stale_ids = [int(member["id"]) for member in existing_members if int(member["id"]) not in matched_ids]
|
||||||
|
if stale_ids:
|
||||||
|
cur.execute("DELETE FROM members WHERE id = ANY(%s)", (stale_ids,))
|
||||||
|
sync_auth_users_from_members(cur)
|
||||||
|
cur.execute("SELECT id FROM members")
|
||||||
|
current_ids = [int(row["id"]) for row in cur.fetchall()]
|
||||||
|
affected_member_ids = sorted(set(current_ids + [int(member["id"]) for member in existing_members]))
|
||||||
|
if affected_member_ids:
|
||||||
|
revision_no = create_history_revision(
|
||||||
|
cur,
|
||||||
|
"members-bulk-sync",
|
||||||
|
"Bulk member sync applied",
|
||||||
|
app_timezone=app_timezone,
|
||||||
|
)
|
||||||
|
revision_created_at = fetch_history_revision_created_at(cur, revision_no)
|
||||||
|
sync_member_versions(cur, affected_member_ids, "members-bulk-sync", revision_no, revision_created_at)
|
||||||
|
sync_seat_assignment_versions(cur, affected_member_ids, "members-bulk-sync", revision_no, revision_created_at)
|
||||||
|
conn.commit()
|
||||||
|
return fetch_members(get_conn)
|
||||||
120
backend/app/system_routes.py
Normal file
120
backend/app/system_routes.py
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from fastapi.responses import FileResponse, Response
|
||||||
|
|
||||||
|
from .admin_db_status import fetch_db_status_snapshot, fetch_db_table_preview
|
||||||
|
|
||||||
|
|
||||||
|
def register_system_routes(
|
||||||
|
app: FastAPI,
|
||||||
|
*,
|
||||||
|
upload_dir: Path,
|
||||||
|
legacy_dir: Path,
|
||||||
|
incoming_files_dir: Path,
|
||||||
|
incoming_served_dir: Path,
|
||||||
|
db_status_served_dir: Path,
|
||||||
|
business_ledger_index_path: Path,
|
||||||
|
get_member_count: Callable[[], int],
|
||||||
|
get_conn,
|
||||||
|
build_business_ledger_default_response: Callable[[object], Response],
|
||||||
|
build_ledger_index_response: Callable[[Path], FileResponse],
|
||||||
|
) -> None:
|
||||||
|
@app.get("/api/health")
|
||||||
|
def health() -> dict[str, object]:
|
||||||
|
checks = {
|
||||||
|
"upload_dir": upload_dir.exists(),
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
member_count = get_member_count()
|
||||||
|
checks["database"] = True
|
||||||
|
except Exception:
|
||||||
|
member_count = None
|
||||||
|
checks["database"] = False
|
||||||
|
|
||||||
|
status = "ok" if all(checks.values()) else "degraded"
|
||||||
|
return {
|
||||||
|
"status": status,
|
||||||
|
"checks": checks,
|
||||||
|
"member_count": member_count,
|
||||||
|
"timestamp": datetime.utcnow().isoformat() + "Z",
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.get("/api/admin/db-status")
|
||||||
|
def admin_db_status() -> dict[str, object]:
|
||||||
|
return fetch_db_status_snapshot()
|
||||||
|
|
||||||
|
@app.get("/api/admin/db-status/table")
|
||||||
|
def admin_db_status_table(schema: str, table: str, limit: int = 50) -> dict[str, object]:
|
||||||
|
return fetch_db_table_preview(schema, table, limit)
|
||||||
|
|
||||||
|
@app.get("/admin/db-status")
|
||||||
|
def admin_db_status_view() -> FileResponse:
|
||||||
|
target = db_status_served_dir / "index.html"
|
||||||
|
if not target.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="DB status dashboard file not found.")
|
||||||
|
response = FileResponse(target)
|
||||||
|
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
|
||||||
|
response.headers["Pragma"] = "no-cache"
|
||||||
|
return response
|
||||||
|
|
||||||
|
@app.get("/api/integration/business-ledger-default")
|
||||||
|
def integration_business_ledger_default() -> Response:
|
||||||
|
with get_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
return build_business_ledger_default_response(cur)
|
||||||
|
|
||||||
|
@app.get("/api/integration/mh-workbook")
|
||||||
|
def integration_mh_workbook() -> FileResponse:
|
||||||
|
target = incoming_files_dir / "MH.xlsx"
|
||||||
|
if not target.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="MH workbook not found.")
|
||||||
|
return FileResponse(
|
||||||
|
target,
|
||||||
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
filename="MH.xlsx",
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.get("/legacy/organization")
|
||||||
|
def legacy_organization() -> FileResponse:
|
||||||
|
target = legacy_dir / "DashBoard-organization.html"
|
||||||
|
if not target.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="Legacy dashboard file not found.")
|
||||||
|
return FileResponse(target)
|
||||||
|
|
||||||
|
@app.get("/legacy/organization-backup")
|
||||||
|
def legacy_organization_backup() -> FileResponse:
|
||||||
|
target = legacy_dir / "DashBoard-organization-backup.html"
|
||||||
|
if not target.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="Legacy dashboard backup not found.")
|
||||||
|
return FileResponse(target)
|
||||||
|
|
||||||
|
@app.get("/integrations/payment")
|
||||||
|
def integration_payment() -> FileResponse:
|
||||||
|
target = incoming_served_dir / "payment.html"
|
||||||
|
if not target.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="Payment integration file not found.")
|
||||||
|
return FileResponse(target)
|
||||||
|
|
||||||
|
@app.get("/integrations/ledger")
|
||||||
|
def integration_ledger() -> FileResponse:
|
||||||
|
return build_ledger_index_response(business_ledger_index_path)
|
||||||
|
|
||||||
|
@app.get("/integrations/mh")
|
||||||
|
def integration_mh() -> FileResponse:
|
||||||
|
target = incoming_served_dir / "mh.html"
|
||||||
|
if not target.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="MH integration file not found.")
|
||||||
|
return FileResponse(target)
|
||||||
|
|
||||||
|
@app.get("/uploads/{filename}")
|
||||||
|
def get_upload(filename: str) -> FileResponse:
|
||||||
|
target = upload_dir / filename
|
||||||
|
if not target.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="Upload not found.")
|
||||||
|
return FileResponse(target)
|
||||||
@@ -286,9 +286,9 @@ API 보호 예시:
|
|||||||
기존 프론트엔드의 mock 로그인 제거.
|
기존 프론트엔드의 mock 로그인 제거.
|
||||||
|
|
||||||
변경 대상:
|
변경 대상:
|
||||||
- [frontend/public/app.js](/home/hyunho/projects/mh-dashboard-organization/frontend/public/app.js)
|
- [frontend/public/app.js](../frontend/public/app.js)
|
||||||
- [backend/app/main.py](/home/hyunho/projects/mh-dashboard-organization/backend/app/main.py)
|
- [backend/app/main.py](../backend/app/main.py)
|
||||||
- [backend/app/config.py](/home/hyunho/projects/mh-dashboard-organization/backend/app/config.py)
|
- [backend/app/config.py](../backend/app/config.py)
|
||||||
|
|
||||||
### Phase 4
|
### Phase 4
|
||||||
|
|
||||||
|
|||||||
@@ -23,19 +23,10 @@
|
|||||||
- 이 저장소를 서버로 복사합니다.
|
- 이 저장소를 서버로 복사합니다.
|
||||||
- `.env.example`을 기준으로 `.env` 파일을 만들고 실제 DB 비밀번호를 넣습니다.
|
- `.env.example`을 기준으로 `.env` 파일을 만들고 실제 DB 비밀번호를 넣습니다.
|
||||||
|
|
||||||
## 4-1. 현재 로컬 PC 기준 WSL 작업 표준
|
## 4-1. 로컬 개발 환경 원칙
|
||||||
- 현재 로컬 개발 서버는 `WSL2 + Ubuntu-24.04` 기준으로 구성했습니다.
|
- 로컬 개발은 Linux 계열 개발 환경을 권장합니다.
|
||||||
- 기본 작업 사용자는 `hyunho` 입니다.
|
- Docker, Python, 파일 경로가 실제 배포 환경과 최대한 비슷한 환경에서 작업하는 것이 안전합니다.
|
||||||
- 앞으로의 기준 작업 경로는 아래입니다.
|
- Windows 호스트를 사용하는 경우에도, 실제 실행 경로와 편집 경로가 어긋나지 않도록 주의합니다.
|
||||||
- `/home/hyunho/projects/mh-dashboard-organization`
|
|
||||||
- Windows 폴더는 원본 참고용으로 남아 있을 수 있지만, 실제 실행과 개발 기준은 WSL 내부 경로로 맞추는 것을 권장합니다.
|
|
||||||
|
|
||||||
## 4-2. VS Code는 어떤 경로를 열어야 하나
|
|
||||||
- VS Code 좌측 아래에 `WSL: Ubuntu-24.04` 가 보이는 상태로 여는 것이 가장 안전합니다.
|
|
||||||
- VS Code에서 `Remote-WSL: Reopen Folder in WSL` 기능으로 다시 열 수 있습니다.
|
|
||||||
- 다시 열어야 할 권장 경로는 아래입니다.
|
|
||||||
- `/home/hyunho/projects/mh-dashboard-organization`
|
|
||||||
- 이렇게 열면 Docker, Python, Linux 경로, 실행 환경이 실제 서버와 가장 비슷하게 맞춰집니다.
|
|
||||||
|
|
||||||
## 5. Docker 설치 관련 메모
|
## 5. Docker 설치 관련 메모
|
||||||
- 메신저에 공유된 명령어는 Ubuntu 서버에 Docker를 설치하기 위한 절차입니다.
|
- 메신저에 공유된 명령어는 Ubuntu 서버에 Docker를 설치하기 위한 절차입니다.
|
||||||
@@ -73,8 +64,6 @@
|
|||||||
## 10. 현재 로컬 테스트 접속 정보
|
## 10. 현재 로컬 테스트 접속 정보
|
||||||
- 접속 주소: `http://localhost:8080`
|
- 접속 주소: `http://localhost:8080`
|
||||||
- 상태 확인 API: `http://localhost:8080/api/health`
|
- 상태 확인 API: `http://localhost:8080/api/health`
|
||||||
- WSL 내부 실행 경로:
|
|
||||||
- `/home/hyunho/projects/mh-dashboard-organization`
|
|
||||||
|
|
||||||
## 11. 운영 검증 체크포인트
|
## 11. 운영 검증 체크포인트
|
||||||
- 엑셀 또는 CSV 업로드 후 `GET /api/members` 에서 데이터가 조회되는지 확인합니다.
|
- 엑셀 또는 CSV 업로드 후 `GET /api/members` 에서 데이터가 조회되는지 확인합니다.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
이 문서는 `total` 브랜치에서 진행한 통합 작업을 기능 단위로 정리한 개발 히스토리다.
|
이 문서는 이 저장소에서 진행한 통합 작업을 기능 단위로 정리한 개발 히스토리다.
|
||||||
목표는 다음 두 가지다.
|
목표는 다음 두 가지다.
|
||||||
|
|
||||||
- 지금까지 어떤 기능을 어떤 방식으로 붙였는지 빠르게 파악
|
- 지금까지 어떤 기능을 어떤 방식으로 붙였는지 빠르게 파악
|
||||||
@@ -161,12 +161,12 @@
|
|||||||
|
|
||||||
### 작업 내용
|
### 작업 내용
|
||||||
|
|
||||||
- WSL 내부 `0.0.0.0:8080` 바인딩 확인
|
- 개발 환경의 외부 접속 경로를 정리
|
||||||
- Windows host에서 `portproxy`와 방화벽 규칙으로 다른 PC 접속 가능하게 정리
|
- 호스트 방화벽과 포트 포워딩 규칙으로 다른 PC 접속 가능하게 구성
|
||||||
|
|
||||||
### 유의사항
|
### 유의사항
|
||||||
|
|
||||||
- Windows LAN IP 또는 WSL IP가 바뀌면 `portproxy`의 `connectaddress`는 다시 맞춰야 한다
|
- 호스트 IP나 포워딩 대상 IP가 바뀌면 포트 포워딩 설정을 다시 맞춰야 한다
|
||||||
- 운영 안정성을 위해 향후 자동화 스크립트화가 필요함
|
- 운영 안정성을 위해 향후 자동화 스크립트화가 필요함
|
||||||
|
|
||||||
## 10. 인증 기본 구조 추가
|
## 10. 인증 기본 구조 추가
|
||||||
@@ -226,356 +226,12 @@
|
|||||||
|
|
||||||
### 설계 문서
|
### 설계 문서
|
||||||
|
|
||||||
- [HISTORY_ASOF_DB_PLAN.md](/home/hyunho/projects/mh-dashboard-organization/docs/HISTORY_ASOF_DB_PLAN.md)
|
- [HISTORY_ASOF_DB_PLAN.md](HISTORY_ASOF_DB_PLAN.md)
|
||||||
|
|
||||||
## 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
|
## Next Focus
|
||||||
|
|
||||||
- 사업관리대장 원본 담당자와 세부 데이터 규칙 정렬
|
- `#2` 영속성 운영 검증과 문서 기준 정리
|
||||||
- 자리배치도 `#7`, `#8` 재작업 및 마무리
|
|
||||||
- 권한 제어와 mock login 정리
|
- 권한 제어와 mock login 정리
|
||||||
- `#9` as-of date 기반 history 구조 설계 및 점진적 도입
|
- `#9` as-of date 기반 history 구조 설계 및 점진적 도입
|
||||||
- 조직현황의 장기적 앱 구조 승격 검토
|
- 자리배치도 조직 트리, 나머지 사무실 도면 등 실사용 기능 고도화
|
||||||
|
- 프로젝트별 분석의 남은 소수점/분류 오차 정리
|
||||||
|
|||||||
@@ -10,15 +10,15 @@
|
|||||||
|
|
||||||
### 코드 경로
|
### 코드 경로
|
||||||
|
|
||||||
- 공개용 `8080`: `/home/hyunho/projects/mh-dashboard-organization`
|
- 공개용 `8080`: 메인 workspace
|
||||||
- 작업용 `8081`: `/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081`
|
- 작업용 `8081`: 메인 workspace 아래의 격리 worktree
|
||||||
|
|
||||||
### 작업용 Compose 기준
|
### 작업용 Compose 기준
|
||||||
|
|
||||||
- 공개용 `8080` stack: `docker-compose.yml`
|
- 공개용 `8080` stack: `docker-compose.yml`
|
||||||
- 작업용 `8081` stack: `docker-compose.8081.yml`
|
- 작업용 `8081` stack: `docker-compose.8081.yml`
|
||||||
- 작업용 project name 기본값: `mh-dashboard-organization-dev`
|
- 작업용 project name 기본값: `mh-dashboard-organization-dev`
|
||||||
- 작업용 `8081`는 반드시 `/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081`에서 띄운다
|
- 작업용 `8081`는 반드시 격리된 worktree에서 띄운다
|
||||||
|
|
||||||
### DB 볼륨
|
### DB 볼륨
|
||||||
|
|
||||||
@@ -102,7 +102,7 @@
|
|||||||
1. `8080`과 `8081` 모두 기동 상태 확인
|
1. `8080`과 `8081` 모두 기동 상태 확인
|
||||||
2. 이번 작업이 `코드 변경`인지 `데이터 변경`인지 먼저 구분
|
2. 이번 작업이 `코드 변경`인지 `데이터 변경`인지 먼저 구분
|
||||||
3. 공개용 기준 데이터가 필요한 화면이면 `8081` DB를 먼저 `8080` 기준으로 맞춤
|
3. 공개용 기준 데이터가 필요한 화면이면 `8081` DB를 먼저 `8080` 기준으로 맞춤
|
||||||
4. 작업 전후 검증은 [REGRESSION_CHECKLIST.md](/home/hyunho/projects/mh-dashboard-organization/docs/REGRESSION_CHECKLIST.md) 기준으로 수행
|
4. 작업 전후 검증은 [REGRESSION_CHECKLIST.md](REGRESSION_CHECKLIST.md) 기준으로 수행
|
||||||
|
|
||||||
### 2. 기능 개발 중
|
### 2. 기능 개발 중
|
||||||
|
|
||||||
@@ -154,7 +154,7 @@
|
|||||||
2. 공개용 기준 데이터가 필요한지 판단
|
2. 공개용 기준 데이터가 필요한지 판단
|
||||||
3. 필요하면 `8081` DB를 `8080` 기준으로 먼저 동기화
|
3. 필요하면 `8081` DB를 `8080` 기준으로 먼저 동기화
|
||||||
4. 그 뒤 기능 개발과 검증 수행
|
4. 그 뒤 기능 개발과 검증 수행
|
||||||
5. 검증은 [REGRESSION_CHECKLIST.md](/home/hyunho/projects/mh-dashboard-organization/docs/REGRESSION_CHECKLIST.md) 기준으로 수행
|
5. 검증은 [REGRESSION_CHECKLIST.md](REGRESSION_CHECKLIST.md) 기준으로 수행
|
||||||
6. 검증 완료 후 공개용에 코드 승격
|
6. 검증 완료 후 공개용에 코드 승격
|
||||||
|
|
||||||
## 다음 액션
|
## 다음 액션
|
||||||
@@ -167,14 +167,14 @@
|
|||||||
|
|
||||||
반복 가능한 동기화 스크립트:
|
반복 가능한 동기화 스크립트:
|
||||||
|
|
||||||
- [sync_prod_db_to_dev.sh](/home/hyunho/projects/mh-dashboard-organization/scripts/sync_prod_db_to_dev.sh)
|
- [sync_prod_db_to_dev.sh](../scripts/sync_prod_db_to_dev.sh)
|
||||||
- [docker-compose.8081.yml](/home/hyunho/projects/mh-dashboard-organization/docker-compose.8081.yml)
|
- [docker-compose.8081.yml](../docker-compose.8081.yml)
|
||||||
|
|
||||||
사용 방법:
|
사용 방법:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./scripts/prepare_dev_worktree.sh
|
./scripts/prepare_dev_worktree.sh
|
||||||
cd /home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081
|
cd <repo>/.dev-worktree-8081
|
||||||
docker compose -p mh-dashboard-organization-dev --env-file .env -f docker-compose.8081.yml up -d --build
|
docker compose -p mh-dashboard-organization-dev --env-file .env -f docker-compose.8081.yml up -d --build
|
||||||
./scripts/sync_prod_db_to_dev.sh minimal
|
./scripts/sync_prod_db_to_dev.sh minimal
|
||||||
./scripts/sync_prod_db_to_dev.sh full
|
./scripts/sync_prod_db_to_dev.sh full
|
||||||
@@ -194,8 +194,8 @@ docker compose -p mh-dashboard-organization-dev --env-file .env -f docker-compos
|
|||||||
중요:
|
중요:
|
||||||
|
|
||||||
- `8081`은 현재 메인 workspace를 직접 마운트하면 안 된다
|
- `8081`은 현재 메인 workspace를 직접 마운트하면 안 된다
|
||||||
- 컨테이너가 `/home/hyunho/projects/mh-dashboard-organization/...`를 물고 있으면 분리 상태가 깨진 것이다
|
- 컨테이너가 메인 workspace를 직접 물고 있으면 분리 상태가 깨진 것이다
|
||||||
- 정상 상태는 `docker inspect mh-dashboard-organization-dev-backend-1` 기준 마운트 소스가 `/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/...`로 나와야 한다
|
- 정상 상태는 `docker inspect mh-dashboard-organization-dev-backend-1` 기준 마운트 소스가 격리 worktree 경로로 나와야 한다
|
||||||
|
|
||||||
규칙:
|
규칙:
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
- 2026-03-27 기준 `curl http://localhost:8080/api/health` 정상
|
- 2026-03-27 기준 `curl http://localhost:8080/api/health` 정상
|
||||||
- 2026-03-27 기준 `curl http://localhost:8080/api/members` 에서 `items` 비어 있지 않음
|
- 2026-03-27 기준 `curl http://localhost:8080/api/members` 에서 `items` 비어 있지 않음
|
||||||
- 다른 PC 접속도 현재 확인됨
|
- 다른 PC 접속도 현재 확인됨
|
||||||
- 개발/운영 DB 분리 운영 원칙은 [DEV_PROD_DB_PROTOCOL.md](/home/hyunho/projects/mh-dashboard-organization/docs/DEV_PROD_DB_PROTOCOL.md) 기준으로 관리
|
- 개발/운영 DB 분리 운영 원칙은 [DEV_PROD_DB_PROTOCOL.md](DEV_PROD_DB_PROTOCOL.md) 기준으로 관리
|
||||||
|
|
||||||
## 1. 컨테이너 기동
|
## 1. 컨테이너 기동
|
||||||
- `docker compose build`
|
- `docker compose build`
|
||||||
|
|||||||
@@ -1,153 +0,0 @@
|
|||||||
# Next Session Checkpoint
|
|
||||||
|
|
||||||
## Current Base
|
|
||||||
|
|
||||||
- `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. 브랜치 기준 확인
|
|
||||||
2. 열린 이슈 확인
|
|
||||||
3. [WORK_RULEBOOK.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/WORK_RULEBOOK.md) 확인
|
|
||||||
4. 이 문서 확인
|
|
||||||
5. `git status`, 변경 파일, 미추적 파일 확인
|
|
||||||
|
|
||||||
주의:
|
|
||||||
|
|
||||||
- `8080` 기준 코드는 직접 수정하지 않는다.
|
|
||||||
- 새 작업은 항상 `.dev-worktree-8081`에서 진행한다.
|
|
||||||
- 커밋과 푸시는 사용자 지시가 있을 때만 수행한다.
|
|
||||||
|
|
||||||
## Confirmed Runtime Rule
|
|
||||||
|
|
||||||
- `8080`은 루트 workspace의 `total` 기준으로 유지한다.
|
|
||||||
- `8081`은 `.dev-worktree-8081` + `work-8081` 기준으로만 수정한다.
|
|
||||||
- `main`, `hyunho`는 보류 브랜치이며 현재 작업에 사용하지 않는다.
|
|
||||||
- `8081` 변경을 `8080`에 올릴 때는 reviewed file diff 기준으로만 반영한다.
|
|
||||||
- `8081` DB는 운영 정본이 아니라 `8080` 기준 검증용 복제본처럼 다룬다.
|
|
||||||
|
|
||||||
## What Was Stabilized
|
|
||||||
|
|
||||||
### Branch / Worktree Safety
|
|
||||||
|
|
||||||
- 기존 `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는 그대로 두고 분리 운영
|
|
||||||
|
|
||||||
### 8081 Design / Serving Baseline
|
|
||||||
|
|
||||||
- 디자인 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) 기준으로 본다.
|
|
||||||
|
|
||||||
디자인 수정 우선순위:
|
|
||||||
|
|
||||||
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. 화면별 실제 서빙 파일
|
|
||||||
|
|
||||||
주의:
|
|
||||||
|
|
||||||
- `incoming-files/sample style.css`는 참고 기준이지만 직접 런타임 수정 파일이 아니다.
|
|
||||||
- `incoming-files` 원본/reference 파일을 먼저 고치지 않는다.
|
|
||||||
- 새 디자인 수정은 먼저 토큰/패턴 파일에서 해결 가능한지 확인한 뒤, 불가피할 때만 화면별 파일에 내린다.
|
|
||||||
|
|
||||||
### 1차 구조 정리 진행분
|
|
||||||
|
|
||||||
- 이슈 기준:
|
|
||||||
- `#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/*`를 보도록 정리 시작
|
|
||||||
|
|
||||||
## 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. `#21` 이후 기준으로 실제 서비스 파일과 reference 파일 경계를 유지
|
|
||||||
2. 사업관리대장 세부 데이터 정합성 보정
|
|
||||||
3. 그 다음 화면별 앱 구조 승격 검토
|
|
||||||
4. 필요 시 `#19`, `#20` 잔여 정리 항목 재평가
|
|
||||||
|
|
||||||
## Quick Resume Prompt
|
|
||||||
|
|
||||||
다음 세션 시작 시 아래 기준으로 이어가면 된다.
|
|
||||||
|
|
||||||
- `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`를 먼저 확인
|
|
||||||
@@ -15,8 +15,8 @@
|
|||||||
|
|
||||||
관련 문서:
|
관련 문서:
|
||||||
|
|
||||||
- [DEV_PROD_DB_PROTOCOL.md](/home/hyunho/projects/mh-dashboard-organization/docs/DEV_PROD_DB_PROTOCOL.md)
|
- [DEV_PROD_DB_PROTOCOL.md](DEV_PROD_DB_PROTOCOL.md)
|
||||||
- [INFRA_VALIDATION_CHECKLIST.md](/home/hyunho/projects/mh-dashboard-organization/docs/INFRA_VALIDATION_CHECKLIST.md)
|
- [INFRA_VALIDATION_CHECKLIST.md](INFRA_VALIDATION_CHECKLIST.md)
|
||||||
|
|
||||||
## 작업 시작 전
|
## 작업 시작 전
|
||||||
|
|
||||||
@@ -27,6 +27,7 @@
|
|||||||
- `docker compose ps`에서 `backend`, `frontend`, `proxy`, `db`가 정상인지 확인
|
- `docker compose ps`에서 `backend`, `frontend`, `proxy`, `db`가 정상인지 확인
|
||||||
- `8081`은 기본적으로 `./scripts/start_8081.sh` 또는 `./scripts/prepare_dev_worktree.sh` 후 `.dev-worktree-8081` 에서 `docker compose -p mh-dashboard-organization-dev --env-file .env -f docker-compose.8081.yml up -d --build` 로 기동
|
- `8081`은 기본적으로 `./scripts/start_8081.sh` 또는 `./scripts/prepare_dev_worktree.sh` 후 `.dev-worktree-8081` 에서 `docker compose -p mh-dashboard-organization-dev --env-file .env -f docker-compose.8081.yml up -d --build` 로 기동
|
||||||
- `8081` 기동 후 `docker inspect mh-dashboard-organization-dev-backend-1`에서 마운트 경로가 `.dev-worktree-8081/...`인지 확인
|
- `8081` 기동 후 `docker inspect mh-dashboard-organization-dev-backend-1`에서 마운트 경로가 `.dev-worktree-8081/...`인지 확인
|
||||||
|
- 구조 정리, 라우트 분리, 기본 원본 API 변경 후에는 먼저 `./scripts/check_8081_smoke.sh` 를 실행한다
|
||||||
|
|
||||||
### 2. 데이터 동기화 범위 결정
|
### 2. 데이터 동기화 범위 결정
|
||||||
|
|
||||||
@@ -52,6 +53,7 @@
|
|||||||
- 메인 허브가 정상 렌더링된다.
|
- 메인 허브가 정상 렌더링된다.
|
||||||
- 상단 탭 이동이 정상 동작한다.
|
- 상단 탭 이동이 정상 동작한다.
|
||||||
- 로그인 상태가 비정상적으로 풀리지 않는다.
|
- 로그인 상태가 비정상적으로 풀리지 않는다.
|
||||||
|
- `DB 상태` 탭이 정상 렌더링된다.
|
||||||
|
|
||||||
### B. 조직현황
|
### B. 조직현황
|
||||||
|
|
||||||
@@ -100,6 +102,12 @@
|
|||||||
- `GPD`, `TDC` 선택 시 각 소속 범위만 버튼 기준으로 보인다.
|
- `GPD`, `TDC` 선택 시 각 소속 범위만 버튼 기준으로 보인다.
|
||||||
- 검색은 버튼 상태와 무관하게 전체 데이터를 검색한다.
|
- 검색은 버튼 상태와 무관하게 전체 데이터를 검색한다.
|
||||||
|
|
||||||
|
### G. 사업관리대장 기본 원본
|
||||||
|
|
||||||
|
- `/api/integration/business-ledger-default` 가 `200` 이어야 한다.
|
||||||
|
- `사업관리대장` 탭 진입 시 기본 원본이 비어 있지 않다.
|
||||||
|
- 기본 원본 응답은 바이너리 XLSX 시그니처(`PK`)를 반환해야 한다.
|
||||||
|
|
||||||
## 작업 유형별 필수 추가 확인
|
## 작업 유형별 필수 추가 확인
|
||||||
|
|
||||||
### 조직도 / 관리자모드 수정 시
|
### 조직도 / 관리자모드 수정 시
|
||||||
|
|||||||
159
docs/TEAM_GUIDE.md
Normal file
159
docs/TEAM_GUIDE.md
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
# Team Guide
|
||||||
|
|
||||||
|
이 문서는 이 저장소에서 작업할 때 가장 먼저 읽는 기준 문서다.
|
||||||
|
|
||||||
|
목표는 세 가지다.
|
||||||
|
|
||||||
|
- 어디를 수정해야 하는지 바로 알 수 있게 하기
|
||||||
|
- `8080`과 `8081`을 헷갈리지 않게 하기
|
||||||
|
- 작업 순서와 검증 기준을 하나의 문서로 시작하게 하기
|
||||||
|
|
||||||
|
## 1. 먼저 이해할 구조
|
||||||
|
|
||||||
|
- `frontend/apps/*`
|
||||||
|
- 화면별 source-of-truth
|
||||||
|
- `incoming-files/served/*`
|
||||||
|
- integration 화면의 실제 런타임 파일
|
||||||
|
- `legacy/static/*`
|
||||||
|
- 조직현황 레거시 런타임 파일
|
||||||
|
- `incoming-files/reference/*`
|
||||||
|
- 원본 참고 자산
|
||||||
|
- `backend/app/routes/*`
|
||||||
|
- API 엔드포인트 등록
|
||||||
|
- `backend/app/services/*`
|
||||||
|
- 비즈니스 로직
|
||||||
|
- `backend/app/repositories/*`
|
||||||
|
- DB 읽기 쿼리
|
||||||
|
|
||||||
|
원칙:
|
||||||
|
|
||||||
|
- source를 먼저 수정하고 runtime은 publish로 반영한다.
|
||||||
|
- reference 파일은 비교/복구용이지 직접 수정 기준이 아니다.
|
||||||
|
|
||||||
|
## 2. 환경 원칙
|
||||||
|
|
||||||
|
- `8080`
|
||||||
|
- 공개 기준 환경
|
||||||
|
- 기준 데이터가 있는 쪽
|
||||||
|
- `8081`
|
||||||
|
- 개발/검증 환경
|
||||||
|
- 먼저 수정하고 검증하는 쪽
|
||||||
|
|
||||||
|
중요:
|
||||||
|
|
||||||
|
- 새 작업은 항상 `.dev-worktree-8081` 기준으로 시작한다.
|
||||||
|
- `8081`에서 검증되지 않은 변경을 바로 `8080`에 올리지 않는다.
|
||||||
|
- `8081` DB는 독립 정본이 아니라 검증용 복제본처럼 다룬다.
|
||||||
|
|
||||||
|
자세한 DB 운영 원칙은 [DEV_PROD_DB_PROTOCOL.md](DEV_PROD_DB_PROTOCOL.md)를 따른다.
|
||||||
|
|
||||||
|
## 3. 작업 시작 순서
|
||||||
|
|
||||||
|
1. 현재 브랜치와 변경 파일을 확인한다.
|
||||||
|
2. 연결된 이슈 또는 작업 목적을 확인한다.
|
||||||
|
3. 이번 작업의 source 파일과 runtime 파일을 구분한다.
|
||||||
|
4. 필요한 경우 `8081` 개발 환경을 띄운다.
|
||||||
|
5. 필요한 DB 동기화 범위를 결정한다.
|
||||||
|
6. 수정 후 관련 시나리오를 검증한다.
|
||||||
|
|
||||||
|
핵심 질문:
|
||||||
|
|
||||||
|
- 지금 고치는 파일이 실제 source-of-truth가 맞는가?
|
||||||
|
- 이 작업은 `8081`에서 먼저 검증해야 하는가?
|
||||||
|
- DB 차이 때문에 생긴 문제는 아닌가?
|
||||||
|
|
||||||
|
## 4. 수정 원칙
|
||||||
|
|
||||||
|
- 한 작업은 한 기능 또는 한 버그 단위로 작게 나눈다.
|
||||||
|
- 완료 기능은 관련 이슈 없이 함부로 건드리지 않는다.
|
||||||
|
- 임시 우회 로직은 이유와 제거 계획이 있어야 한다.
|
||||||
|
- 구조를 정리하더라도 기존 동작을 바꾸면 안 된다.
|
||||||
|
|
||||||
|
고위험 영역:
|
||||||
|
|
||||||
|
- `members`
|
||||||
|
- `seat_maps`
|
||||||
|
- `seat_slots`
|
||||||
|
- `seat_positions`
|
||||||
|
- `auth.*`
|
||||||
|
- 동기화 스크립트
|
||||||
|
- 스키마 변경
|
||||||
|
|
||||||
|
이 영역은 변경 이유, 영향 범위, 검증 결과를 반드시 남긴다.
|
||||||
|
|
||||||
|
## 5. 화면별 수정 기준
|
||||||
|
|
||||||
|
### 조직현황
|
||||||
|
|
||||||
|
- source: `frontend/apps/organization`
|
||||||
|
- runtime: `DashBoard-organization.html`, `legacy/static/*`
|
||||||
|
- publish: `./scripts/publish_organization_app.sh`
|
||||||
|
|
||||||
|
### 프로젝트별 분석
|
||||||
|
|
||||||
|
- source: `frontend/apps/payment/index.html`
|
||||||
|
- runtime: `incoming-files/served/payment.html`
|
||||||
|
- publish: `./scripts/publish_payment_app.sh`
|
||||||
|
|
||||||
|
### 팀/개인별 분석
|
||||||
|
|
||||||
|
- source: `frontend/apps/team/index.html`
|
||||||
|
- runtime: `incoming-files/served/mh.html`
|
||||||
|
- publish: `./scripts/publish_team_app.sh`
|
||||||
|
|
||||||
|
### 사업관리대장
|
||||||
|
|
||||||
|
- source: `frontend/apps/ledger/*`
|
||||||
|
- runtime: `incoming-files/served/ledger/*`
|
||||||
|
- publish: `./scripts/publish_ledger_app.sh`
|
||||||
|
|
||||||
|
### DB 상태
|
||||||
|
|
||||||
|
- source: `frontend/apps/db-status/index.html`
|
||||||
|
- runtime: `incoming-files/served/db-status/index.html`
|
||||||
|
- publish: `./scripts/publish_db_status_app.sh`
|
||||||
|
|
||||||
|
실제 서빙 책임은 [architecture/8081_SERVING_MAP.md](architecture/8081_SERVING_MAP.md)에서 확인한다.
|
||||||
|
|
||||||
|
## 6. 디자인 수정 원칙
|
||||||
|
|
||||||
|
- 먼저 `frontend/public/design-tokens.css`
|
||||||
|
- 다음 `frontend/public/design-patterns.css`
|
||||||
|
- 그 다음 [architecture/DESIGN_SSOT.md](architecture/DESIGN_SSOT.md)
|
||||||
|
- 마지막으로 화면별 파일
|
||||||
|
|
||||||
|
금지:
|
||||||
|
|
||||||
|
- reference 파일부터 수정하기
|
||||||
|
- 토큰/패턴으로 해결 가능한 것을 화면별 하드코딩으로 처리하기
|
||||||
|
- 예전 색 체계를 새 기본값으로 다시 넣기
|
||||||
|
|
||||||
|
## 7. 검증 원칙
|
||||||
|
|
||||||
|
- 완료 기준은 “코드를 썼다”가 아니라 “실제 동작을 검증했다”이다.
|
||||||
|
- 구조 정리나 라우트 분리 후에는 `./scripts/check_8081_smoke.sh`를 먼저 본다.
|
||||||
|
- 기능 수정 후에는 관련 화면만 보지 말고 주변 연동까지 확인한다.
|
||||||
|
|
||||||
|
검증 세부 항목은 [REGRESSION_CHECKLIST.md](REGRESSION_CHECKLIST.md)를 따른다.
|
||||||
|
|
||||||
|
자주 쓰는 DB 동기화:
|
||||||
|
|
||||||
|
- 조직현황/멤버/자리배치: `./scripts/sync_prod_db_to_dev.sh minimal`
|
||||||
|
- 분석 화면: `./scripts/sync_prod_db_to_dev.sh analysis`
|
||||||
|
- 전체 재검증: `./scripts/sync_prod_db_to_dev.sh full`
|
||||||
|
|
||||||
|
## 8. 커밋과 PR
|
||||||
|
|
||||||
|
- 커밋은 한 주제만 담는다.
|
||||||
|
- PR 본문에는 작업 목적, 변경 범위, 검증 방법, DB 영향 여부를 적는다.
|
||||||
|
- 공용 구조 파일을 수정했으면 영향 화면을 명시한다.
|
||||||
|
|
||||||
|
자세한 팀 작업 규칙은 [../CONTRIBUTING.md](../CONTRIBUTING.md)를 따른다.
|
||||||
|
|
||||||
|
## 9. 이 문서 다음에 읽을 것
|
||||||
|
|
||||||
|
- 협업 방식: [../CONTRIBUTING.md](../CONTRIBUTING.md)
|
||||||
|
- DB 운영 원칙: [DEV_PROD_DB_PROTOCOL.md](DEV_PROD_DB_PROTOCOL.md)
|
||||||
|
- 회귀 검증: [REGRESSION_CHECKLIST.md](REGRESSION_CHECKLIST.md)
|
||||||
|
- 실제 서빙 책임: [architecture/8081_SERVING_MAP.md](architecture/8081_SERVING_MAP.md)
|
||||||
|
- 디자인 기준: [architecture/DESIGN_SSOT.md](architecture/DESIGN_SSOT.md)
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
# Today Work Prep - 2026-03-30
|
|
||||||
|
|
||||||
## Current Local State
|
|
||||||
|
|
||||||
- working branch: `total`
|
|
||||||
- HEAD: `24852d4` (`Fix seatmap slot matching and update member modal layout`)
|
|
||||||
- remote tracking: `origin/total`
|
|
||||||
- status: local branch is `ahead 2`
|
|
||||||
- open PRs: none
|
|
||||||
|
|
||||||
untracked files:
|
|
||||||
|
|
||||||
- `docs/HISTORY_ASOF_DB_PLAN.md`
|
|
||||||
- `incoming-files/6f.html`
|
|
||||||
- `incoming-files/7f.html`
|
|
||||||
- `incoming-files/center.html`
|
|
||||||
|
|
||||||
주의:
|
|
||||||
|
|
||||||
- `docs/NEXT_SESSION_CHECKPOINT.md` 의 최신 checked commit 은 아직 `1d15cf9` 로 남아 있다.
|
|
||||||
- 실제 최신 작업 판단은 아래 최근 2개 로컬 커밋 기준으로 보는 것이 맞다.
|
|
||||||
|
|
||||||
## What Was Added After `origin/total`
|
|
||||||
|
|
||||||
### Commit `d666141`
|
|
||||||
|
|
||||||
- 3개 고정 오피스 자리배치도 반영
|
|
||||||
- `technical-development-center`
|
|
||||||
- `hanmac-building-6f`
|
|
||||||
- `hanmac-building-7f`
|
|
||||||
- 백엔드 `office_key` 기반 active viewer/layout 조회 지원
|
|
||||||
- 프런트 자리배치도 탭에서 3개 오피스 선택 지원
|
|
||||||
- `scripts/sync_prod_db_to_dev.sh` 추가
|
|
||||||
- `docs/DEV_PROD_DB_PROTOCOL.md` 추가
|
|
||||||
|
|
||||||
### Commit `24852d4`
|
|
||||||
|
|
||||||
- slot 기반 자리 저장 시 slot matching 보정
|
|
||||||
- 멤버 상세 모달 / 조직도 seat preview 레이아웃 조정
|
|
||||||
- 회귀 점검용 `docs/REGRESSION_CHECKLIST.md` 추가
|
|
||||||
- dev/prod sync script 후속 보정
|
|
||||||
|
|
||||||
## Remote Branch / Issue Snapshot
|
|
||||||
|
|
||||||
remote branches:
|
|
||||||
|
|
||||||
- `total` -> `1d15cf9`
|
|
||||||
- `hyunho` -> `8efb5da`
|
|
||||||
- `main` -> `7a0bd54`
|
|
||||||
|
|
||||||
open issues:
|
|
||||||
|
|
||||||
- `#11` `[P0] [버그] 자리배치도 회귀 오류`
|
|
||||||
- `#12` `[P1] [DB] 공개용/작업용 seat_positions 스키마 불일치 정리`
|
|
||||||
- `#13` `[P1] [인프라] 작업용 DB 동기화 절차 안정화 및 자동화`
|
|
||||||
- `#14` `[P2] [리팩터링] 누적된 임시 로직 정리 및 중복 코드 제거`
|
|
||||||
- `#10` `[P1] [분석] 1~2월 원본 정합성 보정 및 팀/개인별 검색 범위 개선 작업 정리`
|
|
||||||
- `#9` `[P1] [이력관리] as-of date / 버전 누적 저장`
|
|
||||||
- `#8` `[P2] [자리배치도] 좌석 클릭 시 개인 상위 조직 트리 표시`
|
|
||||||
- `#7` `[P2] [자리배치도] 팀별 색상 오버레이 표시`
|
|
||||||
- `#5` `[P2] [인증] 권한 제어 마무리 및 mock login 정리`
|
|
||||||
- `#3` `[P1] [기능] 사무실 좌석 배치도 조회 및 관리자 편집 기능 고도화`
|
|
||||||
- `#2` `[P0] [인프라] 백엔드 영속 저장 구조 운영 마무리`
|
|
||||||
|
|
||||||
현재 관계 해석:
|
|
||||||
|
|
||||||
- `#11` 은 최근 2개 커밋이 직접 겨냥한 회귀 묶음이다.
|
|
||||||
- `#12`, `#13` 은 `#11` 재발 방지용 운영 과제에 가깝다.
|
|
||||||
- `#3` 은 다중 오피스 도면 반영으로 많이 진척됐지만, 공개용 기준 회귀 검증 전에는 완료 처리하면 안 된다.
|
|
||||||
- `#2` 는 단순 구현보다 dev/prod 데이터 운영 기준 정리가 핵심으로 바뀌었다.
|
|
||||||
- `#5` 는 로그인 구현보다 권한 경계와 `/api/mock-login` 정리가 남은 상태다.
|
|
||||||
|
|
||||||
## Best Starting Point Today
|
|
||||||
|
|
||||||
오늘 첫 작업은 새 기능 추가보다, 최근 자리배치도/DB 동기화 작업을 검증 가능한 상태로 굳히는 쪽이 우선이다.
|
|
||||||
|
|
||||||
우선순위:
|
|
||||||
|
|
||||||
1. `#13` 프로토콜대로 작업용 DB를 `minimal` 범위로 동기화
|
|
||||||
2. `docs/REGRESSION_CHECKLIST.md` 기준으로 자리배치도 회귀 확인
|
|
||||||
3. 최근 2개 로컬 커밋을 `origin/total` 에 올릴지 결정
|
|
||||||
4. 회귀가 남아 있으면 `#11` 계속, 없으면 `#5` 또는 `#12/#13` 후속 정리로 이동
|
|
||||||
|
|
||||||
이 순서가 맞는 이유:
|
|
||||||
|
|
||||||
- 현재 가장 최근 변경이 seatmap + DB sync 쪽에 몰려 있다.
|
|
||||||
- 원격 `total` 은 아직 해당 수정들을 포함하지 않는다.
|
|
||||||
- 검증 없이 다른 기능으로 넘어가면 회귀 원인과 신규 작업이 다시 섞인다.
|
|
||||||
|
|
||||||
## Concrete Start Checklist
|
|
||||||
|
|
||||||
세션 시작 즉시:
|
|
||||||
|
|
||||||
1. `docs/DEV_PROD_DB_PROTOCOL.md` 다시 확인
|
|
||||||
2. 필요 시 `./scripts/sync_prod_db_to_dev.sh minimal`
|
|
||||||
3. 로그인 상태 확인
|
|
||||||
4. 아래 3개를 오피스별로 확인
|
|
||||||
- 관리자 DnD 배치 저장
|
|
||||||
- 조직도 상세 seat preview
|
|
||||||
- 비관리자 seatmap 진입 / 표시
|
|
||||||
|
|
||||||
필수 확인 오피스:
|
|
||||||
|
|
||||||
- `기술개발센터`
|
|
||||||
- `한맥빌딩 6층`
|
|
||||||
- `한맥빌딩 7층`
|
|
||||||
|
|
||||||
## Recommended Decision Tree
|
|
||||||
|
|
||||||
### Case A. 회귀가 남아 있음
|
|
||||||
|
|
||||||
- 바로 `#11` 우선
|
|
||||||
- 동시에 원인 범주를 분리
|
|
||||||
- DB sync 실패
|
|
||||||
- `seat_positions` 스키마 차이
|
|
||||||
- 프런트 fallback 오류
|
|
||||||
- 저장 API 로직 오류
|
|
||||||
|
|
||||||
### Case B. 회귀가 해소됨
|
|
||||||
|
|
||||||
- 최근 2개 커밋 푸시
|
|
||||||
- Gitea `#11`, `#3`, `#2` 코멘트 상태 업데이트
|
|
||||||
- 다음 메인 작업을 아래 중 하나로 선택
|
|
||||||
- `#5` 권한 제어 / mock login 제거
|
|
||||||
- `#12`, `#13` DB sync 안정화 마무리
|
|
||||||
- `#9` history / as-of 구조 착수
|
|
||||||
|
|
||||||
## Suggested Main Task After Verification
|
|
||||||
|
|
||||||
가장 자연스러운 다음 메인 작업은 `#5` 보다 `#12`, `#13` 마무리다.
|
|
||||||
|
|
||||||
이유:
|
|
||||||
|
|
||||||
- 지금 이 코드베이스에서 자리배치도/조직도 검증은 DB 상태에 크게 좌우된다.
|
|
||||||
- 권한 작업을 시작해도 검증 기반이 흔들리면 다시 혼선이 생긴다.
|
|
||||||
- 반대로 sync 절차와 스키마 호환을 먼저 고정하면 이후 `#5`, `#9`, `#8`, `#7` 진행이 쉬워진다.
|
|
||||||
|
|
||||||
## Short Summary
|
|
||||||
|
|
||||||
- 코드 최신 상태는 로컬 `total@24852d4`
|
|
||||||
- 원격 `total` 은 아직 최신 seatmap/sync 수정 전 상태
|
|
||||||
- 오늘 첫 목표는 `#11` 관련 회귀 검증과 `#12/#13` 기반 정리
|
|
||||||
- 검증 완료 전에는 새 기능보다 seatmap + DB 운영 안정화를 우선하는 것이 맞다
|
|
||||||
@@ -1,269 +0,0 @@
|
|||||||
# Work Execution Flow
|
|
||||||
|
|
||||||
## 목적
|
|
||||||
|
|
||||||
이 문서는 앞으로 이 프로젝트에서 작업을 어떤 순서로 진행해야 하는지 아주 쉽게 고정하기 위한 문서다.
|
|
||||||
|
|
||||||
세미나에서 들은 흐름을 이 프로젝트 기준으로 다시 쓰면 아래 순서다.
|
|
||||||
|
|
||||||
1. `SSOT` 먼저 확인
|
|
||||||
2. 이슈 생성 또는 연결
|
|
||||||
3. 완료조건 먼저 적기
|
|
||||||
4. 실행 계획 적기
|
|
||||||
5. 필요한 동기화 먼저 하기
|
|
||||||
6. 코드 수정 / 화면 작업 수행
|
|
||||||
7. 가드레일 테스트
|
|
||||||
8. 기록 남기기
|
|
||||||
|
|
||||||
이 순서를 지키는 이유는 하나다.
|
|
||||||
|
|
||||||
- 작업 도중 기준이 바뀌지 않게 하기
|
|
||||||
- 임시 연결이 누적되지 않게 하기
|
|
||||||
- 나중에 봐도 왜 이렇게 했는지 알 수 있게 하기
|
|
||||||
- `8081` 작업이 `8080`을 망가뜨리지 않게 하기
|
|
||||||
|
|
||||||
## 1. SSOT 먼저 확인
|
|
||||||
|
|
||||||
`SSOT`는 Single Source Of Truth 의 줄임말이다.
|
|
||||||
|
|
||||||
쉬운 말로:
|
|
||||||
|
|
||||||
- "무엇을 기준 진실로 볼 것인가"
|
|
||||||
|
|
||||||
이걸 먼저 정하지 않으면 작업 중간에 기준이 계속 바뀌어서 코드가 꼬인다.
|
|
||||||
|
|
||||||
이 프로젝트에서 자주 쓰는 SSOT:
|
|
||||||
|
|
||||||
- 공개용 코드 기준: `/home/hyunho/projects/mh-dashboard-organization`
|
|
||||||
- 작업용 코드 기준: `/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081`
|
|
||||||
- 데이터 정본 기준: `8080` DB
|
|
||||||
- 기능 검증 기준: `8081`
|
|
||||||
- 사업관리대장 디자인 기준: `MH 통합 대시보드_260320.html`
|
|
||||||
- 허브 공통 시각 언어 기준: `sample style.css`
|
|
||||||
- 런타임 디자인 토큰 기준: `frontend/public/design-tokens.css`
|
|
||||||
- 런타임 디자인 패턴 기준: `frontend/public/design-patterns.css`
|
|
||||||
- 현재 작업 지시 기준: 연결된 Gitea 이슈
|
|
||||||
|
|
||||||
작업 시작 전에 먼저 정해야 하는 질문:
|
|
||||||
|
|
||||||
- 이번 작업의 코드 기준은 어디인가?
|
|
||||||
- 이번 작업의 데이터 기준은 어디인가?
|
|
||||||
- 이번 화면의 디자인 기준 파일은 무엇인가?
|
|
||||||
- 지금 바꾸려는 화면이 실제로 어떤 파일에서 렌더링되는가?
|
|
||||||
|
|
||||||
이걸 모르고 코드를 건드리면 높은 확률로 엉뚱한 파일을 수정하게 된다.
|
|
||||||
|
|
||||||
디자인 작업 추가 규칙:
|
|
||||||
|
|
||||||
- 디자인 수정은 항상 `design-tokens.css`와 `design-patterns.css`를 먼저 확인한다.
|
|
||||||
- 색/패널/버튼/테이블/팝업이 공통 규칙으로 해결 가능한지 먼저 본다.
|
|
||||||
- 해결 가능하면 화면별 파일을 고치지 않고 토큰/패턴 파일에서 수정한다.
|
|
||||||
- 화면별 실제 서빙 파일은 마지막 단계에서만 조정한다.
|
|
||||||
- 원본/reference 파일은 비교용이지 직접 수정 우선 대상이 아니다.
|
|
||||||
|
|
||||||
## 2. 이슈 생성 또는 연결
|
|
||||||
|
|
||||||
작업은 이슈 없이 하지 않는다.
|
|
||||||
|
|
||||||
이유:
|
|
||||||
|
|
||||||
- 왜 하는 작업인지 남기기 위해
|
|
||||||
- 중간에 범위가 커지는 걸 막기 위해
|
|
||||||
- 다음 세션에서 바로 이어가기 위해
|
|
||||||
|
|
||||||
좋은 이슈는 아래 4개가 있어야 한다.
|
|
||||||
|
|
||||||
1. 배경
|
|
||||||
2. 목표
|
|
||||||
3. 현재 상태
|
|
||||||
4. 남은 작업
|
|
||||||
|
|
||||||
이슈는 길게 쓸 필요는 없다.
|
|
||||||
하지만 최소한 아래는 있어야 한다.
|
|
||||||
|
|
||||||
- 왜 이 작업을 하는지
|
|
||||||
- 어디까지가 이번 범위인지
|
|
||||||
- 무엇을 완료로 볼지
|
|
||||||
|
|
||||||
## 3. 완료조건 먼저 적기
|
|
||||||
|
|
||||||
이 단계가 중요하다.
|
|
||||||
|
|
||||||
완료조건이 없으면 "대충 된 것 같음" 상태에서 끝나기 쉽다.
|
|
||||||
|
|
||||||
좋은 완료조건 예시:
|
|
||||||
|
|
||||||
- `8081`이 `.dev-worktree-8081`를 실제로 마운트한다
|
|
||||||
- `사업관리대장` 탭이 원본 기준 레이아웃으로 열린다
|
|
||||||
- `8080`은 영향 없이 유지된다
|
|
||||||
- 관련 회귀 검증을 통과한다
|
|
||||||
|
|
||||||
나쁜 완료조건 예시:
|
|
||||||
|
|
||||||
- 화면이 좀 괜찮아 보인다
|
|
||||||
- 아마 될 것 같다
|
|
||||||
- 코드 정리함
|
|
||||||
|
|
||||||
완료조건은 반드시 확인 가능한 문장이어야 한다.
|
|
||||||
|
|
||||||
즉:
|
|
||||||
|
|
||||||
- "봤을 때 예쁨"이 아니라
|
|
||||||
- "어떤 URL에서 어떤 동작이 확인됨"이어야 한다
|
|
||||||
|
|
||||||
## 4. 실행 계획 적기
|
|
||||||
|
|
||||||
계획은 길 필요 없다.
|
|
||||||
|
|
||||||
이 프로젝트에서는 보통 아래 정도면 충분하다.
|
|
||||||
|
|
||||||
1. 기준 파일과 현재 연결 구조 확인
|
|
||||||
2. `8081` worktree 기준으로만 수정
|
|
||||||
3. 필요한 데이터 동기화
|
|
||||||
4. 화면/기능 수정
|
|
||||||
5. 회귀 검증
|
|
||||||
6. 이슈 코멘트와 체크포인트 기록
|
|
||||||
|
|
||||||
핵심은:
|
|
||||||
|
|
||||||
- 수정 전에 먼저 구조를 파악하고
|
|
||||||
- 범위를 정하고
|
|
||||||
- 검증까지 포함해서 끝내는 것
|
|
||||||
|
|
||||||
## 5. 실행 전 동기화
|
|
||||||
|
|
||||||
이 프로젝트는 코드만 맞아도 안 되고, 데이터도 맞아야 한다.
|
|
||||||
|
|
||||||
그래서 실행 전에 동기화가 필요할 수 있다.
|
|
||||||
|
|
||||||
무슨 뜻이냐면:
|
|
||||||
|
|
||||||
- `8081`에서 기능 확인을 하더라도
|
|
||||||
- 데이터가 `8080`과 다르면 검증 결과를 신뢰하면 안 된다
|
|
||||||
|
|
||||||
자주 쓰는 규칙:
|
|
||||||
|
|
||||||
- 조직도 / 멤버 / 자리배치 검증 전
|
|
||||||
- `./scripts/sync_prod_db_to_dev.sh minimal`
|
|
||||||
- 분석 화면까지 공개용 기준으로 맞춰야 할 때
|
|
||||||
- `./scripts/sync_prod_db_to_dev.sh full`
|
|
||||||
|
|
||||||
또 코드 동기화도 중요하다.
|
|
||||||
|
|
||||||
- `8081`은 메인 workspace에서 직접 띄우지 않는다
|
|
||||||
- 먼저 `./scripts/prepare_dev_worktree.sh`
|
|
||||||
- 그 다음 `.dev-worktree-8081`에서 실행
|
|
||||||
|
|
||||||
즉 이 프로젝트의 동기화는 두 종류다.
|
|
||||||
|
|
||||||
- DB 동기화
|
|
||||||
- 코드/worktree 동기화
|
|
||||||
|
|
||||||
## 6. 실제 실행
|
|
||||||
|
|
||||||
이 단계가 코드를 고치는 단계다.
|
|
||||||
|
|
||||||
하지만 여기서도 규칙이 있다.
|
|
||||||
|
|
||||||
- `8081`에서 먼저 작업
|
|
||||||
- 기준 파일이 아닌 곳은 건드리지 않기
|
|
||||||
- 임시 우회 연결을 만들었으면 반드시 기록 남기기
|
|
||||||
- 연결 구조가 난잡해지면 바로 이슈에 `코드 정리 필요`를 남기기
|
|
||||||
|
|
||||||
특히 이 프로젝트는 아래가 자주 꼬인다.
|
|
||||||
|
|
||||||
- `frontend/public`
|
|
||||||
- `legacy/static`
|
|
||||||
- `incoming-files`
|
|
||||||
- 정적 HTML
|
|
||||||
- iframe 연결
|
|
||||||
- 버전 쿼리스트링
|
|
||||||
|
|
||||||
그래서 실행 중 계속 확인해야 한다.
|
|
||||||
|
|
||||||
- 지금 내가 고친 파일이 실제 서빙 파일이 맞는가?
|
|
||||||
- 지금 수정이 `8081` 전용인가, `8080` 공통인가?
|
|
||||||
- 이 연결은 임시인가, 기준 구조인가?
|
|
||||||
|
|
||||||
## 7. 가드레일 테스트
|
|
||||||
|
|
||||||
가드레일 테스트는 쉬운 말로:
|
|
||||||
|
|
||||||
- "이 수정 때문에 같이 망가지면 안 되는 것들을 확인하는 테스트"
|
|
||||||
|
|
||||||
즉 핵심 기능만 보는 게 아니라, 같이 깨지기 쉬운 주변 기능까지 확인하는 것이다.
|
|
||||||
|
|
||||||
이 프로젝트에서 가드레일 테스트 예시:
|
|
||||||
|
|
||||||
- `8081` 디자인 수정 후
|
|
||||||
- `8080`은 그대로인지 확인
|
|
||||||
- 조직현황 수정 후
|
|
||||||
- 조직도 iframe, 모달, 리스트뷰, seat preview 확인
|
|
||||||
- 자리배치 수정 후
|
|
||||||
- 관리자 저장
|
|
||||||
- 비관리자 조회
|
|
||||||
- 조직도 상세 seat preview
|
|
||||||
- 분석 화면 수정 후
|
|
||||||
- 기간 필터
|
|
||||||
- 프로젝트/팀 전환
|
|
||||||
- 빈 데이터 상태
|
|
||||||
- 스타일 깨짐 여부
|
|
||||||
|
|
||||||
가드레일 테스트는 "다 테스트한다"가 아니다.
|
|
||||||
|
|
||||||
이번 수정 때문에 같이 깨질 가능성이 높은 것만 빠르게 확인하는 것이다.
|
|
||||||
|
|
||||||
## 8. 기록 남기기
|
|
||||||
|
|
||||||
작업은 기록까지 남겨야 끝난다.
|
|
||||||
|
|
||||||
남겨야 하는 것:
|
|
||||||
|
|
||||||
- 무엇을 바꿨는지
|
|
||||||
- 무엇을 기준으로 했는지
|
|
||||||
- 무엇을 검증했는지
|
|
||||||
- 무엇이 아직 안 끝났는지
|
|
||||||
- 다음에 어디서 이어야 하는지
|
|
||||||
|
|
||||||
남길 위치:
|
|
||||||
|
|
||||||
- Gitea 이슈 코멘트
|
|
||||||
- 체크포인트 문서
|
|
||||||
- 필요하면 룰북/프로토콜 문서
|
|
||||||
|
|
||||||
## 이 프로젝트용 한 줄 버전
|
|
||||||
|
|
||||||
앞으로는 아래 순서로 생각하면 된다.
|
|
||||||
|
|
||||||
1. 기준 진실부터 정한다
|
|
||||||
2. 이슈에 작업 목적과 완료조건을 적는다
|
|
||||||
3. 실행 전에 코드/DB 동기화를 맞춘다
|
|
||||||
4. `8081`에서만 수정한다
|
|
||||||
5. 같이 깨지면 안 되는 것까지 확인한다
|
|
||||||
6. 결과를 기록한다
|
|
||||||
|
|
||||||
## 시작할 때 바로 쓰는 짧은 템플릿
|
|
||||||
|
|
||||||
작업 시작 전에 아래 6줄만 적어도 된다.
|
|
||||||
|
|
||||||
- SSOT:
|
|
||||||
- 코드 기준:
|
|
||||||
- 데이터 기준:
|
|
||||||
- 디자인 기준:
|
|
||||||
- 이슈:
|
|
||||||
- 완료조건:
|
|
||||||
- 계획:
|
|
||||||
- 필요한 동기화:
|
|
||||||
- 가드레일 테스트:
|
|
||||||
|
|
||||||
예시:
|
|
||||||
|
|
||||||
- SSOT:
|
|
||||||
- 코드 기준: `/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081`
|
|
||||||
- 데이터 기준: `8080` DB를 sync한 `8081`
|
|
||||||
- 디자인 기준: `MH 통합 대시보드_260320.html`
|
|
||||||
- 이슈: `#16`
|
|
||||||
- 완료조건: `8081`에서 사업관리대장 메인이 원본 톤으로 열리고 `8080`은 안 바뀜
|
|
||||||
- 계획: 연결 확인 → worktree 수정 → 검증 → 이슈 기록
|
|
||||||
- 필요한 동기화: `minimal`
|
|
||||||
- 가드레일 테스트: `8080 유지`, `조직현황 탭`, `프로젝트/팀 탭`
|
|
||||||
@@ -1,285 +0,0 @@
|
|||||||
# Work Rulebook
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
이 문서는 이 프로젝트에서 매일 작업을 시작하고 마무리할 때 반드시 따를 운영 규칙을 고정하기 위한 룰북이다.
|
|
||||||
|
|
||||||
목표는 아래 4가지다.
|
|
||||||
|
|
||||||
- 완료된 기능의 회귀 방지
|
|
||||||
- 코드 문제와 DB 문제의 혼선 방지
|
|
||||||
- 작업 기록 누락 방지
|
|
||||||
- 매일 같은 기준으로 안정적으로 이어서 작업
|
|
||||||
|
|
||||||
## Rule 0. Morning Start Mandatory Check
|
|
||||||
|
|
||||||
이 규칙은 강제 규칙이다.
|
|
||||||
|
|
||||||
매일 아침 또는 그날의 첫 작업을 시작할 때는, 코드를 수정하기 전에 반드시 아래 순서를 먼저 수행한다.
|
|
||||||
|
|
||||||
1. Gitea 브랜치 상태 확인
|
|
||||||
2. 열린 이슈 확인
|
|
||||||
3. 이 문서 `WORK_RULEBOOK.md` 확인
|
|
||||||
4. 최신 체크포인트 문서 확인
|
|
||||||
5. 현재 워크트리의 미푸시 커밋, 변경 파일, 미추적 파일 확인
|
|
||||||
|
|
||||||
위 5단계를 확인하기 전에는 새 코드 작성, 기존 코드 수정, 임의 테스트 진행을 시작하지 않는다.
|
|
||||||
|
|
||||||
즉:
|
|
||||||
|
|
||||||
- "오늘 첫 작업"의 시작점은 코드 수정이 아니라 상태 확인이다.
|
|
||||||
- 이 절차를 건너뛰고 바로 수정 작업에 들어가는 것은 금지한다.
|
|
||||||
|
|
||||||
추가 기준:
|
|
||||||
|
|
||||||
- 실제 작업 순서는 [WORK_EXECUTION_FLOW.md](/home/hyunho/projects/mh-dashboard-organization/docs/WORK_EXECUTION_FLOW.md) 를 따른다.
|
|
||||||
- 특히 `SSOT → 이슈 → 완료조건 → 계획 → 동기화 → 실행 → 가드레일 테스트 → 기록` 순서를 기본 운영 흐름으로 본다.
|
|
||||||
|
|
||||||
## Rule 1. Completed Feature Protection
|
|
||||||
|
|
||||||
완료 판정된 작업물의 기능과 코드는 함부로 건드리지 않는다.
|
|
||||||
|
|
||||||
세부 규칙:
|
|
||||||
|
|
||||||
- 직접 관련된 이슈가 없으면 완료 기능을 수정하지 않는다.
|
|
||||||
- 완료 기능 수정이 필요하면 먼저 이유와 영향 범위를 이슈 또는 코멘트에 남긴다.
|
|
||||||
- 단순 편의상 구조를 바꾸거나 정리하는 리팩터링으로 완료 기능 동작을 바꾸지 않는다.
|
|
||||||
- 완료 기능을 수정한 경우에는 관련 회귀 검증까지 완료해야 한다.
|
|
||||||
|
|
||||||
핵심 원칙:
|
|
||||||
|
|
||||||
- "고치는 김에 같이 정리"를 금지한다.
|
|
||||||
- 수정 범위는 현재 작업 목적에 필요한 최소 범위로 제한한다.
|
|
||||||
|
|
||||||
## Rule 2. Work Must Be Tied To An Issue
|
|
||||||
|
|
||||||
원칙적으로 이슈 없는 작업은 하지 않는다.
|
|
||||||
|
|
||||||
세부 규칙:
|
|
||||||
|
|
||||||
- 모든 작업은 기존 이슈에 연결하거나 새 이슈/작업 메모를 만든 뒤 시작한다.
|
|
||||||
- 왜 하는 작업인지 한 줄로라도 남긴다.
|
|
||||||
- 임시 대응도 예외가 아니다.
|
|
||||||
|
|
||||||
## Rule 3. Branch And Workspace Awareness
|
|
||||||
|
|
||||||
작업 전에 현재 브랜치와 워크트리 상태를 먼저 확인한다.
|
|
||||||
|
|
||||||
반드시 확인할 항목:
|
|
||||||
|
|
||||||
- 현재 브랜치
|
|
||||||
- 원격 대비 ahead / behind 상태
|
|
||||||
- 미푸시 커밋
|
|
||||||
- 수정된 파일
|
|
||||||
- 미추적 파일
|
|
||||||
|
|
||||||
금지:
|
|
||||||
|
|
||||||
- 로컬에서만 있는 상태를 기준 진실처럼 가정하기
|
|
||||||
- 미정리 변경사항을 모른 채 새 작업을 덧붙이기
|
|
||||||
|
|
||||||
## Rule 4. DB Before Code Assumption
|
|
||||||
|
|
||||||
조직도, 멤버, 자리배치도, 권한 문제는 코드보다 DB 상태 영향을 먼저 의심한다.
|
|
||||||
|
|
||||||
세부 규칙:
|
|
||||||
|
|
||||||
- dev DB와 prod DB가 다른데 코드 버그로 단정하지 않는다.
|
|
||||||
- 공개용 기준 데이터가 필요한 검증은 먼저 동기화 상태를 확인한다.
|
|
||||||
- DB 차이를 무시한 검증 결과를 신뢰하지 않는다.
|
|
||||||
|
|
||||||
## Rule 5. Dev / Prod Protocol Is Mandatory
|
|
||||||
|
|
||||||
`docs/DEV_PROD_DB_PROTOCOL.md` 의 규칙은 권고가 아니라 작업 기준이다.
|
|
||||||
|
|
||||||
핵심 원칙:
|
|
||||||
|
|
||||||
- 코드 선행은 `8081`
|
|
||||||
- 데이터 정본은 `8080`
|
|
||||||
- `8081` DB는 독립 정본이 아니라 검증용 복제본처럼 다룬다
|
|
||||||
|
|
||||||
조직도/자리배치도/멤버 검증 전에는 필요 시 아래를 먼저 수행한다.
|
|
||||||
|
|
||||||
- `./scripts/sync_prod_db_to_dev.sh minimal`
|
|
||||||
|
|
||||||
분석 화면까지 공개용 기준으로 맞출 필요가 있으면 아래를 사용한다.
|
|
||||||
|
|
||||||
- `./scripts/sync_prod_db_to_dev.sh full`
|
|
||||||
|
|
||||||
## Rule 6. Validation Before Completion
|
|
||||||
|
|
||||||
완료 기준은 "코드를 썼다"가 아니라 "실제 동작을 검증했다"이다.
|
|
||||||
|
|
||||||
세부 규칙:
|
|
||||||
|
|
||||||
- 검증 없이 완료로 판단하지 않는다.
|
|
||||||
- 감으로 확인하지 않고 체크리스트 기준으로 확인한다.
|
|
||||||
- 회귀 가능성이 있는 수정은 관련 기능까지 같이 확인한다.
|
|
||||||
|
|
||||||
검증 기준 문서:
|
|
||||||
|
|
||||||
- `docs/REGRESSION_CHECKLIST.md`
|
|
||||||
|
|
||||||
## Rule 7. Seat Map Work Is High Risk
|
|
||||||
|
|
||||||
자리배치도 관련 작업은 항상 고위험 작업으로 취급한다.
|
|
||||||
|
|
||||||
작업 시 최소 확인 항목:
|
|
||||||
|
|
||||||
1. 관리자 DnD 배치 / 저장
|
|
||||||
2. 조직도 상세의 seat preview
|
|
||||||
3. 비관리자 seatmap 진입 / 표시
|
|
||||||
|
|
||||||
오피스가 여러 개면 아래 모두 확인한다.
|
|
||||||
|
|
||||||
- `기술개발센터`
|
|
||||||
- `한맥빌딩 6층`
|
|
||||||
- `한맥빌딩 7층`
|
|
||||||
|
|
||||||
기술개발센터만 보고 완료 처리하지 않는다.
|
|
||||||
|
|
||||||
## Rule 8. Auth / Schema / Sync Changes Are High Risk
|
|
||||||
|
|
||||||
아래 영역은 일반 기능 수정처럼 다루지 않는다.
|
|
||||||
|
|
||||||
- `auth.*`
|
|
||||||
- `members`
|
|
||||||
- `seat_maps`
|
|
||||||
- `seat_slots`
|
|
||||||
- `seat_positions`
|
|
||||||
- 동기화 스크립트
|
|
||||||
- 스키마 변경
|
|
||||||
|
|
||||||
이 작업은 반드시:
|
|
||||||
|
|
||||||
- 변경 이유 명시
|
|
||||||
- 영향 범위 확인
|
|
||||||
- 관련 검증 수행
|
|
||||||
- 결과 기록
|
|
||||||
|
|
||||||
까지 포함해야 한다.
|
|
||||||
|
|
||||||
## Rule 9. Temporary Logic Must Be Tracked
|
|
||||||
|
|
||||||
mock, fallback, hotfix, 임시 우회 로직은 허용할 수 있다.
|
|
||||||
하지만 반드시 추적 가능해야 한다.
|
|
||||||
|
|
||||||
세부 규칙:
|
|
||||||
|
|
||||||
- 왜 임시인지 기록한다.
|
|
||||||
- 제거 또는 정식화할 이슈를 연결한다.
|
|
||||||
- 운영 기준 로직처럼 장기 방치하지 않는다.
|
|
||||||
|
|
||||||
## Rule 10. End-Of-Day Closing Record
|
|
||||||
|
|
||||||
작업 종료 시 아래를 반드시 남긴다.
|
|
||||||
|
|
||||||
- 무엇을 했는지
|
|
||||||
- 무엇을 검증했는지
|
|
||||||
- 무엇이 아직 남았는지
|
|
||||||
- 다음에 어디서 이어야 하는지
|
|
||||||
|
|
||||||
남길 위치:
|
|
||||||
|
|
||||||
- Gitea 이슈 코멘트
|
|
||||||
- 또는 체크포인트 문서
|
|
||||||
|
|
||||||
둘 다 가능하면 둘 다 남긴다.
|
|
||||||
|
|
||||||
## Rule 11. Commit And Push Need Explicit User Instruction
|
|
||||||
|
|
||||||
커밋과 푸시는 자동으로 하지 않는다.
|
|
||||||
|
|
||||||
세부 규칙:
|
|
||||||
|
|
||||||
- 코드 수정, 문서 수정, 검증 작업은 커밋 없이 계속 진행할 수 있다.
|
|
||||||
- `git commit` 은 사용자가 명시적으로 지시한 경우에만 수행한다.
|
|
||||||
- `git push` 도 사용자가 명시적으로 지시한 경우에만 수행한다.
|
|
||||||
- 작업 중간 상태는 워크트리에 남겨둘 수 있으며, 임의로 잘라서 자주 커밋하지 않는다.
|
|
||||||
- 커밋이 필요하다고 판단되면 먼저 상태와 이유를 공유하고, 지시를 받은 뒤 진행한다.
|
|
||||||
|
|
||||||
## Rule 12. Promote 8081 To 8080 By Reviewed File Diff Only
|
|
||||||
|
|
||||||
`8081` 작업용에서 검증된 변경을 `8080` 공개용으로 가져갈 때는 전체 workspace 를 통째로 덮지 않는다.
|
|
||||||
|
|
||||||
세부 규칙:
|
|
||||||
|
|
||||||
- 먼저 `8081` 작업용의 변경 파일 목록과 diff 를 확인한다.
|
|
||||||
- 공개용에 필요한 파일만 선택해서 메인 workspace 로 반영한다.
|
|
||||||
- 반영 후에는 메인 workspace 기준으로 최소 회귀 검증을 다시 수행한다.
|
|
||||||
- `8081` DB 기준으로만 맞는 수정인지, `8080` 기준 데이터에서도 맞는지 다시 확인한다.
|
|
||||||
- 검증이 끝나기 전에는 공개용 완료로 판단하지 않는다.
|
|
||||||
|
|
||||||
금지:
|
|
||||||
|
|
||||||
- `8081` 작업 디렉터리를 통째로 복사해서 `8080`에 덮어쓰기
|
|
||||||
- diff 확인 없이 일괄 반영
|
|
||||||
- `8081`에서 됐으니 `8080`도 같을 것이라고 가정하기
|
|
||||||
|
|
||||||
## Rule 13. 8081 Must Start From The Isolated Worktree
|
|
||||||
|
|
||||||
`8081` 작업은 항상 `.dev-worktree-8081` 기준으로 시작한다.
|
|
||||||
|
|
||||||
세부 규칙:
|
|
||||||
|
|
||||||
- 디자인 작업도 예외가 아니다.
|
|
||||||
- 허브/조직현황/프로젝트별 분석/사업관리대장 수정 전에 현재 실제 서빙 파일과 SSOT 파일을 먼저 확인한다.
|
|
||||||
|
|
||||||
디자인 작업 강제 우선순위:
|
|
||||||
|
|
||||||
1. `frontend/public/design-tokens.css`
|
|
||||||
2. `frontend/public/design-patterns.css`
|
|
||||||
3. `docs/architecture/DESIGN_SSOT.md`
|
|
||||||
4. 그 다음 화면별 실제 서빙 파일
|
|
||||||
|
|
||||||
금지:
|
|
||||||
|
|
||||||
- reference/original 파일을 먼저 수정하기
|
|
||||||
- 예전 파란톤/indigo/slate 계열을 새 기본값으로 다시 넣기
|
|
||||||
- 토큰/패턴으로 해결 가능한 문제를 화면별 임시 하드코딩으로 처리하기
|
|
||||||
|
|
||||||
`8081` 작업용은 포트만 다른 복제 서버가 아니라, 코드 소스까지 분리된 전용 worktree여야 한다.
|
|
||||||
|
|
||||||
세부 규칙:
|
|
||||||
|
|
||||||
- `8081`은 항상 `.dev-worktree-8081`에서 띄운다.
|
|
||||||
- 기동 전 `./scripts/prepare_dev_worktree.sh`를 먼저 실행한다.
|
|
||||||
- 재부팅 후 빠른 기동은 `./scripts/start_8081.sh` 또는 `./scripts/start_local_dashboards.sh`를 사용한다.
|
|
||||||
- `.env`와 로컬 전용 디자인 자산은 준비 스크립트가 복사한 것을 기준으로 사용한다.
|
|
||||||
- 기동 후 `docker inspect mh-dashboard-organization-dev-backend-1`로 마운트 소스를 확인한다.
|
|
||||||
|
|
||||||
금지:
|
|
||||||
|
|
||||||
- 현재 메인 workspace를 직접 마운트한 상태로 `8081`을 띄우기
|
|
||||||
- `8080`과 `8081`이 같은 `frontend/public`, `legacy/static`, `incoming-files`를 동시에 보게 두기
|
|
||||||
- `8081`에서 보이던 디자인을 `8080` 공통 소스에 바로 덮어쓰기
|
|
||||||
|
|
||||||
## Daily Start Checklist
|
|
||||||
|
|
||||||
매일 첫 작업 시작 전 체크:
|
|
||||||
|
|
||||||
- 현재 브랜치 확인
|
|
||||||
- 원격 대비 커밋 상태 확인
|
|
||||||
- 열린 이슈 확인
|
|
||||||
- `WORK_RULEBOOK.md` 확인
|
|
||||||
- 최신 체크포인트 확인
|
|
||||||
- 미추적 / 수정 파일 확인
|
|
||||||
- 현재 작업은 커밋 없이 진행하고, 커밋/푸시는 지시받을 때만 한다는 규칙 확인
|
|
||||||
- 오늘 작업이 코드 문제인지 DB 문제인지 먼저 구분
|
|
||||||
- 공개용 기준 데이터 검증이 필요한지 판단
|
|
||||||
|
|
||||||
## Daily End Checklist
|
|
||||||
|
|
||||||
매일 작업 종료 전 체크:
|
|
||||||
|
|
||||||
- 오늘 변경 파일 정리
|
|
||||||
- 검증 결과 정리
|
|
||||||
- 미완료 항목 정리
|
|
||||||
- 관련 이슈 코멘트 또는 문서 업데이트
|
|
||||||
- 다음 시작 지점 명시
|
|
||||||
|
|
||||||
## One-Line Operating Principle
|
|
||||||
|
|
||||||
이 프로젝트의 작업 기준은 아래 한 줄로 요약한다.
|
|
||||||
|
|
||||||
- 상태를 먼저 확인하고, 완료 기능은 보호하며, DB와 검증을 무시하지 않고, 기록을 남기면서 작업한다.
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
# WSL 작업 기준 가이드
|
|
||||||
|
|
||||||
## 1. 왜 WSL 기준으로 작업하나
|
|
||||||
- 현재 이 프로젝트는 Ubuntu 24.04 기반 Docker 환경에서 실행되고 있습니다.
|
|
||||||
- Windows 폴더에서 바로 작업하면 실행 경로와 편집 경로가 달라질 수 있습니다.
|
|
||||||
- 그래서 앞으로는 `WSL Ubuntu 내부 경로`를 기준 작업공간으로 사용하는 것을 권장합니다.
|
|
||||||
|
|
||||||
## 2. 기준 작업 경로
|
|
||||||
- 사용자: `hyunho`
|
|
||||||
- 프로젝트 경로: `/home/hyunho/projects/mh-dashboard-organization`
|
|
||||||
|
|
||||||
## 3. VS Code에서 여는 방법
|
|
||||||
1. VS Code 명령 팔레트를 엽니다.
|
|
||||||
2. `Remote-WSL: Reopen Folder in WSL` 를 실행합니다.
|
|
||||||
3. 아래 경로를 엽니다.
|
|
||||||
- `/home/hyunho/projects/mh-dashboard-organization`
|
|
||||||
4. 좌측 아래 상태 표시줄에 `WSL: Ubuntu-24.04` 가 보이면 정상입니다.
|
|
||||||
|
|
||||||
## 4. 앞으로의 작업 규칙
|
|
||||||
- 코드 수정은 가능하면 WSL 경로 기준으로 진행합니다.
|
|
||||||
- Docker 실행, Python 실행, 배포 테스트도 WSL 안에서 진행합니다.
|
|
||||||
- Windows 경로는 참고용 또는 백업용으로만 보고, 실행 기준으로 사용하지 않는 것이 좋습니다.
|
|
||||||
|
|
||||||
## 5. 자주 쓰는 명령
|
|
||||||
```bash
|
|
||||||
cd /home/hyunho/projects/mh-dashboard-organization
|
|
||||||
docker compose ps
|
|
||||||
docker compose logs backend
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
## 6. 현재 확인 가능한 주소
|
|
||||||
- 메인 화면: `http://localhost:8080`
|
|
||||||
- API 상태 확인: `http://localhost:8080/api/health`
|
|
||||||
@@ -47,15 +47,20 @@
|
|||||||
- 현재 실제 서빙 파일: `incoming-files/served/mh.html`
|
- 현재 실제 서빙 파일: `incoming-files/served/mh.html`
|
||||||
- 앱 소스 기준: `frontend/apps/team/index.html`
|
- 앱 소스 기준: `frontend/apps/team/index.html`
|
||||||
- publish 규칙: `scripts/publish_team_app.sh`
|
- publish 규칙: `scripts/publish_team_app.sh`
|
||||||
|
- URL: `/db-status.html`
|
||||||
|
- 현재 실제 서빙 파일: `incoming-files/served/db-status/index.html`
|
||||||
|
- 앱 소스 기준: `frontend/apps/db-status/index.html`
|
||||||
|
- publish 규칙: `scripts/publish_db_status_app.sh`
|
||||||
|
|
||||||
정리 원칙:
|
정리 원칙:
|
||||||
|
|
||||||
- `incoming-files` 아래에서는 `served/`를 실제 서빙 자산용으로 사용한다.
|
- `incoming-files` 아래에서는 `served/`를 실제 서빙 자산용으로 사용한다.
|
||||||
|
- `payment`, `mh`, `ledger`, `db-status`는 사람이 직접 `served/`를 먼저 수정하지 않는다.
|
||||||
|
- 이 4개 화면의 source-of-truth는 `frontend/apps/*`이고, publish 스크립트가 `served/`를 갱신한다.
|
||||||
- `reference/`는 원본 참고 파일, 복구 참고 파일, 비교용 자산만 둔다.
|
- `reference/`는 원본 참고 파일, 복구 참고 파일, 비교용 자산만 둔다.
|
||||||
- 1차 정리에서는 기존 실제 서빙 파일을 `served/`에 복사하고, backend 서빙 경로를 먼저 `served/`로 갱신한다.
|
- 1차 정리에서는 기존 실제 서빙 파일을 `served/`에 복사하고, backend 서빙 경로를 먼저 `served/`로 갱신한다.
|
||||||
- `사업관리대장`은 `#21`부터 wrapper decode 방식 대신 `served/ledger/index.html`과 `served/ledger/*`를 직접 서빙한다.
|
- `사업관리대장`은 `#21`부터 wrapper decode 방식 대신 `served/ledger/index.html`과 `served/ledger/*`를 직접 서빙한다.
|
||||||
- `사업관리대장` 수정 원본은 `#21` 다음 단계부터 `frontend/apps/ledger/*`를 먼저 보고, `scripts/publish_ledger_app.sh`로 runtime served 파일에 반영한다.
|
- `사업관리대장` 수정 원본은 `#21` 다음 단계부터 `frontend/apps/ledger/*`를 먼저 보고, `scripts/publish_ledger_app.sh`로 runtime served 파일에 반영한다.
|
||||||
- 기존 루트 `incoming-files/payment.html`, `incoming-files/mh.html`는 안전한 비교/복구를 위해 당분간 남겨둔다.
|
|
||||||
|
|
||||||
## Seat Map
|
## Seat Map
|
||||||
|
|
||||||
@@ -109,4 +114,5 @@
|
|||||||
- 로그인은 `styles.css`만 본다.
|
- 로그인은 `styles.css`만 본다.
|
||||||
- 허브 8081 디자인은 `styles-8081-design.css`만 본다.
|
- 허브 8081 디자인은 `styles-8081-design.css`만 본다.
|
||||||
- `/integrations/payment`, `/integrations/mh`의 실제 서빙 파일 위치가 문서와 코드에서 일치한다.
|
- `/integrations/payment`, `/integrations/mh`의 실제 서빙 파일 위치가 문서와 코드에서 일치한다.
|
||||||
|
- `/db-status.html`가 현재 DB 저장 구조와 import 상태를 화면에서 바로 보여준다.
|
||||||
- 기존 참고 자산을 지우지 않고도 실제 서빙 경로와 참고 경로를 구분할 수 있다.
|
- 기존 참고 자산을 지우지 않고도 실제 서빙 경로와 참고 경로를 구분할 수 있다.
|
||||||
|
|||||||
205
docs/architecture/DB_TABLE_CATALOG.md
Normal file
205
docs/architecture/DB_TABLE_CATALOG.md
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
# DB Table Catalog
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
이 문서는 현재 PostgreSQL 테이블을 역할별로 분류한 운영 기준 문서다.
|
||||||
|
|
||||||
|
핵심 원칙:
|
||||||
|
|
||||||
|
- 테이블 수가 많다고 바로 줄이지 않는다.
|
||||||
|
- 먼저 `유지 / 주의 / 원본·추적 / 정리 후보`로 나눈다.
|
||||||
|
- 실제 운영 화면과 저장 흐름에 필요한 것은 유지한다.
|
||||||
|
- 의미가 불분명하거나 중복 역할만 하는 것은 후보로 남겨두고, 실제 삭제는 별도 검증 후 진행한다.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
- 전체 테이블: `26`
|
||||||
|
- 유지: `16`
|
||||||
|
- 주의: `6`
|
||||||
|
- 원본·추적: `4`
|
||||||
|
- 정리 후보: `0`
|
||||||
|
|
||||||
|
## 1. 유지
|
||||||
|
|
||||||
|
현재 운영 화면, 인증, 이력, 적재 흐름에서 계속 필요하다.
|
||||||
|
|
||||||
|
- `auth.users`
|
||||||
|
- `auth.sessions`
|
||||||
|
- `auth.login_audit_logs`
|
||||||
|
- `public.members`
|
||||||
|
- `public.member_versions`
|
||||||
|
- `public.history_revisions`
|
||||||
|
- `public.seat_maps`
|
||||||
|
- `public.seat_slots`
|
||||||
|
- `public.seat_positions`
|
||||||
|
- `public.seat_assignment_versions`
|
||||||
|
- `public.integration_import_batches`
|
||||||
|
- `public.integration_projects`
|
||||||
|
- `public.integration_work_logs`
|
||||||
|
- `public.integration_work_log_segments`
|
||||||
|
- `public.integration_vouchers`
|
||||||
|
- `public.integration_binary_sources`
|
||||||
|
|
||||||
|
설명:
|
||||||
|
|
||||||
|
- `members`, `seat_*`는 조직현황/자리배치도 핵심
|
||||||
|
- `member_versions`, `seat_assignment_versions`, `history_revisions`는 as-of 조회와 이력 비교 핵심
|
||||||
|
- `integration_*` 표준화 결과는 프로젝트별 분석 / 팀·개인별 분석 핵심
|
||||||
|
- `integration_binary_sources`는 사업관리대장 같은 바이너리 원본 보관용
|
||||||
|
- `auth.*`는 로그인과 권한 운영 핵심
|
||||||
|
|
||||||
|
## 2. 주의
|
||||||
|
|
||||||
|
현재도 역할은 있지만, 실제 운영에서 얼마나 계속 필요한지 주기적으로 점검해야 한다.
|
||||||
|
|
||||||
|
- `public.member_overrides`
|
||||||
|
- `public.member_retirements`
|
||||||
|
- `public.member_aliases`
|
||||||
|
- `public.integration_project_aliases`
|
||||||
|
- `public.integration_project_category_mappings`
|
||||||
|
- `public.integration_project_pm_assignments`
|
||||||
|
|
||||||
|
설명:
|
||||||
|
|
||||||
|
- 이 테이블들은 핵심 마스터라기보다 “보정/매핑/예외 처리” 성격이 강하다.
|
||||||
|
- 운영상 필요할 수 있지만, 남용되면 기준 데이터가 흐려진다.
|
||||||
|
- 사용 규칙과 관리 책임을 분명히 해야 한다.
|
||||||
|
|
||||||
|
## 3. 원본·추적
|
||||||
|
|
||||||
|
원본 적재와 검증을 위해 필요하다. 직접 서비스 화면의 주 출력원이 아니라, 적재 근거와 추적용이다.
|
||||||
|
|
||||||
|
- `public.integration_raw_organization_rows`
|
||||||
|
- `public.integration_raw_mh_rows`
|
||||||
|
- `public.integration_raw_mh_pm_rows`
|
||||||
|
- `public.integration_raw_payment_rows`
|
||||||
|
|
||||||
|
설명:
|
||||||
|
|
||||||
|
- 원본 파일을 바로 표준화 테이블에만 넣으면, 나중에 적재 오류를 추적하기 어렵다.
|
||||||
|
- raw row 보관은 import 검증과 재현성 측면에서 의미가 있다.
|
||||||
|
- 단, 장기 보관 정책과 용량 관리는 별도 필요하다.
|
||||||
|
|
||||||
|
## 4. 정리 후보
|
||||||
|
|
||||||
|
현재 기준 정리 후보 테이블은 없다.
|
||||||
|
|
||||||
|
## Domain Map
|
||||||
|
|
||||||
|
### 인증
|
||||||
|
|
||||||
|
- `auth.users`
|
||||||
|
- `auth.sessions`
|
||||||
|
- `auth.login_audit_logs`
|
||||||
|
|
||||||
|
### 조직 / 구성원
|
||||||
|
|
||||||
|
- `public.members`
|
||||||
|
- `public.member_overrides`
|
||||||
|
- `public.member_retirements`
|
||||||
|
- `public.member_aliases`
|
||||||
|
|
||||||
|
### 자리배치도
|
||||||
|
|
||||||
|
- `public.seat_maps`
|
||||||
|
- `public.seat_slots`
|
||||||
|
- `public.seat_positions`
|
||||||
|
|
||||||
|
### 이력
|
||||||
|
|
||||||
|
- `public.history_revisions`
|
||||||
|
- `public.member_versions`
|
||||||
|
- `public.seat_assignment_versions`
|
||||||
|
|
||||||
|
### integration 표준화
|
||||||
|
|
||||||
|
- `public.integration_import_batches`
|
||||||
|
- `public.integration_projects`
|
||||||
|
- `public.integration_project_aliases`
|
||||||
|
- `public.integration_project_category_mappings`
|
||||||
|
- `public.integration_project_pm_assignments`
|
||||||
|
- `public.integration_work_logs`
|
||||||
|
- `public.integration_work_log_segments`
|
||||||
|
- `public.integration_vouchers`
|
||||||
|
- `public.integration_binary_sources`
|
||||||
|
|
||||||
|
### integration raw
|
||||||
|
|
||||||
|
- `public.integration_raw_organization_rows`
|
||||||
|
- `public.integration_raw_mh_rows`
|
||||||
|
- `public.integration_raw_mh_pm_rows`
|
||||||
|
- `public.integration_raw_payment_rows`
|
||||||
|
|
||||||
|
## Product View
|
||||||
|
|
||||||
|
운영자가 DB를 볼 때는 물리 테이블 수보다 아래 5개 묶음으로 보는 편이 더 이해하기 쉽다.
|
||||||
|
|
||||||
|
### 탭 데이터
|
||||||
|
|
||||||
|
- `public.members`
|
||||||
|
- `public.seat_maps`
|
||||||
|
- `public.seat_slots`
|
||||||
|
- `public.seat_positions`
|
||||||
|
- `public.integration_projects`
|
||||||
|
- `public.integration_work_logs`
|
||||||
|
- `public.integration_work_log_segments`
|
||||||
|
- `public.integration_vouchers`
|
||||||
|
- `public.integration_binary_sources`
|
||||||
|
|
||||||
|
### 로그인·권한
|
||||||
|
|
||||||
|
- `auth.users`
|
||||||
|
- `auth.sessions`
|
||||||
|
- `auth.login_audit_logs`
|
||||||
|
|
||||||
|
### 히스토리
|
||||||
|
|
||||||
|
- `public.history_revisions`
|
||||||
|
- `public.member_versions`
|
||||||
|
- `public.seat_assignment_versions`
|
||||||
|
|
||||||
|
### 로우데이터·적재
|
||||||
|
|
||||||
|
- `public.integration_import_batches`
|
||||||
|
- `public.integration_raw_organization_rows`
|
||||||
|
- `public.integration_raw_mh_rows`
|
||||||
|
- `public.integration_raw_mh_pm_rows`
|
||||||
|
- `public.integration_raw_payment_rows`
|
||||||
|
|
||||||
|
### 보정·보조
|
||||||
|
|
||||||
|
- `public.member_overrides`
|
||||||
|
- `public.member_retirements`
|
||||||
|
- `public.member_aliases`
|
||||||
|
- `public.integration_project_aliases`
|
||||||
|
- `public.integration_project_category_mappings`
|
||||||
|
- `public.integration_project_pm_assignments`
|
||||||
|
|
||||||
|
## Operational Guidance
|
||||||
|
|
||||||
|
### 바로 줄이지 말아야 하는 것
|
||||||
|
|
||||||
|
- `integration_raw_*`
|
||||||
|
- `member_versions`
|
||||||
|
- `seat_assignment_versions`
|
||||||
|
- `auth.*`
|
||||||
|
|
||||||
|
이건 지금 구조상 “많아 보여도 필요한 층”이다.
|
||||||
|
|
||||||
|
### 먼저 점검할 것
|
||||||
|
|
||||||
|
- `member_overrides`, `member_aliases`, `project_aliases`의 실제 운영 빈도
|
||||||
|
- `seat_maps`의 과거 실험 도면 정리 기준
|
||||||
|
|
||||||
|
### 정리 원칙
|
||||||
|
|
||||||
|
1. 테이블을 없애기 전에 실제 읽는 API/화면/스크립트를 확인한다.
|
||||||
|
2. 원본 추적용 테이블은 운영 출력용 테이블과 구분해서 판단한다.
|
||||||
|
3. 테이블 삭제보다 먼저 “사용 안 함” 상태를 문서화한다.
|
||||||
|
4. 삭제는 백업과 검증 절차가 준비된 뒤에만 한다.
|
||||||
|
|
||||||
|
## Recommended Next Checks
|
||||||
|
|
||||||
|
1. `seat_maps` 과거 DXF 시도본 정리 기준 수립
|
||||||
|
2. `주의` 그룹 테이블의 입력/수정 주체 명확화
|
||||||
|
3. `DB 상태` 화면에서 이 분류를 기준으로 계속 설명 유지
|
||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
## Source of truth
|
## 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)
|
- Primary visual source: [incoming-files/sample style.css](../../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 token file: [design-tokens.css](../../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)
|
- Runtime pattern file: [design-patterns.css](../../frontend/public/design-patterns.css)
|
||||||
|
|
||||||
`sample style.css` defines the intended MH visual language. `design-tokens.css` is the token-level SSOT, and `design-patterns.css` is the component-level SSOT that packages those tokens into reusable runtime patterns.
|
`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.
|
||||||
|
|
||||||
|
|||||||
7
frontend/apps/db-status/README.md
Normal file
7
frontend/apps/db-status/README.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
## DB Status App
|
||||||
|
|
||||||
|
- 수정 원본: `frontend/apps/db-status/index.html`
|
||||||
|
- 실제 서빙: `incoming-files/served/db-status/index.html`
|
||||||
|
- publish: `./scripts/publish_db_status_app.sh`
|
||||||
|
|
||||||
|
`#2` 이슈용 관리자 화면으로, 현재 DB 저장 구조와 적재 상태를 사람이 읽을 수 있게 보여준다.
|
||||||
918
frontend/apps/db-status/index.html
Normal file
918
frontend/apps/db-status/index.html
Normal file
@@ -0,0 +1,918 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>DB 상태</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Pretendard:wght@400;600;700;800&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="/design-tokens.css?v=20260401-01">
|
||||||
|
<link rel="stylesheet" href="/design-patterns.css?v=20260401-01">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "Pretendard", sans-serif;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(247, 217, 119, 0.28), transparent 30%),
|
||||||
|
linear-gradient(180deg, var(--ds-bg, #f5f1e8) 0%, #efe6d5 100%);
|
||||||
|
color: var(--ds-ink, #2f2419);
|
||||||
|
}
|
||||||
|
.page {
|
||||||
|
max-width: 2000px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 28px;
|
||||||
|
display: grid;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
.hero {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 28px 30px;
|
||||||
|
border: 1px solid rgba(134, 98, 47, 0.14);
|
||||||
|
border-radius: 28px;
|
||||||
|
background: linear-gradient(135deg, rgba(255, 250, 240, 0.96), rgba(242, 232, 214, 0.92));
|
||||||
|
box-shadow: 0 28px 68px rgba(88, 61, 23, 0.15);
|
||||||
|
}
|
||||||
|
.hero h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 30px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
}
|
||||||
|
.hero p {
|
||||||
|
margin: 0;
|
||||||
|
color: rgba(76, 58, 35, 0.82);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.overview {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.kpi {
|
||||||
|
padding: 18px 20px;
|
||||||
|
border-radius: 22px;
|
||||||
|
background: rgba(255, 252, 247, 0.92);
|
||||||
|
border: 1px solid rgba(140, 110, 59, 0.14);
|
||||||
|
box-shadow: 0 14px 34px rgba(81, 58, 23, 0.08);
|
||||||
|
}
|
||||||
|
.kpi-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: rgba(112, 84, 41, 0.72);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
.kpi-value {
|
||||||
|
display: block;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #3d2e1d;
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.4fr 1fr;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
.panel {
|
||||||
|
border-radius: 24px;
|
||||||
|
background: rgba(255, 251, 245, 0.96);
|
||||||
|
border: 1px solid rgba(142, 110, 54, 0.14);
|
||||||
|
box-shadow: 0 18px 48px rgba(85, 60, 24, 0.08);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.panel-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 18px 22px 14px;
|
||||||
|
border-bottom: 1px solid rgba(128, 98, 48, 0.12);
|
||||||
|
}
|
||||||
|
.panel-head h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
.panel-head p {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(102, 77, 41, 0.72);
|
||||||
|
}
|
||||||
|
.panel-body {
|
||||||
|
padding: 16px 18px 20px;
|
||||||
|
}
|
||||||
|
.panel-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.panel-body.tight {
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
.meta-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 11px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(251, 236, 196, 0.8);
|
||||||
|
color: #7a5923;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
th, td {
|
||||||
|
padding: 12px 10px;
|
||||||
|
vertical-align: top;
|
||||||
|
border-bottom: 1px solid rgba(130, 100, 53, 0.1);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: rgba(104, 79, 40, 0.76);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
tbody tr:hover {
|
||||||
|
background: rgba(250, 240, 213, 0.34);
|
||||||
|
}
|
||||||
|
.domain-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 9px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
background: rgba(90, 122, 94, 0.14);
|
||||||
|
color: #456b4c;
|
||||||
|
}
|
||||||
|
.domain-tag.integration { background: rgba(196, 143, 58, 0.16); color: #8c5f18; }
|
||||||
|
.domain-tag.history { background: rgba(120, 92, 156, 0.14); color: #6a4b8b; }
|
||||||
|
.domain-tag.auth { background: rgba(103, 114, 154, 0.14); color: #48567c; }
|
||||||
|
.domain-tag.other { background: rgba(131, 112, 80, 0.12); color: #6a5637; }
|
||||||
|
.group-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
background: rgba(240, 231, 214, 0.95);
|
||||||
|
color: #674d27;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.group-tag.keep { background: rgba(112, 143, 87, 0.18); color: #49623c; }
|
||||||
|
.group-tag.caution { background: rgba(214, 167, 84, 0.18); color: #8f5d17; }
|
||||||
|
.group-tag.trace { background: rgba(113, 120, 168, 0.16); color: #56628c; }
|
||||||
|
.group-tag.cleanup { background: rgba(184, 111, 84, 0.16); color: #884d39; }
|
||||||
|
.table-title {
|
||||||
|
font-weight: 800;
|
||||||
|
color: #2f2419;
|
||||||
|
}
|
||||||
|
.table-trigger {
|
||||||
|
all: unset;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #2f2419;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.table-trigger:hover {
|
||||||
|
color: #80591f;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.table-desc {
|
||||||
|
margin-top: 5px;
|
||||||
|
color: rgba(98, 75, 42, 0.72);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.view-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.view-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(86, 119, 93, 0.12);
|
||||||
|
color: #456b4c;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.toolbar-input {
|
||||||
|
width: min(360px, 100%);
|
||||||
|
padding: 11px 14px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid rgba(132, 102, 54, 0.16);
|
||||||
|
background: rgba(255, 251, 244, 0.96);
|
||||||
|
color: #2f2419;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
.toolbar-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: rgba(129, 88, 31, 0.34);
|
||||||
|
box-shadow: 0 0 0 4px rgba(208, 176, 116, 0.16);
|
||||||
|
}
|
||||||
|
.toolbar-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.action-button {
|
||||||
|
border: 1px solid rgba(128, 98, 48, 0.14);
|
||||||
|
background: rgba(250, 240, 213, 0.62);
|
||||||
|
color: #6c4a1a;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 9px 14px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.action-button:hover {
|
||||||
|
background: rgba(244, 228, 186, 0.8);
|
||||||
|
}
|
||||||
|
.action-button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
.notes {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
color: rgba(84, 65, 38, 0.84);
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
.mapping-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.mapping-card {
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(255, 249, 239, 0.92);
|
||||||
|
border: 1px solid rgba(132, 102, 54, 0.12);
|
||||||
|
}
|
||||||
|
.mapping-card h3 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
.mapping-card p {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: rgba(98, 75, 42, 0.74);
|
||||||
|
line-height: 1.55;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.mapping-table-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.preview-meta {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 16px 18px 0;
|
||||||
|
}
|
||||||
|
.preview-columns {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.column-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(240, 231, 214, 0.9);
|
||||||
|
color: #634a25;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.column-pill em {
|
||||||
|
font-style: normal;
|
||||||
|
color: rgba(99, 74, 37, 0.68);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.preview-table-wrap {
|
||||||
|
overflow: auto;
|
||||||
|
max-height: 520px;
|
||||||
|
border-top: 1px solid rgba(128, 98, 48, 0.12);
|
||||||
|
}
|
||||||
|
.sticky-head th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: rgba(255, 248, 236, 0.98);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.muted {
|
||||||
|
color: rgba(110, 86, 50, 0.72);
|
||||||
|
}
|
||||||
|
.empty {
|
||||||
|
padding: 22px;
|
||||||
|
text-align: center;
|
||||||
|
color: rgba(102, 77, 41, 0.72);
|
||||||
|
}
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
background: rgba(44, 31, 16, 0.42);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
.modal-overlay.open {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.modal-card {
|
||||||
|
width: min(1600px, 100%);
|
||||||
|
max-height: min(88vh, 980px);
|
||||||
|
border-radius: 28px;
|
||||||
|
background: rgba(255, 250, 243, 0.98);
|
||||||
|
border: 1px solid rgba(142, 110, 54, 0.18);
|
||||||
|
box-shadow: 0 32px 80px rgba(59, 40, 16, 0.28);
|
||||||
|
overflow: hidden;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto auto 1fr;
|
||||||
|
}
|
||||||
|
.modal-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 22px 24px 16px;
|
||||||
|
border-bottom: 1px solid rgba(128, 98, 48, 0.12);
|
||||||
|
}
|
||||||
|
.modal-head h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
}
|
||||||
|
.modal-close {
|
||||||
|
border: 0;
|
||||||
|
background: rgba(240, 229, 206, 0.9);
|
||||||
|
color: #6d5127;
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 800;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.modal-close:hover {
|
||||||
|
background: rgba(225, 208, 174, 0.96);
|
||||||
|
}
|
||||||
|
.modal-body {
|
||||||
|
overflow: hidden;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto 1fr;
|
||||||
|
}
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.grid { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.page { padding: 16px; }
|
||||||
|
.hero { padding: 22px 20px; }
|
||||||
|
.kpi-value { font-size: 24px; }
|
||||||
|
th, td { padding: 10px 8px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page">
|
||||||
|
<section class="hero">
|
||||||
|
<span class="meta-chip">#2 백엔드 영속 저장 구조 운영</span>
|
||||||
|
<h1>DB 상태와 저장 구조를 화면에서 바로 확인</h1>
|
||||||
|
<p>
|
||||||
|
이 화면은 현재 운영 DB의 핵심 테이블, 적재 상태, 최근 import 흐름을 SQL 없이 확인하기 위한 관리자용 뷰어입니다.
|
||||||
|
이후 저장 구조 검증과 데이터 정합성 작업은 이 화면을 기준으로 진행합니다.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
`원본 import 배치`는 업로드한 원본 파일이 몇 행으로 적재됐는지 보여주고, `바이너리 원본 보관`은 엑셀 같은 파일 자체를 DB에 보관하는 상태를 보여줍니다.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
아래 표는 전체 테이블을 보여주고, 오른쪽 패널은 화면별 데이터 소스와 저장 흐름을 운영 관점으로 묶어서 보여줍니다.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="overview" class="overview"></section>
|
||||||
|
|
||||||
|
<section class="grid">
|
||||||
|
<article class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<h2>전체 테이블 현황</h2>
|
||||||
|
<p>현재 운영 DB의 전체 26개 테이블을 보여주며, 테이블명을 누르면 샘플 row를 바로 확인할 수 있습니다.</p>
|
||||||
|
</div>
|
||||||
|
<span id="generated-at" class="meta-chip">로딩 중</span>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div class="panel-toolbar">
|
||||||
|
<input id="table-search" class="toolbar-input" type="search" placeholder="테이블명, 설명, 화면명으로 검색" />
|
||||||
|
<div class="toolbar-actions">
|
||||||
|
<span id="table-count" class="meta-chip">0 / 0</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>도메인</th>
|
||||||
|
<th>테이블</th>
|
||||||
|
<th>Rows</th>
|
||||||
|
<th>최근 갱신</th>
|
||||||
|
<th>연결 화면</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="table-body">
|
||||||
|
<tr><td colspan="5" class="empty">DB 상태를 불러오는 중입니다.</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<div style="display:grid; gap:20px;">
|
||||||
|
<article class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<h2>원본 import 배치</h2>
|
||||||
|
<p>현재 적재된 원본 파일 배치와 row 수입니다.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Source</th>
|
||||||
|
<th>Rows</th>
|
||||||
|
<th>Imported</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="batch-body">
|
||||||
|
<tr><td colspan="3" class="empty">로딩 중</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<h2>바이너리 원본 보관</h2>
|
||||||
|
<p>엑셀 같은 바이너리 원본의 DB 보관 상태입니다.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Source</th>
|
||||||
|
<th>파일</th>
|
||||||
|
<th>크기</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="binary-body">
|
||||||
|
<tr><td colspan="3" class="empty">로딩 중</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<h2>운영 메모</h2>
|
||||||
|
<p>#2에서 확인해야 할 저장 구조 핵심 포인트입니다.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<ol id="notes" class="notes"></ol>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<h2>운영 분류</h2>
|
||||||
|
<p>유지/주의/원본·추적/정리 후보 기준과 제품 관점 묶음을 같이 봅니다.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div id="group-summary"></div>
|
||||||
|
<div id="product-summary" style="margin-top:18px;"></div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<h2>화면별 데이터 소스</h2>
|
||||||
|
<p>각 탭/기능이 실제로 어떤 테이블을 읽고 저장하는지 빠르게 확인합니다.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="screen-map" class="panel-body mapping-list"></div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="preview-modal" class="modal-overlay" aria-hidden="true">
|
||||||
|
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="preview-title">
|
||||||
|
<div class="modal-head">
|
||||||
|
<div>
|
||||||
|
<h2 id="preview-title">테이블 내용 미리보기</h2>
|
||||||
|
<p id="preview-subtitle" class="muted">선택한 테이블의 컬럼과 최대 50개 row를 표시합니다.</p>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-actions">
|
||||||
|
<button id="preview-download" class="action-button" type="button" disabled>CSV 다운로드</button>
|
||||||
|
<button id="preview-close" class="modal-close" type="button" aria-label="닫기">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div id="preview-meta" class="preview-meta"></div>
|
||||||
|
<div class="panel-body tight">
|
||||||
|
<div class="preview-table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead id="preview-head" class="sticky-head"></thead>
|
||||||
|
<tbody id="preview-body">
|
||||||
|
<tr><td class="empty">왼쪽 표에서 테이블을 선택하세요.</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let allTables = [];
|
||||||
|
let currentPreview = null;
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value ?? "")
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("'", "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(value) {
|
||||||
|
return new Intl.NumberFormat("ko-KR").format(Number(value || 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value) {
|
||||||
|
if (!value) return '<span class="muted">-</span>';
|
||||||
|
const parsed = new Date(value);
|
||||||
|
if (Number.isNaN(parsed.getTime())) return escapeHtml(value);
|
||||||
|
return parsed.toLocaleString("ko-KR", { hour12: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(value) {
|
||||||
|
const size = Number(value || 0);
|
||||||
|
if (size <= 0) return "0 B";
|
||||||
|
const units = ["B", "KB", "MB", "GB"];
|
||||||
|
let current = size;
|
||||||
|
let unit = 0;
|
||||||
|
while (current >= 1024 && unit < units.length - 1) {
|
||||||
|
current /= 1024;
|
||||||
|
unit += 1;
|
||||||
|
}
|
||||||
|
return `${current.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderOverview(overview) {
|
||||||
|
const target = document.getElementById("overview");
|
||||||
|
target.innerHTML = [
|
||||||
|
["핵심 테이블", overview.visible_tables],
|
||||||
|
["전체 테이블", overview.total_tables],
|
||||||
|
["등록 인원", overview.registered_members],
|
||||||
|
["재직 인원", overview.active_members],
|
||||||
|
["고정 오피스 도면", overview.fixed_office_maps],
|
||||||
|
["현재 active 도면", overview.active_seat_maps],
|
||||||
|
["Import 배치", overview.import_batches],
|
||||||
|
["바이너리 원본", overview.binary_sources],
|
||||||
|
].map(([label, value]) => `
|
||||||
|
<article class="kpi">
|
||||||
|
<span class="kpi-label">${escapeHtml(label)}</span>
|
||||||
|
<span class="kpi-value">${formatNumber(value)}</span>
|
||||||
|
</article>
|
||||||
|
`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTables(items) {
|
||||||
|
const target = document.getElementById("table-body");
|
||||||
|
const countTarget = document.getElementById("table-count");
|
||||||
|
if (countTarget) {
|
||||||
|
countTarget.textContent = `${formatNumber(items.length)} / ${formatNumber(allTables.length)}`;
|
||||||
|
}
|
||||||
|
if (!items.length) {
|
||||||
|
target.innerHTML = '<tr><td colspan="5" class="empty">표시할 테이블이 없습니다.</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
target.innerHTML = items.map((item) => `
|
||||||
|
<tr>
|
||||||
|
<td><span class="domain-tag ${escapeHtml(item.domain)}">${escapeHtml(item.domain)}</span></td>
|
||||||
|
<td>
|
||||||
|
<div style="margin-bottom:8px;">
|
||||||
|
<span class="group-tag ${item.group === '유지' ? 'keep' : item.group === '원본·추적' ? 'trace' : item.group === '정리 후보' ? 'cleanup' : 'caution'}">${escapeHtml(item.group || '주의')}</span>
|
||||||
|
</div>
|
||||||
|
<button class="table-trigger" type="button" data-schema="${escapeHtml(item.schema)}" data-table="${escapeHtml(item.table_name)}">${escapeHtml(item.label)}</button>
|
||||||
|
<div class="muted">${escapeHtml(item.table_ref)}</div>
|
||||||
|
<div class="table-desc">${escapeHtml(item.description)}</div>
|
||||||
|
</td>
|
||||||
|
<td>${formatNumber(item.row_count)}</td>
|
||||||
|
<td>${formatDateTime(item.last_event_at)}</td>
|
||||||
|
<td>
|
||||||
|
<div class="view-list">
|
||||||
|
${(item.related_views || []).map((view) => `<span class="view-pill">${escapeHtml(view)}</span>`).join("")}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join("");
|
||||||
|
target.querySelectorAll(".table-trigger").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
loadTablePreview(button.dataset.schema, button.dataset.table);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBatches(items) {
|
||||||
|
const target = document.getElementById("batch-body");
|
||||||
|
if (!items.length) {
|
||||||
|
target.innerHTML = '<tr><td colspan="3" class="empty">적재 배치가 없습니다.</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
target.innerHTML = items.map((item) => `
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="table-title">${escapeHtml(item.source_name)}</div>
|
||||||
|
<div class="muted">${escapeHtml(item.source_key)}</div>
|
||||||
|
</td>
|
||||||
|
<td>${formatNumber(item.row_count)}</td>
|
||||||
|
<td>${formatDateTime(item.imported_at)}</td>
|
||||||
|
</tr>
|
||||||
|
`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBinarySources(items) {
|
||||||
|
const target = document.getElementById("binary-body");
|
||||||
|
if (!items.length) {
|
||||||
|
target.innerHTML = '<tr><td colspan="3" class="empty">보관 중인 바이너리 원본이 없습니다.</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
target.innerHTML = items.map((item) => `
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="table-title">${escapeHtml(item.source_name)}</div>
|
||||||
|
<div class="muted">${escapeHtml(item.source_key)}</div>
|
||||||
|
</td>
|
||||||
|
<td>${escapeHtml(item.filename)}</td>
|
||||||
|
<td>${formatBytes(item.byte_size)}</td>
|
||||||
|
</tr>
|
||||||
|
`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNotes(notes) {
|
||||||
|
const target = document.getElementById("notes");
|
||||||
|
target.innerHTML = (notes || []).map((note) => `<li>${escapeHtml(note)}</li>`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGroupSummary(summary) {
|
||||||
|
const target = document.getElementById("group-summary");
|
||||||
|
const groups = [
|
||||||
|
["유지", "keep"],
|
||||||
|
["주의", "caution"],
|
||||||
|
["원본·추적", "trace"],
|
||||||
|
["정리 후보", "cleanup"],
|
||||||
|
];
|
||||||
|
target.innerHTML = groups.map(([label, klass]) => `
|
||||||
|
<div style="display:grid; gap:8px; margin-bottom:16px;">
|
||||||
|
<div><span class="group-tag ${klass}">${escapeHtml(label)}</span></div>
|
||||||
|
<div class="view-list">
|
||||||
|
${((summary && summary[label]) || []).map((item) => `<span class="view-pill">${escapeHtml(item)}</span>`).join("") || '<span class="muted">없음</span>'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProductSummary(summary) {
|
||||||
|
const target = document.getElementById("product-summary");
|
||||||
|
const groups = [
|
||||||
|
"탭 데이터",
|
||||||
|
"로그인·권한",
|
||||||
|
"히스토리",
|
||||||
|
"로우데이터·적재",
|
||||||
|
"보정·보조",
|
||||||
|
];
|
||||||
|
target.innerHTML = groups.map((label) => `
|
||||||
|
<div style="display:grid; gap:8px; margin-bottom:16px;">
|
||||||
|
<div><span class="group-tag keep">${escapeHtml(label)}</span></div>
|
||||||
|
<div class="view-list">
|
||||||
|
${((summary && summary[label]) || []).map((item) => `<span class="view-pill">${escapeHtml(item)}</span>`).join("") || '<span class="muted">없음</span>'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderScreenMap(items) {
|
||||||
|
const target = document.getElementById("screen-map");
|
||||||
|
if (!items || !items.length) {
|
||||||
|
target.innerHTML = '<div class="empty">화면별 데이터 소스 정보가 없습니다.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
target.innerHTML = items.map((item) => `
|
||||||
|
<article class="mapping-card">
|
||||||
|
<h3>${escapeHtml(item.screen || "")}</h3>
|
||||||
|
<div class="mapping-table-list">
|
||||||
|
${(item.tables || []).map((table) => `<span class="view-pill">${escapeHtml(table)}</span>`).join("")}
|
||||||
|
</div>
|
||||||
|
<p>${escapeHtml(item.write_flow || "")}</p>
|
||||||
|
</article>
|
||||||
|
`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTablePreview(payload) {
|
||||||
|
currentPreview = payload;
|
||||||
|
const previewModal = document.getElementById("preview-modal");
|
||||||
|
const downloadButton = document.getElementById("preview-download");
|
||||||
|
const previewMeta = document.getElementById("preview-meta");
|
||||||
|
const previewTitle = document.getElementById("preview-title");
|
||||||
|
const previewSubtitle = document.getElementById("preview-subtitle");
|
||||||
|
const previewHead = document.getElementById("preview-head");
|
||||||
|
const previewBody = document.getElementById("preview-body");
|
||||||
|
|
||||||
|
previewTitle.textContent = `${payload.label} · ${payload.table_ref}`;
|
||||||
|
previewSubtitle.textContent = `${formatNumber(payload.row_count)} rows / 최대 ${formatNumber(payload.limit)}개 표시`;
|
||||||
|
if (downloadButton) {
|
||||||
|
downloadButton.disabled = !(payload.rows && payload.rows.length);
|
||||||
|
}
|
||||||
|
previewMeta.innerHTML = `
|
||||||
|
<div>
|
||||||
|
<div class="table-title">${escapeHtml(payload.label)}</div>
|
||||||
|
<div class="muted">${escapeHtml(payload.description || "")}</div>
|
||||||
|
</div>
|
||||||
|
<div class="preview-columns">
|
||||||
|
${(payload.columns || []).map((column) => `
|
||||||
|
<span class="column-pill">${escapeHtml(column.name)} <em>${escapeHtml(column.type)}</em></span>
|
||||||
|
`).join("")}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const columns = payload.columns || [];
|
||||||
|
previewHead.innerHTML = `<tr>${columns.map((column) => `<th>${escapeHtml(column.name)}</th>`).join("")}</tr>`;
|
||||||
|
if (!payload.rows || !payload.rows.length) {
|
||||||
|
previewBody.innerHTML = `<tr><td colspan="${Math.max(columns.length, 1)}" class="empty">표시할 row가 없습니다.</td></tr>`;
|
||||||
|
previewModal.classList.add("open");
|
||||||
|
previewModal.setAttribute("aria-hidden", "false");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
previewBody.innerHTML = payload.rows.map((row) => `
|
||||||
|
<tr>
|
||||||
|
${columns.map((column) => `<td>${escapeHtml(row[column.name] ?? "")}</td>`).join("")}
|
||||||
|
</tr>
|
||||||
|
`).join("");
|
||||||
|
previewModal.classList.add("open");
|
||||||
|
previewModal.setAttribute("aria-hidden", "false");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTablePreview(schema, table) {
|
||||||
|
const previewModal = document.getElementById("preview-modal");
|
||||||
|
const previewMeta = document.getElementById("preview-meta");
|
||||||
|
const previewTitle = document.getElementById("preview-title");
|
||||||
|
const previewSubtitle = document.getElementById("preview-subtitle");
|
||||||
|
const previewHead = document.getElementById("preview-head");
|
||||||
|
const previewBody = document.getElementById("preview-body");
|
||||||
|
previewTitle.textContent = `${table}`;
|
||||||
|
previewSubtitle.textContent = "테이블 내용을 불러오는 중입니다.";
|
||||||
|
previewMeta.innerHTML = "";
|
||||||
|
previewHead.innerHTML = "";
|
||||||
|
previewBody.innerHTML = `<tr><td class="empty">테이블 내용을 불러오는 중입니다.</td></tr>`;
|
||||||
|
previewModal.classList.add("open");
|
||||||
|
previewModal.setAttribute("aria-hidden", "false");
|
||||||
|
const response = await fetch(`/api/admin/db-status/table?schema=${encodeURIComponent(schema)}&table=${encodeURIComponent(table)}`, { cache: "no-store" });
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`테이블 내용을 불러오지 못했습니다. (${response.status})`);
|
||||||
|
}
|
||||||
|
const payload = await response.json();
|
||||||
|
renderTablePreview(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
const response = await fetch("/api/admin/db-status", { cache: "no-store" });
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`DB 상태를 불러오지 못했습니다. (${response.status})`);
|
||||||
|
}
|
||||||
|
const payload = await response.json();
|
||||||
|
allTables = payload.tables || [];
|
||||||
|
document.getElementById("generated-at").textContent = payload.generated_at
|
||||||
|
? `갱신 ${formatDateTime(payload.generated_at)}`
|
||||||
|
: "갱신 시각 없음";
|
||||||
|
renderOverview(payload.overview || {});
|
||||||
|
renderTables(allTables);
|
||||||
|
renderBatches(payload.import_batches || []);
|
||||||
|
renderBinarySources(payload.binary_sources || []);
|
||||||
|
renderNotes(payload.notes || []);
|
||||||
|
renderGroupSummary(payload.group_summary || {});
|
||||||
|
renderProductSummary(payload.product_summary || {});
|
||||||
|
renderScreenMap(payload.screen_map || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toCsvValue(value) {
|
||||||
|
const text = String(value ?? "");
|
||||||
|
if (!/[",\n]/.test(text)) return text;
|
||||||
|
return `"${text.replaceAll('"', '""')}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadPreviewCsv() {
|
||||||
|
if (!currentPreview || !currentPreview.columns || !currentPreview.rows || !currentPreview.rows.length) return;
|
||||||
|
const headers = currentPreview.columns.map((column) => column.name);
|
||||||
|
const lines = [
|
||||||
|
headers.map(toCsvValue).join(","),
|
||||||
|
...currentPreview.rows.map((row) => headers.map((header) => toCsvValue(row[header] ?? "")).join(",")),
|
||||||
|
];
|
||||||
|
const blob = new Blob(["\ufeff" + lines.join("\n")], { type: "text/csv;charset=utf-8;" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = `${currentPreview.table_name || "table-preview"}.csv`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTableSearch(query) {
|
||||||
|
const normalized = String(query || "").trim().toLowerCase();
|
||||||
|
if (!normalized) {
|
||||||
|
renderTables(allTables);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const filtered = allTables.filter((item) => {
|
||||||
|
const haystack = [
|
||||||
|
item.label,
|
||||||
|
item.table_ref,
|
||||||
|
item.description,
|
||||||
|
...(item.related_views || []),
|
||||||
|
item.domain,
|
||||||
|
item.group,
|
||||||
|
].join(" ").toLowerCase();
|
||||||
|
return haystack.includes(normalized);
|
||||||
|
});
|
||||||
|
renderTables(filtered);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("preview-close").addEventListener("click", () => {
|
||||||
|
const modal = document.getElementById("preview-modal");
|
||||||
|
modal.classList.remove("open");
|
||||||
|
modal.setAttribute("aria-hidden", "true");
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("preview-download").addEventListener("click", downloadPreviewCsv);
|
||||||
|
document.getElementById("table-search").addEventListener("input", (event) => {
|
||||||
|
applyTableSearch(event.target.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("preview-modal").addEventListener("click", (event) => {
|
||||||
|
if (event.target.id !== "preview-modal") return;
|
||||||
|
const modal = document.getElementById("preview-modal");
|
||||||
|
modal.classList.remove("open");
|
||||||
|
modal.setAttribute("aria-hidden", "true");
|
||||||
|
});
|
||||||
|
|
||||||
|
bootstrap().catch((error) => {
|
||||||
|
document.getElementById("table-body").innerHTML = `<tr><td colspan="5" class="empty">${escapeHtml(error.message || "DB 상태를 불러오지 못했습니다.")}</td></tr>`;
|
||||||
|
document.getElementById("batch-body").innerHTML = '<tr><td colspan="3" class="empty">배치 정보를 불러오지 못했습니다.</td></tr>';
|
||||||
|
document.getElementById("binary-body").innerHTML = '<tr><td colspan="3" class="empty">바이너리 원본 정보를 불러오지 못했습니다.</td></tr>';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -10,6 +10,10 @@
|
|||||||
return new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
return new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function bgNormalizeText(value) {
|
||||||
|
return String(value || "").replace(/\s+/g, " ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
function bgParseDate(value) {
|
function bgParseDate(value) {
|
||||||
var text = String(value || "").trim();
|
var text = String(value || "").trim();
|
||||||
if (!text) return null;
|
if (!text) return null;
|
||||||
@@ -36,6 +40,44 @@
|
|||||||
return bgYearFromText(row && row.eDate);
|
return bgYearFromText(row && row.eDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizedCategory(row) {
|
||||||
|
var category = bgNormalizeText(row && row.cat);
|
||||||
|
if (category.indexOf("가족사") >= 0) return "가족사";
|
||||||
|
var corp = bgNormalizeText(row && row.corp);
|
||||||
|
if (corp && corp !== "바론") return "가족사";
|
||||||
|
return "바론";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSupportServiceRow(row) {
|
||||||
|
return bgNormalizeText(row && row.name).indexOf("경영 및 기술지원 서비스") >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectTypeLabel(row) {
|
||||||
|
if (isSupportServiceRow(row)) return "기술지원서비스";
|
||||||
|
return normalizedCategory(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectTypeRank(row) {
|
||||||
|
var label = projectTypeLabel(row);
|
||||||
|
if (label === "바론") return 0;
|
||||||
|
if (label === "가족사") return 1;
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStatusLabel(status) {
|
||||||
|
var value = bgNormalizeText(status);
|
||||||
|
if (!value) return "-";
|
||||||
|
if (value === "완료") return "준공";
|
||||||
|
if (value === "진행") return "과업진행중";
|
||||||
|
if (value === "대기") return "계약대기";
|
||||||
|
if (value === "중지") return "과업중지";
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowStatusLabel(row) {
|
||||||
|
return normalizeStatusLabel(row && row.status);
|
||||||
|
}
|
||||||
|
|
||||||
function bgDisplayYear(row) {
|
function bgDisplayYear(row) {
|
||||||
var start = bgStartYear(row);
|
var start = bgStartYear(row);
|
||||||
if (start) return start;
|
if (start) return start;
|
||||||
@@ -82,7 +124,7 @@
|
|||||||
if (!(cutoff && yearStart && startDate)) return false;
|
if (!(cutoff && yearStart && startDate)) return false;
|
||||||
if (startDate > cutoff) return false;
|
if (startDate > cutoff) return false;
|
||||||
if (endDate && endDate < yearStart) return false;
|
if (endDate && endDate < yearStart) return false;
|
||||||
return !(endDate && endDate <= cutoff);
|
return rowStatusLabel(row) === "과업진행중";
|
||||||
}
|
}
|
||||||
|
|
||||||
function bgStartedInYear(row, year) {
|
function bgStartedInYear(row, year) {
|
||||||
@@ -96,7 +138,7 @@
|
|||||||
var cutoff = bgYearCutoff(year);
|
var cutoff = bgYearCutoff(year);
|
||||||
var endDate = bgDateOrYearEnd(row);
|
var endDate = bgDateOrYearEnd(row);
|
||||||
if (!(cutoff && endDate)) return false;
|
if (!(cutoff && endDate)) return false;
|
||||||
return endDate.getFullYear() === Number(year || 0) && endDate <= cutoff;
|
return rowStatusLabel(row) === "준공" && endDate.getFullYear() === Number(year || 0) && endDate <= cutoff;
|
||||||
}
|
}
|
||||||
|
|
||||||
function bgYearRange(row) {
|
function bgYearRange(row) {
|
||||||
@@ -140,16 +182,32 @@
|
|||||||
}, { c: 0, col: 0, recv: 0 });
|
}, { c: 0, col: 0, recv: 0 });
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSupportServiceRow(row) {
|
function isBaronProjectRow(row) {
|
||||||
var category = String((row && row.cat) || "").trim();
|
return projectTypeLabel(row) === "바론";
|
||||||
return category.indexOf("경영지원") >= 0 || category.indexOf("서비스") >= 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isBaronProjectRow(row) {
|
function isSoftwareProjectRow(row) {
|
||||||
var category = String((row && row.cat) || "").trim();
|
var name = bgNormalizeText(row && row.name).toLowerCase();
|
||||||
if (category.indexOf("바론") < 0) return false;
|
if (!name) return false;
|
||||||
if (isSupportServiceRow(row)) return false;
|
return [
|
||||||
return true;
|
"프로그램",
|
||||||
|
"소프트웨어",
|
||||||
|
"software",
|
||||||
|
" sw",
|
||||||
|
"sw ",
|
||||||
|
"erp",
|
||||||
|
"tova",
|
||||||
|
"ipipe",
|
||||||
|
"eg-bim",
|
||||||
|
"cad"
|
||||||
|
].some(function (keyword) {
|
||||||
|
return name.indexOf(keyword) >= 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldSinkProjectName(row) {
|
||||||
|
var name = bgNormalizeText(row && row.name);
|
||||||
|
return name.indexOf("프로그램") >= 0 || name.indexOf("사용") >= 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function bgSummarize(rows, selectedYear) {
|
function bgSummarize(rows, selectedYear) {
|
||||||
@@ -158,14 +216,18 @@
|
|||||||
var activeRows = items.filter(function (row) { return bgActiveInYear(row, targetYear); });
|
var activeRows = items.filter(function (row) { return bgActiveInYear(row, targetYear); });
|
||||||
var newProjectRows = items.filter(function (row) { return bgStartedInYear(row, targetYear); });
|
var newProjectRows = items.filter(function (row) { return bgStartedInYear(row, targetYear); });
|
||||||
var completedRows = items.filter(function (row) { return bgCompletedInYear(row, targetYear); });
|
var completedRows = items.filter(function (row) { return bgCompletedInYear(row, targetYear); });
|
||||||
var managementRows = newProjectRows.filter(isSupportServiceRow);
|
var managementRows = activeRows.filter(isSupportServiceRow);
|
||||||
|
var baronActiveRows = activeRows.filter(isBaronProjectRow);
|
||||||
return {
|
return {
|
||||||
targetYear: targetYear,
|
targetYear: targetYear,
|
||||||
activeRows: activeRows,
|
activeRows: activeRows,
|
||||||
newProjectRows: newProjectRows,
|
newProjectRows: newProjectRows,
|
||||||
completedRows: completedRows,
|
completedRows: completedRows,
|
||||||
managementRows: managementRows,
|
managementRows: managementRows,
|
||||||
managementTotals: bgTotals(managementRows)
|
managementTotals: bgTotals(managementRows),
|
||||||
|
baronActiveRows: baronActiveRows,
|
||||||
|
baronProjectTotals: bgTotals(baronActiveRows),
|
||||||
|
baronSoftwareCount: baronActiveRows.filter(isSoftwareProjectRow).length
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,13 +239,6 @@
|
|||||||
return bgActiveInYear(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) {
|
function formatSplitPercent(split) {
|
||||||
var numeric = parseFloat(String(split || "").replace(/[^0-9.\-]/g, ""));
|
var numeric = parseFloat(String(split || "").replace(/[^0-9.\-]/g, ""));
|
||||||
if (!Number.isFinite(numeric) || numeric === 0) return "분담율 -%";
|
if (!Number.isFinite(numeric) || numeric === 0) return "분담율 -%";
|
||||||
@@ -204,17 +259,57 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function groupSortRank(row) {
|
function groupSortRank(row) {
|
||||||
var selectedYear = Number((S.dashboard && S.dashboard.year) || projectYear(row) || 0);
|
|
||||||
var startYear = Number(projectYear(row) || 0);
|
var startYear = Number(projectYear(row) || 0);
|
||||||
if (typeof bgCompletedInYear === "function" && bgCompletedInYear(row, String(selectedYear))) return 9999;
|
|
||||||
if (!startYear) return 9998;
|
if (!startYear) return 9998;
|
||||||
return startYear;
|
return startYear;
|
||||||
}
|
}
|
||||||
|
|
||||||
function tableGroupLabel(row) {
|
function tableGroupLabel(row) {
|
||||||
var startYear = projectYear(row);
|
var startYear = projectYear(row);
|
||||||
if (/^20\d{2}$/.test(startYear)) return startYear + "년";
|
if (/^20\d{2}$/.test(startYear)) return startYear + " " + projectTypeLabel(row);
|
||||||
return "미지정";
|
return "미지정 " + projectTypeLabel(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareDashboardRows(a, b) {
|
||||||
|
var typeRankDiff = projectTypeRank(a) - projectTypeRank(b);
|
||||||
|
if (typeRankDiff !== 0) return typeRankDiff;
|
||||||
|
var groupDiff = groupSortRank(a) - groupSortRank(b);
|
||||||
|
if (groupDiff !== 0) return groupDiff;
|
||||||
|
var sinkDiff = Number(shouldSinkProjectName(a)) - Number(shouldSinkProjectName(b));
|
||||||
|
if (sinkDiff !== 0) return sinkDiff;
|
||||||
|
return bgNormalizeText(a && a.name).localeCompare(bgNormalizeText(b && b.name), "ko");
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterCategoryLabel(row) {
|
||||||
|
return projectTypeLabel(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterClientLabel(row) {
|
||||||
|
if (typeof normalizeClientDisplay === "function") {
|
||||||
|
return normalizeClientDisplay(row && row.client);
|
||||||
|
}
|
||||||
|
return bgNormalizeText(row && row.client) || "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterOrderLabel(row) {
|
||||||
|
return bgNormalizeText(row && row.order) || "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
function receivableFilterLabel(row) {
|
||||||
|
var amount = Number((row && row.recv) || 0);
|
||||||
|
if (amount <= 0) return "미수 없음";
|
||||||
|
if (amount < 10000000) return "1천만 미만";
|
||||||
|
if (amount < 100000000) return "1천만 이상";
|
||||||
|
return "1억 이상";
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshFilterDom() {
|
||||||
|
E.filterButtons = Object.fromEntries(Array.from(document.querySelectorAll(".th-trigger")).map(function (el) {
|
||||||
|
return [el.dataset.filter, el];
|
||||||
|
}));
|
||||||
|
E.filterMenus = Object.fromEntries(Array.from(document.querySelectorAll(".th-menu")).map(function (el) {
|
||||||
|
return [el.dataset.filter, el];
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderLedgerTable() {
|
function renderLedgerTable() {
|
||||||
@@ -236,12 +331,7 @@
|
|||||||
+ '<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>'
|
+ '<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>";
|
+ "</tr>";
|
||||||
}
|
}
|
||||||
var rows = (Array.isArray(S.viewRows) ? S.viewRows : []).slice().sort(function (a, b) {
|
var rows = (Array.isArray(S.viewRows) ? S.viewRows : []).slice().sort(compareDashboardRows);
|
||||||
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;
|
S.viewRows = rows;
|
||||||
var lastGroupLabel = "";
|
var lastGroupLabel = "";
|
||||||
E.tbody.innerHTML = rows.map(function (r) {
|
E.tbody.innerHTML = rows.map(function (r) {
|
||||||
@@ -259,7 +349,7 @@
|
|||||||
+ '<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><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 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>' + esc(r.order || "-") + '</div></td>'
|
||||||
+ '<td><div class="badge ' + (String(r.status || "").indexOf("완료") >= 0 ? 'ok' : '') + '">' + esc(normalizeStatusLabel(r.status)) + '</div></td>'
|
+ '<td><div class="badge ' + (rowStatusLabel(r) === "준공" ? 'ok' : '') + '">' + esc(rowStatusLabel(r)) + '</div></td>'
|
||||||
+ '<td class="num"><strong>' + esc(won(r.cSup || 0)) + '</strong></td>'
|
+ '<td class="num"><strong>' + esc(won(r.cSup || 0)) + '</strong></td>'
|
||||||
+ '<td class="num"><strong>' + esc(r.outsourceCost ? won(r.outsourceCost) : "-") + '</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.recv || 0)) + '</strong></td>'
|
||||||
@@ -267,6 +357,8 @@
|
|||||||
+ '<td class="num"><strong style="color:' + (isSettledRow(r) ? '#b7aa93' : '#1a5645') + '">' + esc((Number(r.rate || 0)).toFixed(2) + "%") + '</strong></td>'
|
+ '<td class="num"><strong style="color:' + (isSettledRow(r) ? '#b7aa93' : '#1a5645') + '">' + esc((Number(r.rate || 0)).toFixed(2) + "%") + '</strong></td>'
|
||||||
+ '</tr>';
|
+ '</tr>';
|
||||||
}).join("");
|
}).join("");
|
||||||
|
refreshFilterDom();
|
||||||
|
if (typeof syncColumnFilters === "function") syncColumnFilters(S.all);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderCollectionBoard(r) {
|
function renderCollectionBoard(r) {
|
||||||
@@ -379,10 +471,8 @@
|
|||||||
}
|
}
|
||||||
var years = bgEnsureYear(S.all);
|
var years = bgEnsureYear(S.all);
|
||||||
var summary = bgSummarize(S.all, S.dashboard.year);
|
var summary = bgSummarize(S.all, S.dashboard.year);
|
||||||
var rows = Array.isArray(S.rows) ? S.rows : [];
|
var totals = summary.baronProjectTotals;
|
||||||
var visibleBaronProjectRows = rows.filter(isBaronProjectRow);
|
var totalRate = totals.c > 0 ? (totals.col / totals.c) * 100 : 0;
|
||||||
var totals = bgTotals(visibleBaronProjectRows);
|
|
||||||
var totalRate = typeof rate === "function" ? rate("", totals.col, totals.col + totals.recv) : 0;
|
|
||||||
var toolbarHtml = '<div class="cards-toolbar">'
|
var toolbarHtml = '<div class="cards-toolbar">'
|
||||||
+ '<div class="cards-toolbar-row">'
|
+ '<div class="cards-toolbar-row">'
|
||||||
+ years.map(function (year) {
|
+ years.map(function (year) {
|
||||||
@@ -393,14 +483,14 @@
|
|||||||
+ '<div class="cards-toolbar-metrics">'
|
+ '<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 === "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 === "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>'
|
+ '<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>";
|
+ "</div></div>";
|
||||||
var cards = [
|
var cards = [
|
||||||
{ label: summary.targetYear + "년 프로젝트", value: visibleBaronProjectRows.length.toLocaleString("ko-KR") + " 건", note: "" },
|
{ label: summary.targetYear + "년 프로젝트", value: summary.baronActiveRows.length.toLocaleString("ko-KR") + "건 (" + summary.baronSoftwareCount.toLocaleString("ko-KR") + "건)", note: "바론 수행중 프로젝트 / SW" },
|
||||||
{ label: "계약금", value: won(totals.c), note: "" },
|
{ label: "계약금 (VAT별도)", value: won(totals.c), note: "" },
|
||||||
{ label: "수금액", value: won(totals.col), note: "" },
|
{ label: "수금액", value: won(totals.col), note: "" },
|
||||||
{ label: "미수금", value: won(totals.recv), note: "" },
|
{ label: "미수금", value: won(totals.recv), note: "" },
|
||||||
{ label: "수금률(%)", value: totalRate.toFixed(2) + "%", note: "" },
|
{ label: "수금율", value: totalRate.toFixed(2) + "%", note: "계약금 대비 수금액" },
|
||||||
{ label: "경영지원서비스 금액", value: won(summary.managementTotals.c), note: "", className: "management" }
|
{ label: "경영지원서비스 금액", value: won(summary.managementTotals.c), note: "", className: "management" }
|
||||||
];
|
];
|
||||||
E.cards.innerHTML = toolbarHtml + cards.map(function (card) {
|
E.cards.innerHTML = toolbarHtml + cards.map(function (card) {
|
||||||
@@ -429,9 +519,62 @@
|
|||||||
S.rows = searched.filter(function (r) {
|
S.rows = searched.filter(function (r) {
|
||||||
return bgMatches(r) && matchesColumnFilters(r);
|
return bgMatches(r) && matchesColumnFilters(r);
|
||||||
});
|
});
|
||||||
|
S.rows.sort(compareDashboardRows);
|
||||||
render();
|
render();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
filterDefinitions = function () {
|
||||||
|
return [
|
||||||
|
{ key: "cat", map: filterCategoryLabel },
|
||||||
|
{ key: "code", map: function (r) { return r.code || "-"; } },
|
||||||
|
{ key: "name", map: function (r) { return r.name || "-"; } },
|
||||||
|
{ key: "client", map: filterClientLabel },
|
||||||
|
{ key: "order", map: filterOrderLabel },
|
||||||
|
{ key: "status", map: rowStatusLabel },
|
||||||
|
{ key: "amount", map: amountFilterLabel },
|
||||||
|
{ key: "outsource", map: outsourceFilterLabel },
|
||||||
|
{ key: "receivable", map: receivableFilterLabel },
|
||||||
|
{ key: "collected", map: collectedFilterLabel },
|
||||||
|
{ key: "rate", map: rateFilterLabel }
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
updateFilterButtons = function () {
|
||||||
|
Object.keys(E.filterButtons || {}).forEach(function (key) {
|
||||||
|
var btn = E.filterButtons[key];
|
||||||
|
if (!btn) return;
|
||||||
|
var active = !!S.filters[key];
|
||||||
|
btn.classList.toggle("active", active);
|
||||||
|
btn.title = active ? ((btn.dataset.label || "") + ": " + S.filters[key]) : (btn.dataset.label || "");
|
||||||
|
var mark = btn.querySelector(".th-mark");
|
||||||
|
if (mark) mark.textContent = active ? "•" : "";
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
syncColumnFilters = function (rows) {
|
||||||
|
filterDefinitions().forEach(function (def) {
|
||||||
|
var values = uniqueFilterValues(rows, def.map);
|
||||||
|
if (S.filters[def.key] && !values.includes(S.filters[def.key])) delete S.filters[def.key];
|
||||||
|
renderFilterMenu(def.key, values);
|
||||||
|
});
|
||||||
|
updateFilterButtons();
|
||||||
|
};
|
||||||
|
|
||||||
|
matchesColumnFilters = function (r) {
|
||||||
|
if (S.filters.cat && filterCategoryLabel(r) !== S.filters.cat) return false;
|
||||||
|
if (S.filters.code && (r.code || "-") !== S.filters.code) return false;
|
||||||
|
if (S.filters.name && (r.name || "-") !== S.filters.name) return false;
|
||||||
|
if (S.filters.client && filterClientLabel(r) !== S.filters.client) return false;
|
||||||
|
if (S.filters.order && filterOrderLabel(r) !== S.filters.order) return false;
|
||||||
|
if (S.filters.status && rowStatusLabel(r) !== S.filters.status) return false;
|
||||||
|
if (S.filters.amount && amountFilterLabel(r) !== S.filters.amount) return false;
|
||||||
|
if (S.filters.outsource && outsourceFilterLabel(r) !== S.filters.outsource) return false;
|
||||||
|
if (S.filters.receivable && receivableFilterLabel(r) !== S.filters.receivable) return false;
|
||||||
|
if (S.filters.collected && collectedFilterLabel(r) !== S.filters.collected) return false;
|
||||||
|
if (S.filters.rate && rateFilterLabel(r) !== S.filters.rate) return false;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
if (E.cards && !E.cards.dataset.dashboardBound) {
|
if (E.cards && !E.cards.dataset.dashboardBound) {
|
||||||
E.cards.dataset.dashboardBound = "true";
|
E.cards.dataset.dashboardBound = "true";
|
||||||
E.cards.addEventListener("click", function (event) {
|
E.cards.addEventListener("click", function (event) {
|
||||||
@@ -472,6 +615,26 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var panel = document.querySelector(".panel");
|
||||||
|
if (panel && !panel.dataset.ledgerFilterBound) {
|
||||||
|
panel.dataset.ledgerFilterBound = "true";
|
||||||
|
panel.addEventListener("click", function (event) {
|
||||||
|
var trigger = event.target && event.target.closest ? event.target.closest(".th-trigger") : null;
|
||||||
|
if (trigger) {
|
||||||
|
refreshFilterDom();
|
||||||
|
event.stopPropagation();
|
||||||
|
toggleFilterMenu(trigger.dataset.filter);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var option = event.target && event.target.closest ? event.target.closest("button[data-filter-value]") : null;
|
||||||
|
var menu = event.target && event.target.closest ? event.target.closest(".th-menu") : null;
|
||||||
|
if (option && menu) {
|
||||||
|
event.stopPropagation();
|
||||||
|
setFilterValue(menu.dataset.filter, option.getAttribute("data-filter-value") || "");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
setTimeout(function () {
|
setTimeout(function () {
|
||||||
try {
|
try {
|
||||||
filter();
|
filter();
|
||||||
|
|||||||
16
frontend/apps/organization/README.md
Normal file
16
frontend/apps/organization/README.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# Organization App
|
||||||
|
|
||||||
|
조직현황 탭 전용 소스 디렉터리다.
|
||||||
|
|
||||||
|
- 편집 원본:
|
||||||
|
- `index.html`
|
||||||
|
- `assets/common.css`
|
||||||
|
- `assets/organization.css`
|
||||||
|
- `assets/organization.js`
|
||||||
|
- 실제 서빙 대상:
|
||||||
|
- `DashBoard-organization.html`
|
||||||
|
- `legacy/static/common.css`
|
||||||
|
- `legacy/static/organization.css`
|
||||||
|
- `legacy/static/organization.js`
|
||||||
|
|
||||||
|
수정 후에는 `scripts/publish_organization_app.sh`를 실행해 서빙 파일로 반영한다.
|
||||||
110
frontend/apps/organization/assets/common.css
Normal file
110
frontend/apps/organization/assets/common.css
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
@import url("/design-tokens.css?v=20260401-01");
|
||||||
|
@import url("/design-patterns.css?v=20260401-01");
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--font-sans: var(--ds-font-sans);
|
||||||
|
|
||||||
|
--color-bg: var(--ds-bg);
|
||||||
|
--color-bg-soft: var(--ds-bg-soft);
|
||||||
|
--color-surface: var(--ds-panel);
|
||||||
|
--color-surface-soft: var(--ds-panel-soft);
|
||||||
|
--color-surface-strong: var(--ds-panel-strong);
|
||||||
|
--color-text: var(--ds-ink);
|
||||||
|
--color-text-soft: var(--ds-text-soft);
|
||||||
|
--color-text-muted: var(--ds-text-muted);
|
||||||
|
--color-border: var(--ds-line);
|
||||||
|
--color-border-soft: var(--ds-line-soft);
|
||||||
|
--color-header: var(--ds-brand);
|
||||||
|
--color-header-soft: var(--ds-brand-soft);
|
||||||
|
--color-accent: var(--ds-accent);
|
||||||
|
--color-accent-soft: var(--ds-accent-soft);
|
||||||
|
--color-accent-strong: var(--ds-accent-strong);
|
||||||
|
|
||||||
|
--radius-sm: var(--ds-radius-sm);
|
||||||
|
--radius-md: var(--ds-radius-md);
|
||||||
|
--radius-lg: var(--ds-radius-lg);
|
||||||
|
--radius-xl: var(--ds-radius-xl);
|
||||||
|
--radius-pill: var(--ds-radius-pill);
|
||||||
|
|
||||||
|
--shadow-soft: var(--ds-shadow-soft);
|
||||||
|
--shadow-card: var(--ds-shadow-card);
|
||||||
|
--shadow-float: var(--ds-shadow-float);
|
||||||
|
|
||||||
|
--space-1: var(--ds-space-1);
|
||||||
|
--space-2: var(--ds-space-2);
|
||||||
|
--space-3: var(--ds-space-3);
|
||||||
|
--space-4: var(--ds-space-4);
|
||||||
|
--space-5: var(--ds-space-5);
|
||||||
|
--space-6: var(--ds-space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 100%;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
color: var(--color-text);
|
||||||
|
background: var(--ds-bg-gradient);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--ds-bg-gradient);
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea,
|
||||||
|
a {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-card {
|
||||||
|
background: var(--color-surface-soft);
|
||||||
|
border: 1px solid var(--color-border-soft);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-button-primary {
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
background: var(--color-accent);
|
||||||
|
box-shadow: var(--shadow-float);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-button-secondary {
|
||||||
|
border: 1px solid var(--color-border-soft);
|
||||||
|
color: var(--color-text);
|
||||||
|
background: var(--ds-surface-tint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-input {
|
||||||
|
border: 1px solid var(--color-border-soft);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--ds-surface-tint-strong);
|
||||||
|
color: var(--color-text);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-input:focus {
|
||||||
|
border-color: rgba(47, 153, 115, 0.45);
|
||||||
|
box-shadow: 0 0 0 4px rgba(47, 153, 115, 0.1);
|
||||||
|
}
|
||||||
1744
frontend/apps/organization/assets/organization.css
Normal file
1744
frontend/apps/organization/assets/organization.css
Normal file
File diff suppressed because it is too large
Load Diff
1979
frontend/apps/organization/assets/organization.js
Normal file
1979
frontend/apps/organization/assets/organization.js
Normal file
File diff suppressed because it is too large
Load Diff
65
frontend/apps/organization/index.html
Normal file
65
frontend/apps/organization/index.html
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>MH 조직현황 관리</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Pretendard:wght@400;600;700;900&display=swap" rel="stylesheet" />
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<link rel="stylesheet" href="/legacy/static/common.css?v=20260402-02" />
|
||||||
|
<link rel="stylesheet" href="/legacy/static/organization.css?v=20260402-02" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<input type="file" id="upload-excel" class="hidden" accept=".xlsx, .csv" />
|
||||||
|
|
||||||
|
<div class="search-section">
|
||||||
|
<div class="flex flex-col w-full">
|
||||||
|
<div class="relative flex items-center w-full">
|
||||||
|
<span class="search-icon">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
|
||||||
|
</span>
|
||||||
|
<input type="text" id="search-input" placeholder="이름 또는 조직 검색" class="search-input" />
|
||||||
|
</div>
|
||||||
|
<div id="dept-tabs" class="dept-tabs-container"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="stats-area" class="stats-section" style="padding: 10px 15px;">
|
||||||
|
<div class="flex justify-between items-center mb-0 cursor-pointer p-0" id="stats-header">
|
||||||
|
<h2 class="stats-title text-xs font-black text-slate-800 flex items-center gap-2">인원 현황 통계 <span id="total-count-badge" class="bg-indigo-100 text-indigo-600 text-[10px] px-2 py-0.5 rounded-full">0명</span></h2>
|
||||||
|
<span id="stats-toggle-icon" class="text-slate-400 text-xs transition-transform duration-200" style="transform: rotate(-90deg);">▼</span>
|
||||||
|
</div>
|
||||||
|
<div id="stats-table-container" class="mt-3 overflow-hidden transition-all duration-300" style="display: none;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="tree-root" class="org-canvas">
|
||||||
|
<div class="text-slate-400 font-bold mt-20 text-xs text-center">서버에서 조직 데이터를 불러오는 중입니다.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="admin-mode-btn" class="admin-mode-btn" data-label="관리자 모드 전환">🔐</button>
|
||||||
|
|
||||||
|
<div id="last-updated" class="fixed bottom-4 left-5 text-[10px] text-slate-400 font-bold z-[4000] pointer-events-none" style="letter-spacing: 0.02em; opacity: 0.8;"></div>
|
||||||
|
|
||||||
|
<div class="fab-container" id="fab-container">
|
||||||
|
<button class="fab-main text-white" id="fab-main">+</button>
|
||||||
|
<div class="fab-menu" id="fab-menu"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div id="modal-header-area">
|
||||||
|
<h2 id="modal-title" class="text-xl font-black mb-6 text-slate-800 border-b pb-4">구성원 정보 수정</h2>
|
||||||
|
</div>
|
||||||
|
<div id="modal-fields" class="grid grid-cols-2 gap-x-8 gap-y-5"></div>
|
||||||
|
<div id="modal-footer-area" class="flex gap-4 mt-10">
|
||||||
|
<button id="modal-cancel-btn" class="flex-1 bg-slate-100 py-3.5 rounded-xl font-bold text-sm">취소</button>
|
||||||
|
<button id="btn-save" class="flex-1 bg-indigo-600 text-white py-3.5 rounded-xl font-bold text-sm">저장</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/legacy/static/organization.js?v=20260402-02"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -23,6 +23,8 @@ const projectFrame = document.getElementById("project-frame");
|
|||||||
const projectStage = document.getElementById("project-stage");
|
const projectStage = document.getElementById("project-stage");
|
||||||
const teamFrame = document.getElementById("team-frame");
|
const teamFrame = document.getElementById("team-frame");
|
||||||
const teamStage = document.getElementById("team-stage");
|
const teamStage = document.getElementById("team-stage");
|
||||||
|
const dbStatusFrame = document.getElementById("db-status-frame");
|
||||||
|
const dbStatusStage = document.getElementById("db-status-stage");
|
||||||
const seatMapAdminStage = document.getElementById("seatmap-admin-stage");
|
const seatMapAdminStage = document.getElementById("seatmap-admin-stage");
|
||||||
const seatMapReadonlyStage = document.getElementById("seatmap-readonly-stage");
|
const seatMapReadonlyStage = document.getElementById("seatmap-readonly-stage");
|
||||||
const emptyStage = document.getElementById("empty-stage");
|
const emptyStage = document.getElementById("empty-stage");
|
||||||
@@ -48,6 +50,7 @@ const seatMapDom = {
|
|||||||
formGap: null,
|
formGap: null,
|
||||||
formImage: document.getElementById("seatmap-admin-form-image"),
|
formImage: document.getElementById("seatmap-admin-form-image"),
|
||||||
search: document.getElementById("seatmap-admin-search"),
|
search: document.getElementById("seatmap-admin-search"),
|
||||||
|
context: document.getElementById("seatmap-admin-context"),
|
||||||
unassigned: document.getElementById("seatmap-admin-unassigned"),
|
unassigned: document.getElementById("seatmap-admin-unassigned"),
|
||||||
officeTabs: document.getElementById("seatmap-admin-office-tabs"),
|
officeTabs: document.getElementById("seatmap-admin-office-tabs"),
|
||||||
sidebarTitle: document.getElementById("seatmap-admin-sidebar-title"),
|
sidebarTitle: document.getElementById("seatmap-admin-sidebar-title"),
|
||||||
@@ -73,6 +76,7 @@ const seatMapDom = {
|
|||||||
formGap: null,
|
formGap: null,
|
||||||
formImage: null,
|
formImage: null,
|
||||||
search: document.getElementById("seatmap-readonly-search"),
|
search: document.getElementById("seatmap-readonly-search"),
|
||||||
|
context: document.getElementById("seatmap-readonly-context"),
|
||||||
unassigned: document.getElementById("seatmap-readonly-unassigned"),
|
unassigned: document.getElementById("seatmap-readonly-unassigned"),
|
||||||
officeTabs: document.getElementById("seatmap-readonly-office-tabs"),
|
officeTabs: document.getElementById("seatmap-readonly-office-tabs"),
|
||||||
sidebarTitle: document.getElementById("seatmap-readonly-sidebar-title"),
|
sidebarTitle: document.getElementById("seatmap-readonly-sidebar-title"),
|
||||||
@@ -98,6 +102,7 @@ let seatMapFormCols = seatMapDom.admin.formCols;
|
|||||||
let seatMapFormGap = seatMapDom.admin.formGap;
|
let seatMapFormGap = seatMapDom.admin.formGap;
|
||||||
let seatMapFormImage = seatMapDom.admin.formImage;
|
let seatMapFormImage = seatMapDom.admin.formImage;
|
||||||
let seatMapSearch = seatMapDom.admin.search;
|
let seatMapSearch = seatMapDom.admin.search;
|
||||||
|
let seatMapContext = seatMapDom.admin.context;
|
||||||
let seatMapUnassigned = seatMapDom.admin.unassigned;
|
let seatMapUnassigned = seatMapDom.admin.unassigned;
|
||||||
let seatMapOfficeTabs = seatMapDom.admin.officeTabs;
|
let seatMapOfficeTabs = seatMapDom.admin.officeTabs;
|
||||||
let seatMapSidebarTitle = seatMapDom.admin.sidebarTitle;
|
let seatMapSidebarTitle = seatMapDom.admin.sidebarTitle;
|
||||||
@@ -115,6 +120,7 @@ const viewLabels = {
|
|||||||
project: "프로젝트별 분석",
|
project: "프로젝트별 분석",
|
||||||
team: "팀/개인별 분석",
|
team: "팀/개인별 분석",
|
||||||
organization: "조직 현황",
|
organization: "조직 현황",
|
||||||
|
"db-status": "DB 상태",
|
||||||
"seatmap-admin": "자리배치도",
|
"seatmap-admin": "자리배치도",
|
||||||
"seatmap-readonly": "자리배치도",
|
"seatmap-readonly": "자리배치도",
|
||||||
};
|
};
|
||||||
@@ -132,6 +138,7 @@ const seatMapState = {
|
|||||||
search: "",
|
search: "",
|
||||||
status: "",
|
status: "",
|
||||||
statusTone: "info",
|
statusTone: "info",
|
||||||
|
selectedMemberId: null,
|
||||||
draggingMemberId: null,
|
draggingMemberId: null,
|
||||||
zoom: 1,
|
zoom: 1,
|
||||||
panning: false,
|
panning: false,
|
||||||
@@ -488,6 +495,7 @@ function syncSeatMapDomRefs() {
|
|||||||
seatMapFormGap = dom.formGap;
|
seatMapFormGap = dom.formGap;
|
||||||
seatMapFormImage = dom.formImage;
|
seatMapFormImage = dom.formImage;
|
||||||
seatMapSearch = dom.search;
|
seatMapSearch = dom.search;
|
||||||
|
seatMapContext = dom.context;
|
||||||
seatMapUnassigned = dom.unassigned;
|
seatMapUnassigned = dom.unassigned;
|
||||||
seatMapOfficeTabs = dom.officeTabs;
|
seatMapOfficeTabs = dom.officeTabs;
|
||||||
seatMapSidebarTitle = dom.sidebarTitle;
|
seatMapSidebarTitle = dom.sidebarTitle;
|
||||||
@@ -834,6 +842,98 @@ function getMemberMap() {
|
|||||||
return new Map(seatMapState.members.map((member) => [Number(member.id), member]));
|
return new Map(seatMapState.members.map((member) => [Number(member.id), member]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildSeatMapTeamTone(teamName) {
|
||||||
|
const team = String(teamName || "").trim();
|
||||||
|
if (!team) {
|
||||||
|
return {
|
||||||
|
accent: "rgba(120, 132, 119, 0.86)",
|
||||||
|
soft: "rgba(120, 132, 119, 0.18)",
|
||||||
|
label: "미분류",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const palette = [
|
||||||
|
{ accent: "rgba(13, 148, 136, 0.9)", soft: "rgba(13, 148, 136, 0.18)" },
|
||||||
|
{ accent: "rgba(202, 138, 4, 0.9)", soft: "rgba(202, 138, 4, 0.18)" },
|
||||||
|
{ accent: "rgba(5, 150, 105, 0.9)", soft: "rgba(5, 150, 105, 0.18)" },
|
||||||
|
{ accent: "rgba(180, 83, 9, 0.9)", soft: "rgba(180, 83, 9, 0.18)" },
|
||||||
|
{ accent: "rgba(20, 83, 45, 0.9)", soft: "rgba(20, 83, 45, 0.16)" },
|
||||||
|
{ accent: "rgba(11, 110, 79, 0.9)", soft: "rgba(11, 110, 79, 0.18)" },
|
||||||
|
];
|
||||||
|
let hash = 0;
|
||||||
|
for (const char of team) {
|
||||||
|
hash = ((hash * 31) + char.charCodeAt(0)) % 2147483647;
|
||||||
|
}
|
||||||
|
const picked = palette[Math.abs(hash) % palette.length];
|
||||||
|
return { ...picked, label: team };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSeatMapTeamStyle(member) {
|
||||||
|
const tone = buildSeatMapTeamTone(getSeatMapOrgUnitLabel(member));
|
||||||
|
return `--seatmap-team-accent:${tone.accent}; --seatmap-team-soft:${tone.soft};`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSeatMapTeamChip(member) {
|
||||||
|
const label = getSeatMapOrgUnitLabel(member);
|
||||||
|
const tone = buildSeatMapTeamTone(label);
|
||||||
|
return `<span class="seatmap-team-chip" style="${escapeHtml(getSeatMapTeamStyle({ team: label }))}">${escapeHtml(label)}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSeatMapOrgPath(member) {
|
||||||
|
const values = [
|
||||||
|
member?.department,
|
||||||
|
member?.grp,
|
||||||
|
member?.division,
|
||||||
|
member?.team,
|
||||||
|
member?.cell,
|
||||||
|
]
|
||||||
|
.map((value) => String(value || "").trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
return values.filter((value, index) => values.indexOf(value) === index);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSeatMapOrgUnitLabel(member) {
|
||||||
|
return String(
|
||||||
|
member?.team
|
||||||
|
|| member?.division
|
||||||
|
|| member?.grp
|
||||||
|
|| member?.department
|
||||||
|
|| member?.cell
|
||||||
|
|| "미분류"
|
||||||
|
).trim() || "미분류";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSeatMapOverlayTeamLabel(member) {
|
||||||
|
return String(member?.team || "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSeatMapMemberContext(memberId) {
|
||||||
|
if (!seatMapContext) return;
|
||||||
|
const member = memberId ? getMemberMap().get(Number(memberId)) : null;
|
||||||
|
seatMapState.selectedMemberId = member ? Number(member.id) : null;
|
||||||
|
if (!member) {
|
||||||
|
seatMapContext.classList.add("hidden");
|
||||||
|
seatMapContext.innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tone = buildSeatMapTeamTone(member.team);
|
||||||
|
const orgPath = getSeatMapOrgPath(member);
|
||||||
|
seatMapContext.classList.remove("hidden");
|
||||||
|
seatMapContext.innerHTML = `
|
||||||
|
<div class="seatmap-context-head">
|
||||||
|
<div class="seatmap-context-title">
|
||||||
|
<strong>${escapeHtml(member.name || "-")}</strong>
|
||||||
|
<span>${escapeHtml(member.rank || member.role || "구성원")}</span>
|
||||||
|
</div>
|
||||||
|
<span class="seatmap-context-badge" style="${escapeHtml(getSeatMapTeamStyle(member))}">${escapeHtml(tone.label)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="seatmap-context-tree">
|
||||||
|
${orgPath.length
|
||||||
|
? orgPath.map((item) => `<span class="seatmap-context-node">${escapeHtml(item)}</span>`).join("")
|
||||||
|
: '<div class="seatmap-context-empty">표시할 상위 조직 정보가 없습니다.</div>'}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
function getPlacementForMember(memberId) {
|
function getPlacementForMember(memberId) {
|
||||||
return getPlacementSource().find((item) => Number(item.member_id) === Number(memberId)) || null;
|
return getPlacementSource().find((item) => Number(item.member_id) === Number(memberId)) || null;
|
||||||
}
|
}
|
||||||
@@ -868,9 +968,13 @@ function getSidebarMembers() {
|
|||||||
return members.filter(memberMatchesSeatMapSearch);
|
return members.filter(memberMatchesSeatMapSearch);
|
||||||
}
|
}
|
||||||
|
|
||||||
function focusSeatMapMember(memberId) {
|
function focusSeatMapMember(memberId, options = {}) {
|
||||||
|
const showContext = options.showContext !== false;
|
||||||
const placement = getPlacementForMember(memberId);
|
const placement = getPlacementForMember(memberId);
|
||||||
const member = getMemberMap().get(Number(memberId));
|
const member = getMemberMap().get(Number(memberId));
|
||||||
|
if (showContext) {
|
||||||
|
renderSeatMapMemberContext(memberId);
|
||||||
|
}
|
||||||
if (!placement) {
|
if (!placement) {
|
||||||
setSeatMapStatus("해당 인원은 아직 배치되지 않았습니다.", "info");
|
setSeatMapStatus("해당 인원은 아직 배치되지 않았습니다.", "info");
|
||||||
return;
|
return;
|
||||||
@@ -940,6 +1044,46 @@ function getSlotPlacementMap() {
|
|||||||
return slotMap;
|
return slotMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildSeatMapGridTeamOverlays(rows, cols, placementMap, memberMap) {
|
||||||
|
const groups = new Map();
|
||||||
|
placementMap.forEach((placement) => {
|
||||||
|
const member = memberMap.get(Number(placement.member_id));
|
||||||
|
if (!member) return;
|
||||||
|
const label = getSeatMapOverlayTeamLabel(member);
|
||||||
|
if (!label) return;
|
||||||
|
const rowIndex = Number(placement.row_index);
|
||||||
|
const colIndex = Number(placement.col_index);
|
||||||
|
if (!Number.isFinite(rowIndex) || !Number.isFinite(colIndex)) return;
|
||||||
|
if (!groups.has(label)) {
|
||||||
|
groups.set(label, {
|
||||||
|
label,
|
||||||
|
toneMember: { team: label },
|
||||||
|
minRow: rowIndex,
|
||||||
|
maxRow: rowIndex,
|
||||||
|
minCol: colIndex,
|
||||||
|
maxCol: colIndex,
|
||||||
|
count: 1,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const group = groups.get(label);
|
||||||
|
group.minRow = Math.min(group.minRow, rowIndex);
|
||||||
|
group.maxRow = Math.max(group.maxRow, rowIndex);
|
||||||
|
group.minCol = Math.min(group.minCol, colIndex);
|
||||||
|
group.maxCol = Math.max(group.maxCol, colIndex);
|
||||||
|
group.count += 1;
|
||||||
|
});
|
||||||
|
return Array.from(groups.values())
|
||||||
|
.filter((group) => group.count >= 2 && group.maxRow < rows && group.maxCol < cols)
|
||||||
|
.map((group) => `
|
||||||
|
<div
|
||||||
|
class="seatmap-team-overlay"
|
||||||
|
data-team-label="${escapeHtml(group.label)}"
|
||||||
|
style="${escapeHtml(getSeatMapTeamStyle(group.toneMember))}; grid-column:${group.minCol + 1} / ${group.maxCol + 2}; grid-row:${group.minRow + 1} / ${group.maxRow + 2};"
|
||||||
|
></div>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
function upsertDraftPlacement(memberId, rowIndex, colIndex) {
|
function upsertDraftPlacement(memberId, rowIndex, colIndex) {
|
||||||
const cellMap = getCellPlacementMap();
|
const cellMap = getCellPlacementMap();
|
||||||
const existing = cellMap.get(`${rowIndex}:${colIndex}`);
|
const existing = cellMap.get(`${rowIndex}:${colIndex}`);
|
||||||
@@ -1000,11 +1144,12 @@ function renderMemberCard(member, draggable) {
|
|||||||
? `<span class="seatmap-member-avatar"><img src="${photoUrl}" alt="${escapeHtml(member.name)}"></span>`
|
? `<span class="seatmap-member-avatar"><img src="${photoUrl}" alt="${escapeHtml(member.name)}"></span>`
|
||||||
: `<span class="seatmap-member-avatar seatmap-member-avatar-fallback">${escapeHtml(getInitials(member.name))}</span>`;
|
: `<span class="seatmap-member-avatar seatmap-member-avatar-fallback">${escapeHtml(getInitials(member.name))}</span>`;
|
||||||
return `
|
return `
|
||||||
<div class="seatmap-member-card${draggable ? " draggable" : ""}" draggable="${draggable}" data-member-id="${Number(member.id)}">
|
<div class="seatmap-member-card team-colored${draggable ? " draggable" : ""}" style="${escapeHtml(getSeatMapTeamStyle(member))}" draggable="${draggable}" data-member-id="${Number(member.id)}">
|
||||||
${avatar}
|
${avatar}
|
||||||
<span class="seatmap-member-text">
|
<span class="seatmap-member-text">
|
||||||
<strong>${escapeHtml(member.name || "-")}</strong>
|
<strong>${escapeHtml(member.name || "-")}</strong>
|
||||||
<em>${escapeHtml(member.department || member.team || member.rank || "-")}</em>
|
<em>${escapeHtml(member.rank || "-")}</em>
|
||||||
|
<span class="seatmap-team-chip" style="${escapeHtml(getSeatMapTeamStyle({ team: getSeatMapOrgUnitLabel(member) }))}">${escapeHtml(getSeatMapOrgUnitLabel(member))}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -1012,11 +1157,12 @@ function renderMemberCard(member, draggable) {
|
|||||||
|
|
||||||
function renderUnassignedMemberCard(member, draggable) {
|
function renderUnassignedMemberCard(member, draggable) {
|
||||||
return `
|
return `
|
||||||
<div class="seatmap-member-card seatmap-member-card-compact${draggable ? " draggable" : ""}" draggable="${draggable}" data-member-id="${Number(member.id)}">
|
<div class="seatmap-member-card seatmap-member-card-compact${draggable ? " draggable" : ""}" style="${escapeHtml(getSeatMapTeamStyle(member))}" draggable="${draggable}" data-member-id="${Number(member.id)}">
|
||||||
<span class="seatmap-member-text seatmap-member-text-inline">
|
<span class="seatmap-member-text seatmap-member-text-inline">
|
||||||
<strong>${escapeHtml(member.name || "-")}</strong>
|
<strong>${escapeHtml(member.name || "-")}</strong>
|
||||||
<em>${escapeHtml(member.rank || "-")}</em>
|
<em>${escapeHtml(member.rank || "-")}</em>
|
||||||
</span>
|
</span>
|
||||||
|
${renderSeatMapTeamChip(member)}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -1024,14 +1170,13 @@ function renderUnassignedMemberCard(member, draggable) {
|
|||||||
function renderSeatMapSearchCard(member) {
|
function renderSeatMapSearchCard(member) {
|
||||||
const placement = getPlacementForMember(Number(member.id));
|
const placement = getPlacementForMember(Number(member.id));
|
||||||
if (!placement) return "";
|
if (!placement) return "";
|
||||||
const badge = `<span class="seatmap-member-badge occupied">${escapeHtml(placement.seat_label || "배치완료")}</span>`;
|
|
||||||
return `
|
return `
|
||||||
<button class="seatmap-member-search-card" type="button" data-member-id="${Number(member.id)}">
|
<button class="seatmap-member-search-card" type="button" data-member-id="${Number(member.id)}">
|
||||||
<span class="seatmap-member-text seatmap-member-text-inline">
|
<span class="seatmap-member-text seatmap-member-text-inline">
|
||||||
<strong>${escapeHtml(member.name || "-")}</strong>
|
<strong>${escapeHtml(member.name || "-")}</strong>
|
||||||
<em>${escapeHtml(member.rank || member.department || "-")}</em>
|
<em>${escapeHtml(member.rank || "-")}</em>
|
||||||
</span>
|
</span>
|
||||||
${badge}
|
${renderSeatMapTeamChip(member)}
|
||||||
</button>
|
</button>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -1051,14 +1196,16 @@ function renderSeatMapBoard() {
|
|||||||
const gap = Number(seatMapState.seatMap.cell_gap || 0);
|
const gap = Number(seatMapState.seatMap.cell_gap || 0);
|
||||||
const editable = seatMapState.editMode && isAdmin();
|
const editable = seatMapState.editMode && isAdmin();
|
||||||
const cells = [];
|
const cells = [];
|
||||||
|
const overlays = buildSeatMapGridTeamOverlays(rows, cols, placementMap, memberMap);
|
||||||
|
|
||||||
for (let rowIndex = 0; rowIndex < rows; rowIndex += 1) {
|
for (let rowIndex = 0; rowIndex < rows; rowIndex += 1) {
|
||||||
for (let colIndex = 0; colIndex < cols; colIndex += 1) {
|
for (let colIndex = 0; colIndex < cols; colIndex += 1) {
|
||||||
const key = `${rowIndex}:${colIndex}`;
|
const key = `${rowIndex}:${colIndex}`;
|
||||||
const placement = placementMap.get(key);
|
const placement = placementMap.get(key);
|
||||||
const member = placement ? memberMap.get(Number(placement.member_id)) : null;
|
const member = placement ? memberMap.get(Number(placement.member_id)) : null;
|
||||||
|
const teamStyle = member ? ` style="${escapeHtml(getSeatMapTeamStyle(member))}"` : "";
|
||||||
cells.push(`
|
cells.push(`
|
||||||
<div class="seatmap-cell${placement ? " occupied" : ""}${editable ? " editable" : ""}" data-row="${rowIndex}" data-col="${colIndex}">
|
<div class="seatmap-cell${placement ? " occupied" : ""}${member ? " team-colored" : ""}${editable ? " editable" : ""}" data-row="${rowIndex}" data-col="${colIndex}"${teamStyle}>
|
||||||
<span class="seatmap-cell-label">${escapeHtml(computeSeatLabel(rowIndex, colIndex))}</span>
|
<span class="seatmap-cell-label">${escapeHtml(computeSeatLabel(rowIndex, colIndex))}</span>
|
||||||
${member ? renderMemberCard(member, editable) : ""}
|
${member ? renderMemberCard(member, editable) : ""}
|
||||||
</div>
|
</div>
|
||||||
@@ -1069,7 +1216,7 @@ function renderSeatMapBoard() {
|
|||||||
seatMapBoard.innerHTML = `
|
seatMapBoard.innerHTML = `
|
||||||
<div class="seatmap-canvas" style="--seatmap-rows:${rows}; --seatmap-cols:${cols}; --seatmap-gap:${gap}px;">
|
<div class="seatmap-canvas" style="--seatmap-rows:${rows}; --seatmap-cols:${cols}; --seatmap-gap:${gap}px;">
|
||||||
<img class="seatmap-image" src="${escapeHtml(seatMapState.seatMap.image_url)}" alt="${escapeHtml(seatMapState.seatMap.name)}">
|
<img class="seatmap-image" src="${escapeHtml(seatMapState.seatMap.image_url)}" alt="${escapeHtml(seatMapState.seatMap.name)}">
|
||||||
<div class="seatmap-grid">${cells.join("")}</div>
|
<div class="seatmap-grid">${overlays.join("")}${cells.join("")}</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -1172,6 +1319,7 @@ function renderSeatMapActions() {
|
|||||||
function updateSeatMapDraftUi() {
|
function updateSeatMapDraftUi() {
|
||||||
renderSeatMapActions();
|
renderSeatMapActions();
|
||||||
renderUnassignedMembers();
|
renderUnassignedMembers();
|
||||||
|
renderSeatMapMemberContext(seatMapState.selectedMemberId);
|
||||||
syncSeatMapViewerFrame();
|
syncSeatMapViewerFrame();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1391,6 +1539,9 @@ function handleEmbeddedNavigationMessage(event) {
|
|||||||
updateSeatMapDraftUi();
|
updateSeatMapDraftUi();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (data.type === "seatmap-member-selected") {
|
||||||
|
renderSeatMapMemberContext(Number(data.memberId || 0) || null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchJson(url, options) {
|
async function fetchJson(url, options) {
|
||||||
@@ -1434,6 +1585,7 @@ async function loadSeatMapData(force = false) {
|
|||||||
seatMapState.placements = clonePlacements(layoutPayload.placements || []);
|
seatMapState.placements = clonePlacements(layoutPayload.placements || []);
|
||||||
seatMapState.zoom = 1;
|
seatMapState.zoom = 1;
|
||||||
seatMapState.hoveredSlotId = null;
|
seatMapState.hoveredSlotId = null;
|
||||||
|
seatMapState.selectedMemberId = null;
|
||||||
seatMapState.editMode = canEditSeatMap();
|
seatMapState.editMode = canEditSeatMap();
|
||||||
resetSeatMapDraft();
|
resetSeatMapDraft();
|
||||||
seatMapState.loaded = true;
|
seatMapState.loaded = true;
|
||||||
@@ -1447,6 +1599,7 @@ async function loadSeatMapData(force = false) {
|
|||||||
seatMapState.placements = [];
|
seatMapState.placements = [];
|
||||||
seatMapState.zoom = 1;
|
seatMapState.zoom = 1;
|
||||||
seatMapState.hoveredSlotId = null;
|
seatMapState.hoveredSlotId = null;
|
||||||
|
seatMapState.selectedMemberId = null;
|
||||||
seatMapState.editMode = canEditSeatMap();
|
seatMapState.editMode = canEditSeatMap();
|
||||||
resetSeatMapDraft();
|
resetSeatMapDraft();
|
||||||
seatMapState.loaded = true;
|
seatMapState.loaded = true;
|
||||||
@@ -1623,6 +1776,7 @@ function setActiveView(view) {
|
|||||||
const isLedger = currentView === "ledger";
|
const isLedger = currentView === "ledger";
|
||||||
const isProject = currentView === "project";
|
const isProject = currentView === "project";
|
||||||
const isTeam = currentView === "team";
|
const isTeam = currentView === "team";
|
||||||
|
const isDbStatus = currentView === "db-status";
|
||||||
const isSeatMapAdmin = currentView === "seatmap-admin";
|
const isSeatMapAdmin = currentView === "seatmap-admin";
|
||||||
const isSeatMapReadonly = currentView === "seatmap-readonly";
|
const isSeatMapReadonly = currentView === "seatmap-readonly";
|
||||||
if (ledgerStage) {
|
if (ledgerStage) {
|
||||||
@@ -1641,6 +1795,10 @@ function setActiveView(view) {
|
|||||||
teamStage.hidden = !isTeam;
|
teamStage.hidden = !isTeam;
|
||||||
teamStage.style.display = isTeam ? "flex" : "none";
|
teamStage.style.display = isTeam ? "flex" : "none";
|
||||||
}
|
}
|
||||||
|
if (dbStatusStage) {
|
||||||
|
dbStatusStage.hidden = !isDbStatus;
|
||||||
|
dbStatusStage.style.display = isDbStatus ? "flex" : "none";
|
||||||
|
}
|
||||||
if (seatMapAdminStage) {
|
if (seatMapAdminStage) {
|
||||||
seatMapAdminStage.hidden = !isSeatMapAdmin;
|
seatMapAdminStage.hidden = !isSeatMapAdmin;
|
||||||
seatMapAdminStage.style.display = isSeatMapAdmin ? "flex" : "none";
|
seatMapAdminStage.style.display = isSeatMapAdmin ? "flex" : "none";
|
||||||
@@ -1650,7 +1808,7 @@ function setActiveView(view) {
|
|||||||
seatMapReadonlyStage.style.display = isSeatMapReadonly ? "flex" : "none";
|
seatMapReadonlyStage.style.display = isSeatMapReadonly ? "flex" : "none";
|
||||||
}
|
}
|
||||||
if (emptyStage) {
|
if (emptyStage) {
|
||||||
const showEmpty = !isLedger && !isOrganization && !isProject && !isTeam && !isSeatMapAdmin && !isSeatMapReadonly;
|
const showEmpty = !isLedger && !isOrganization && !isProject && !isTeam && !isDbStatus && !isSeatMapAdmin && !isSeatMapReadonly;
|
||||||
emptyStage.hidden = !showEmpty;
|
emptyStage.hidden = !showEmpty;
|
||||||
emptyStage.style.display = showEmpty ? "flex" : "none";
|
emptyStage.style.display = showEmpty ? "flex" : "none";
|
||||||
}
|
}
|
||||||
@@ -1677,8 +1835,12 @@ function setActiveView(view) {
|
|||||||
} else if (isTeam) {
|
} else if (isTeam) {
|
||||||
postGlobalDateRangeToFrame(teamFrame);
|
postGlobalDateRangeToFrame(teamFrame);
|
||||||
}
|
}
|
||||||
|
if (isDbStatus && previousView !== "db-status" && dbStatusFrame) {
|
||||||
|
const frameSrc = dbStatusFrame.dataset.src || dbStatusFrame.src;
|
||||||
|
dbStatusFrame.src = resolveAppUrl(frameSrc);
|
||||||
|
}
|
||||||
if (isSeatMapAdmin || isSeatMapReadonly) {
|
if (isSeatMapAdmin || isSeatMapReadonly) {
|
||||||
loadSeatMapData();
|
loadSeatMapData(previousView !== currentView);
|
||||||
}
|
}
|
||||||
notifyEmbeddedTabActivated();
|
notifyEmbeddedTabActivated();
|
||||||
}
|
}
|
||||||
@@ -1874,14 +2036,15 @@ Object.values(seatMapDom).forEach((dom) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
dom.unassigned?.addEventListener("click", (event) => {
|
dom.unassigned?.addEventListener("click", (event) => {
|
||||||
const button = event.target.closest("[data-member-id]");
|
const target = event.target.closest("[data-member-id]");
|
||||||
if (!button) return;
|
if (!target) return;
|
||||||
if (button.classList.contains("seatmap-member-search-card")) {
|
if (target.classList.contains("seatmap-member-search-card")) {
|
||||||
focusSeatMapMember(Number(button.dataset.memberId));
|
focusSeatMapMember(Number(target.dataset.memberId), { showContext: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
renderSeatMapMemberContext(Number(target.dataset.memberId));
|
||||||
if (canEditSeatMap()) return;
|
if (canEditSeatMap()) return;
|
||||||
focusSeatMapMember(Number(button.dataset.memberId));
|
focusSeatMapMember(Number(target.dataset.memberId));
|
||||||
});
|
});
|
||||||
dom.unassigned?.addEventListener("dragover", (event) => {
|
dom.unassigned?.addEventListener("dragover", (event) => {
|
||||||
if (!seatMapState.editMode) return;
|
if (!seatMapState.editMode) return;
|
||||||
@@ -1910,6 +2073,17 @@ Object.values(seatMapDom).forEach((dom) => {
|
|||||||
if (!fitButton) return;
|
if (!fitButton) return;
|
||||||
fitDxfSeatMapBoard();
|
fitDxfSeatMapBoard();
|
||||||
});
|
});
|
||||||
|
dom.board?.addEventListener("click", (event) => {
|
||||||
|
const memberCard = event.target.closest(".seatmap-member-card[data-member-id]");
|
||||||
|
if (memberCard) {
|
||||||
|
renderSeatMapMemberContext(Number(memberCard.dataset.memberId));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cell = event.target.closest(".seatmap-cell[data-row][data-col]");
|
||||||
|
if (!cell) return;
|
||||||
|
const placement = getCellPlacementMap().get(`${Number(cell.dataset.row)}:${Number(cell.dataset.col)}`);
|
||||||
|
renderSeatMapMemberContext(placement ? Number(placement.member_id) : null);
|
||||||
|
});
|
||||||
dom.board?.addEventListener("dragover", (event) => {
|
dom.board?.addEventListener("dragover", (event) => {
|
||||||
if (!seatMapState.editMode) return;
|
if (!seatMapState.editMode) return;
|
||||||
const target = isSlotBasedSeatMap()
|
const target = isSlotBasedSeatMap()
|
||||||
|
|||||||
918
frontend/public/db-status.html
Normal file
918
frontend/public/db-status.html
Normal file
@@ -0,0 +1,918 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>DB 상태</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Pretendard:wght@400;600;700;800&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="/design-tokens.css?v=20260401-01">
|
||||||
|
<link rel="stylesheet" href="/design-patterns.css?v=20260401-01">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "Pretendard", sans-serif;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(247, 217, 119, 0.28), transparent 30%),
|
||||||
|
linear-gradient(180deg, var(--ds-bg, #f5f1e8) 0%, #efe6d5 100%);
|
||||||
|
color: var(--ds-ink, #2f2419);
|
||||||
|
}
|
||||||
|
.page {
|
||||||
|
max-width: 2000px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 28px;
|
||||||
|
display: grid;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
.hero {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 28px 30px;
|
||||||
|
border: 1px solid rgba(134, 98, 47, 0.14);
|
||||||
|
border-radius: 28px;
|
||||||
|
background: linear-gradient(135deg, rgba(255, 250, 240, 0.96), rgba(242, 232, 214, 0.92));
|
||||||
|
box-shadow: 0 28px 68px rgba(88, 61, 23, 0.15);
|
||||||
|
}
|
||||||
|
.hero h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 30px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
}
|
||||||
|
.hero p {
|
||||||
|
margin: 0;
|
||||||
|
color: rgba(76, 58, 35, 0.82);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.overview {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.kpi {
|
||||||
|
padding: 18px 20px;
|
||||||
|
border-radius: 22px;
|
||||||
|
background: rgba(255, 252, 247, 0.92);
|
||||||
|
border: 1px solid rgba(140, 110, 59, 0.14);
|
||||||
|
box-shadow: 0 14px 34px rgba(81, 58, 23, 0.08);
|
||||||
|
}
|
||||||
|
.kpi-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: rgba(112, 84, 41, 0.72);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
.kpi-value {
|
||||||
|
display: block;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #3d2e1d;
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.4fr 1fr;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
.panel {
|
||||||
|
border-radius: 24px;
|
||||||
|
background: rgba(255, 251, 245, 0.96);
|
||||||
|
border: 1px solid rgba(142, 110, 54, 0.14);
|
||||||
|
box-shadow: 0 18px 48px rgba(85, 60, 24, 0.08);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.panel-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 18px 22px 14px;
|
||||||
|
border-bottom: 1px solid rgba(128, 98, 48, 0.12);
|
||||||
|
}
|
||||||
|
.panel-head h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
.panel-head p {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(102, 77, 41, 0.72);
|
||||||
|
}
|
||||||
|
.panel-body {
|
||||||
|
padding: 16px 18px 20px;
|
||||||
|
}
|
||||||
|
.panel-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.panel-body.tight {
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
.meta-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 11px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(251, 236, 196, 0.8);
|
||||||
|
color: #7a5923;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
th, td {
|
||||||
|
padding: 12px 10px;
|
||||||
|
vertical-align: top;
|
||||||
|
border-bottom: 1px solid rgba(130, 100, 53, 0.1);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: rgba(104, 79, 40, 0.76);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
tbody tr:hover {
|
||||||
|
background: rgba(250, 240, 213, 0.34);
|
||||||
|
}
|
||||||
|
.domain-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 9px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
background: rgba(90, 122, 94, 0.14);
|
||||||
|
color: #456b4c;
|
||||||
|
}
|
||||||
|
.domain-tag.integration { background: rgba(196, 143, 58, 0.16); color: #8c5f18; }
|
||||||
|
.domain-tag.history { background: rgba(120, 92, 156, 0.14); color: #6a4b8b; }
|
||||||
|
.domain-tag.auth { background: rgba(103, 114, 154, 0.14); color: #48567c; }
|
||||||
|
.domain-tag.other { background: rgba(131, 112, 80, 0.12); color: #6a5637; }
|
||||||
|
.group-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
background: rgba(240, 231, 214, 0.95);
|
||||||
|
color: #674d27;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.group-tag.keep { background: rgba(112, 143, 87, 0.18); color: #49623c; }
|
||||||
|
.group-tag.caution { background: rgba(214, 167, 84, 0.18); color: #8f5d17; }
|
||||||
|
.group-tag.trace { background: rgba(113, 120, 168, 0.16); color: #56628c; }
|
||||||
|
.group-tag.cleanup { background: rgba(184, 111, 84, 0.16); color: #884d39; }
|
||||||
|
.table-title {
|
||||||
|
font-weight: 800;
|
||||||
|
color: #2f2419;
|
||||||
|
}
|
||||||
|
.table-trigger {
|
||||||
|
all: unset;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #2f2419;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.table-trigger:hover {
|
||||||
|
color: #80591f;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.table-desc {
|
||||||
|
margin-top: 5px;
|
||||||
|
color: rgba(98, 75, 42, 0.72);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.view-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.view-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(86, 119, 93, 0.12);
|
||||||
|
color: #456b4c;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.toolbar-input {
|
||||||
|
width: min(360px, 100%);
|
||||||
|
padding: 11px 14px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid rgba(132, 102, 54, 0.16);
|
||||||
|
background: rgba(255, 251, 244, 0.96);
|
||||||
|
color: #2f2419;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
.toolbar-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: rgba(129, 88, 31, 0.34);
|
||||||
|
box-shadow: 0 0 0 4px rgba(208, 176, 116, 0.16);
|
||||||
|
}
|
||||||
|
.toolbar-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.action-button {
|
||||||
|
border: 1px solid rgba(128, 98, 48, 0.14);
|
||||||
|
background: rgba(250, 240, 213, 0.62);
|
||||||
|
color: #6c4a1a;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 9px 14px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.action-button:hover {
|
||||||
|
background: rgba(244, 228, 186, 0.8);
|
||||||
|
}
|
||||||
|
.action-button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
.notes {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
color: rgba(84, 65, 38, 0.84);
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
.mapping-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.mapping-card {
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(255, 249, 239, 0.92);
|
||||||
|
border: 1px solid rgba(132, 102, 54, 0.12);
|
||||||
|
}
|
||||||
|
.mapping-card h3 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
.mapping-card p {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: rgba(98, 75, 42, 0.74);
|
||||||
|
line-height: 1.55;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.mapping-table-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.preview-meta {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 16px 18px 0;
|
||||||
|
}
|
||||||
|
.preview-columns {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.column-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(240, 231, 214, 0.9);
|
||||||
|
color: #634a25;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.column-pill em {
|
||||||
|
font-style: normal;
|
||||||
|
color: rgba(99, 74, 37, 0.68);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.preview-table-wrap {
|
||||||
|
overflow: auto;
|
||||||
|
max-height: 520px;
|
||||||
|
border-top: 1px solid rgba(128, 98, 48, 0.12);
|
||||||
|
}
|
||||||
|
.sticky-head th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: rgba(255, 248, 236, 0.98);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.muted {
|
||||||
|
color: rgba(110, 86, 50, 0.72);
|
||||||
|
}
|
||||||
|
.empty {
|
||||||
|
padding: 22px;
|
||||||
|
text-align: center;
|
||||||
|
color: rgba(102, 77, 41, 0.72);
|
||||||
|
}
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
background: rgba(44, 31, 16, 0.42);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
.modal-overlay.open {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.modal-card {
|
||||||
|
width: min(1600px, 100%);
|
||||||
|
max-height: min(88vh, 980px);
|
||||||
|
border-radius: 28px;
|
||||||
|
background: rgba(255, 250, 243, 0.98);
|
||||||
|
border: 1px solid rgba(142, 110, 54, 0.18);
|
||||||
|
box-shadow: 0 32px 80px rgba(59, 40, 16, 0.28);
|
||||||
|
overflow: hidden;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto auto 1fr;
|
||||||
|
}
|
||||||
|
.modal-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 22px 24px 16px;
|
||||||
|
border-bottom: 1px solid rgba(128, 98, 48, 0.12);
|
||||||
|
}
|
||||||
|
.modal-head h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
}
|
||||||
|
.modal-close {
|
||||||
|
border: 0;
|
||||||
|
background: rgba(240, 229, 206, 0.9);
|
||||||
|
color: #6d5127;
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 800;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.modal-close:hover {
|
||||||
|
background: rgba(225, 208, 174, 0.96);
|
||||||
|
}
|
||||||
|
.modal-body {
|
||||||
|
overflow: hidden;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto 1fr;
|
||||||
|
}
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.grid { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.page { padding: 16px; }
|
||||||
|
.hero { padding: 22px 20px; }
|
||||||
|
.kpi-value { font-size: 24px; }
|
||||||
|
th, td { padding: 10px 8px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page">
|
||||||
|
<section class="hero">
|
||||||
|
<span class="meta-chip">#2 백엔드 영속 저장 구조 운영</span>
|
||||||
|
<h1>DB 상태와 저장 구조를 화면에서 바로 확인</h1>
|
||||||
|
<p>
|
||||||
|
이 화면은 현재 운영 DB의 핵심 테이블, 적재 상태, 최근 import 흐름을 SQL 없이 확인하기 위한 관리자용 뷰어입니다.
|
||||||
|
이후 저장 구조 검증과 데이터 정합성 작업은 이 화면을 기준으로 진행합니다.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
`원본 import 배치`는 업로드한 원본 파일이 몇 행으로 적재됐는지 보여주고, `바이너리 원본 보관`은 엑셀 같은 파일 자체를 DB에 보관하는 상태를 보여줍니다.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
아래 표는 전체 테이블을 보여주고, 오른쪽 패널은 화면별 데이터 소스와 저장 흐름을 운영 관점으로 묶어서 보여줍니다.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="overview" class="overview"></section>
|
||||||
|
|
||||||
|
<section class="grid">
|
||||||
|
<article class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<h2>전체 테이블 현황</h2>
|
||||||
|
<p>현재 운영 DB의 전체 26개 테이블을 보여주며, 테이블명을 누르면 샘플 row를 바로 확인할 수 있습니다.</p>
|
||||||
|
</div>
|
||||||
|
<span id="generated-at" class="meta-chip">로딩 중</span>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div class="panel-toolbar">
|
||||||
|
<input id="table-search" class="toolbar-input" type="search" placeholder="테이블명, 설명, 화면명으로 검색" />
|
||||||
|
<div class="toolbar-actions">
|
||||||
|
<span id="table-count" class="meta-chip">0 / 0</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>도메인</th>
|
||||||
|
<th>테이블</th>
|
||||||
|
<th>Rows</th>
|
||||||
|
<th>최근 갱신</th>
|
||||||
|
<th>연결 화면</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="table-body">
|
||||||
|
<tr><td colspan="5" class="empty">DB 상태를 불러오는 중입니다.</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<div style="display:grid; gap:20px;">
|
||||||
|
<article class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<h2>원본 import 배치</h2>
|
||||||
|
<p>현재 적재된 원본 파일 배치와 row 수입니다.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Source</th>
|
||||||
|
<th>Rows</th>
|
||||||
|
<th>Imported</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="batch-body">
|
||||||
|
<tr><td colspan="3" class="empty">로딩 중</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<h2>바이너리 원본 보관</h2>
|
||||||
|
<p>엑셀 같은 바이너리 원본의 DB 보관 상태입니다.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Source</th>
|
||||||
|
<th>파일</th>
|
||||||
|
<th>크기</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="binary-body">
|
||||||
|
<tr><td colspan="3" class="empty">로딩 중</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<h2>운영 메모</h2>
|
||||||
|
<p>#2에서 확인해야 할 저장 구조 핵심 포인트입니다.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<ol id="notes" class="notes"></ol>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<h2>운영 분류</h2>
|
||||||
|
<p>유지/주의/원본·추적/정리 후보 기준과 제품 관점 묶음을 같이 봅니다.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div id="group-summary"></div>
|
||||||
|
<div id="product-summary" style="margin-top:18px;"></div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<h2>화면별 데이터 소스</h2>
|
||||||
|
<p>각 탭/기능이 실제로 어떤 테이블을 읽고 저장하는지 빠르게 확인합니다.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="screen-map" class="panel-body mapping-list"></div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="preview-modal" class="modal-overlay" aria-hidden="true">
|
||||||
|
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="preview-title">
|
||||||
|
<div class="modal-head">
|
||||||
|
<div>
|
||||||
|
<h2 id="preview-title">테이블 내용 미리보기</h2>
|
||||||
|
<p id="preview-subtitle" class="muted">선택한 테이블의 컬럼과 최대 50개 row를 표시합니다.</p>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-actions">
|
||||||
|
<button id="preview-download" class="action-button" type="button" disabled>CSV 다운로드</button>
|
||||||
|
<button id="preview-close" class="modal-close" type="button" aria-label="닫기">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div id="preview-meta" class="preview-meta"></div>
|
||||||
|
<div class="panel-body tight">
|
||||||
|
<div class="preview-table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead id="preview-head" class="sticky-head"></thead>
|
||||||
|
<tbody id="preview-body">
|
||||||
|
<tr><td class="empty">왼쪽 표에서 테이블을 선택하세요.</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let allTables = [];
|
||||||
|
let currentPreview = null;
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value ?? "")
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("'", "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(value) {
|
||||||
|
return new Intl.NumberFormat("ko-KR").format(Number(value || 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value) {
|
||||||
|
if (!value) return '<span class="muted">-</span>';
|
||||||
|
const parsed = new Date(value);
|
||||||
|
if (Number.isNaN(parsed.getTime())) return escapeHtml(value);
|
||||||
|
return parsed.toLocaleString("ko-KR", { hour12: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(value) {
|
||||||
|
const size = Number(value || 0);
|
||||||
|
if (size <= 0) return "0 B";
|
||||||
|
const units = ["B", "KB", "MB", "GB"];
|
||||||
|
let current = size;
|
||||||
|
let unit = 0;
|
||||||
|
while (current >= 1024 && unit < units.length - 1) {
|
||||||
|
current /= 1024;
|
||||||
|
unit += 1;
|
||||||
|
}
|
||||||
|
return `${current.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderOverview(overview) {
|
||||||
|
const target = document.getElementById("overview");
|
||||||
|
target.innerHTML = [
|
||||||
|
["핵심 테이블", overview.visible_tables],
|
||||||
|
["전체 테이블", overview.total_tables],
|
||||||
|
["등록 인원", overview.registered_members],
|
||||||
|
["재직 인원", overview.active_members],
|
||||||
|
["고정 오피스 도면", overview.fixed_office_maps],
|
||||||
|
["현재 active 도면", overview.active_seat_maps],
|
||||||
|
["Import 배치", overview.import_batches],
|
||||||
|
["바이너리 원본", overview.binary_sources],
|
||||||
|
].map(([label, value]) => `
|
||||||
|
<article class="kpi">
|
||||||
|
<span class="kpi-label">${escapeHtml(label)}</span>
|
||||||
|
<span class="kpi-value">${formatNumber(value)}</span>
|
||||||
|
</article>
|
||||||
|
`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTables(items) {
|
||||||
|
const target = document.getElementById("table-body");
|
||||||
|
const countTarget = document.getElementById("table-count");
|
||||||
|
if (countTarget) {
|
||||||
|
countTarget.textContent = `${formatNumber(items.length)} / ${formatNumber(allTables.length)}`;
|
||||||
|
}
|
||||||
|
if (!items.length) {
|
||||||
|
target.innerHTML = '<tr><td colspan="5" class="empty">표시할 테이블이 없습니다.</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
target.innerHTML = items.map((item) => `
|
||||||
|
<tr>
|
||||||
|
<td><span class="domain-tag ${escapeHtml(item.domain)}">${escapeHtml(item.domain)}</span></td>
|
||||||
|
<td>
|
||||||
|
<div style="margin-bottom:8px;">
|
||||||
|
<span class="group-tag ${item.group === '유지' ? 'keep' : item.group === '원본·추적' ? 'trace' : item.group === '정리 후보' ? 'cleanup' : 'caution'}">${escapeHtml(item.group || '주의')}</span>
|
||||||
|
</div>
|
||||||
|
<button class="table-trigger" type="button" data-schema="${escapeHtml(item.schema)}" data-table="${escapeHtml(item.table_name)}">${escapeHtml(item.label)}</button>
|
||||||
|
<div class="muted">${escapeHtml(item.table_ref)}</div>
|
||||||
|
<div class="table-desc">${escapeHtml(item.description)}</div>
|
||||||
|
</td>
|
||||||
|
<td>${formatNumber(item.row_count)}</td>
|
||||||
|
<td>${formatDateTime(item.last_event_at)}</td>
|
||||||
|
<td>
|
||||||
|
<div class="view-list">
|
||||||
|
${(item.related_views || []).map((view) => `<span class="view-pill">${escapeHtml(view)}</span>`).join("")}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join("");
|
||||||
|
target.querySelectorAll(".table-trigger").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
loadTablePreview(button.dataset.schema, button.dataset.table);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBatches(items) {
|
||||||
|
const target = document.getElementById("batch-body");
|
||||||
|
if (!items.length) {
|
||||||
|
target.innerHTML = '<tr><td colspan="3" class="empty">적재 배치가 없습니다.</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
target.innerHTML = items.map((item) => `
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="table-title">${escapeHtml(item.source_name)}</div>
|
||||||
|
<div class="muted">${escapeHtml(item.source_key)}</div>
|
||||||
|
</td>
|
||||||
|
<td>${formatNumber(item.row_count)}</td>
|
||||||
|
<td>${formatDateTime(item.imported_at)}</td>
|
||||||
|
</tr>
|
||||||
|
`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBinarySources(items) {
|
||||||
|
const target = document.getElementById("binary-body");
|
||||||
|
if (!items.length) {
|
||||||
|
target.innerHTML = '<tr><td colspan="3" class="empty">보관 중인 바이너리 원본이 없습니다.</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
target.innerHTML = items.map((item) => `
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="table-title">${escapeHtml(item.source_name)}</div>
|
||||||
|
<div class="muted">${escapeHtml(item.source_key)}</div>
|
||||||
|
</td>
|
||||||
|
<td>${escapeHtml(item.filename)}</td>
|
||||||
|
<td>${formatBytes(item.byte_size)}</td>
|
||||||
|
</tr>
|
||||||
|
`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNotes(notes) {
|
||||||
|
const target = document.getElementById("notes");
|
||||||
|
target.innerHTML = (notes || []).map((note) => `<li>${escapeHtml(note)}</li>`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGroupSummary(summary) {
|
||||||
|
const target = document.getElementById("group-summary");
|
||||||
|
const groups = [
|
||||||
|
["유지", "keep"],
|
||||||
|
["주의", "caution"],
|
||||||
|
["원본·추적", "trace"],
|
||||||
|
["정리 후보", "cleanup"],
|
||||||
|
];
|
||||||
|
target.innerHTML = groups.map(([label, klass]) => `
|
||||||
|
<div style="display:grid; gap:8px; margin-bottom:16px;">
|
||||||
|
<div><span class="group-tag ${klass}">${escapeHtml(label)}</span></div>
|
||||||
|
<div class="view-list">
|
||||||
|
${((summary && summary[label]) || []).map((item) => `<span class="view-pill">${escapeHtml(item)}</span>`).join("") || '<span class="muted">없음</span>'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProductSummary(summary) {
|
||||||
|
const target = document.getElementById("product-summary");
|
||||||
|
const groups = [
|
||||||
|
"탭 데이터",
|
||||||
|
"로그인·권한",
|
||||||
|
"히스토리",
|
||||||
|
"로우데이터·적재",
|
||||||
|
"보정·보조",
|
||||||
|
];
|
||||||
|
target.innerHTML = groups.map((label) => `
|
||||||
|
<div style="display:grid; gap:8px; margin-bottom:16px;">
|
||||||
|
<div><span class="group-tag keep">${escapeHtml(label)}</span></div>
|
||||||
|
<div class="view-list">
|
||||||
|
${((summary && summary[label]) || []).map((item) => `<span class="view-pill">${escapeHtml(item)}</span>`).join("") || '<span class="muted">없음</span>'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderScreenMap(items) {
|
||||||
|
const target = document.getElementById("screen-map");
|
||||||
|
if (!items || !items.length) {
|
||||||
|
target.innerHTML = '<div class="empty">화면별 데이터 소스 정보가 없습니다.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
target.innerHTML = items.map((item) => `
|
||||||
|
<article class="mapping-card">
|
||||||
|
<h3>${escapeHtml(item.screen || "")}</h3>
|
||||||
|
<div class="mapping-table-list">
|
||||||
|
${(item.tables || []).map((table) => `<span class="view-pill">${escapeHtml(table)}</span>`).join("")}
|
||||||
|
</div>
|
||||||
|
<p>${escapeHtml(item.write_flow || "")}</p>
|
||||||
|
</article>
|
||||||
|
`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTablePreview(payload) {
|
||||||
|
currentPreview = payload;
|
||||||
|
const previewModal = document.getElementById("preview-modal");
|
||||||
|
const downloadButton = document.getElementById("preview-download");
|
||||||
|
const previewMeta = document.getElementById("preview-meta");
|
||||||
|
const previewTitle = document.getElementById("preview-title");
|
||||||
|
const previewSubtitle = document.getElementById("preview-subtitle");
|
||||||
|
const previewHead = document.getElementById("preview-head");
|
||||||
|
const previewBody = document.getElementById("preview-body");
|
||||||
|
|
||||||
|
previewTitle.textContent = `${payload.label} · ${payload.table_ref}`;
|
||||||
|
previewSubtitle.textContent = `${formatNumber(payload.row_count)} rows / 최대 ${formatNumber(payload.limit)}개 표시`;
|
||||||
|
if (downloadButton) {
|
||||||
|
downloadButton.disabled = !(payload.rows && payload.rows.length);
|
||||||
|
}
|
||||||
|
previewMeta.innerHTML = `
|
||||||
|
<div>
|
||||||
|
<div class="table-title">${escapeHtml(payload.label)}</div>
|
||||||
|
<div class="muted">${escapeHtml(payload.description || "")}</div>
|
||||||
|
</div>
|
||||||
|
<div class="preview-columns">
|
||||||
|
${(payload.columns || []).map((column) => `
|
||||||
|
<span class="column-pill">${escapeHtml(column.name)} <em>${escapeHtml(column.type)}</em></span>
|
||||||
|
`).join("")}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const columns = payload.columns || [];
|
||||||
|
previewHead.innerHTML = `<tr>${columns.map((column) => `<th>${escapeHtml(column.name)}</th>`).join("")}</tr>`;
|
||||||
|
if (!payload.rows || !payload.rows.length) {
|
||||||
|
previewBody.innerHTML = `<tr><td colspan="${Math.max(columns.length, 1)}" class="empty">표시할 row가 없습니다.</td></tr>`;
|
||||||
|
previewModal.classList.add("open");
|
||||||
|
previewModal.setAttribute("aria-hidden", "false");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
previewBody.innerHTML = payload.rows.map((row) => `
|
||||||
|
<tr>
|
||||||
|
${columns.map((column) => `<td>${escapeHtml(row[column.name] ?? "")}</td>`).join("")}
|
||||||
|
</tr>
|
||||||
|
`).join("");
|
||||||
|
previewModal.classList.add("open");
|
||||||
|
previewModal.setAttribute("aria-hidden", "false");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTablePreview(schema, table) {
|
||||||
|
const previewModal = document.getElementById("preview-modal");
|
||||||
|
const previewMeta = document.getElementById("preview-meta");
|
||||||
|
const previewTitle = document.getElementById("preview-title");
|
||||||
|
const previewSubtitle = document.getElementById("preview-subtitle");
|
||||||
|
const previewHead = document.getElementById("preview-head");
|
||||||
|
const previewBody = document.getElementById("preview-body");
|
||||||
|
previewTitle.textContent = `${table}`;
|
||||||
|
previewSubtitle.textContent = "테이블 내용을 불러오는 중입니다.";
|
||||||
|
previewMeta.innerHTML = "";
|
||||||
|
previewHead.innerHTML = "";
|
||||||
|
previewBody.innerHTML = `<tr><td class="empty">테이블 내용을 불러오는 중입니다.</td></tr>`;
|
||||||
|
previewModal.classList.add("open");
|
||||||
|
previewModal.setAttribute("aria-hidden", "false");
|
||||||
|
const response = await fetch(`/api/admin/db-status/table?schema=${encodeURIComponent(schema)}&table=${encodeURIComponent(table)}`, { cache: "no-store" });
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`테이블 내용을 불러오지 못했습니다. (${response.status})`);
|
||||||
|
}
|
||||||
|
const payload = await response.json();
|
||||||
|
renderTablePreview(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
const response = await fetch("/api/admin/db-status", { cache: "no-store" });
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`DB 상태를 불러오지 못했습니다. (${response.status})`);
|
||||||
|
}
|
||||||
|
const payload = await response.json();
|
||||||
|
allTables = payload.tables || [];
|
||||||
|
document.getElementById("generated-at").textContent = payload.generated_at
|
||||||
|
? `갱신 ${formatDateTime(payload.generated_at)}`
|
||||||
|
: "갱신 시각 없음";
|
||||||
|
renderOverview(payload.overview || {});
|
||||||
|
renderTables(allTables);
|
||||||
|
renderBatches(payload.import_batches || []);
|
||||||
|
renderBinarySources(payload.binary_sources || []);
|
||||||
|
renderNotes(payload.notes || []);
|
||||||
|
renderGroupSummary(payload.group_summary || {});
|
||||||
|
renderProductSummary(payload.product_summary || {});
|
||||||
|
renderScreenMap(payload.screen_map || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toCsvValue(value) {
|
||||||
|
const text = String(value ?? "");
|
||||||
|
if (!/[",\n]/.test(text)) return text;
|
||||||
|
return `"${text.replaceAll('"', '""')}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadPreviewCsv() {
|
||||||
|
if (!currentPreview || !currentPreview.columns || !currentPreview.rows || !currentPreview.rows.length) return;
|
||||||
|
const headers = currentPreview.columns.map((column) => column.name);
|
||||||
|
const lines = [
|
||||||
|
headers.map(toCsvValue).join(","),
|
||||||
|
...currentPreview.rows.map((row) => headers.map((header) => toCsvValue(row[header] ?? "")).join(",")),
|
||||||
|
];
|
||||||
|
const blob = new Blob(["\ufeff" + lines.join("\n")], { type: "text/csv;charset=utf-8;" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = `${currentPreview.table_name || "table-preview"}.csv`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTableSearch(query) {
|
||||||
|
const normalized = String(query || "").trim().toLowerCase();
|
||||||
|
if (!normalized) {
|
||||||
|
renderTables(allTables);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const filtered = allTables.filter((item) => {
|
||||||
|
const haystack = [
|
||||||
|
item.label,
|
||||||
|
item.table_ref,
|
||||||
|
item.description,
|
||||||
|
...(item.related_views || []),
|
||||||
|
item.domain,
|
||||||
|
item.group,
|
||||||
|
].join(" ").toLowerCase();
|
||||||
|
return haystack.includes(normalized);
|
||||||
|
});
|
||||||
|
renderTables(filtered);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("preview-close").addEventListener("click", () => {
|
||||||
|
const modal = document.getElementById("preview-modal");
|
||||||
|
modal.classList.remove("open");
|
||||||
|
modal.setAttribute("aria-hidden", "true");
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("preview-download").addEventListener("click", downloadPreviewCsv);
|
||||||
|
document.getElementById("table-search").addEventListener("input", (event) => {
|
||||||
|
applyTableSearch(event.target.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("preview-modal").addEventListener("click", (event) => {
|
||||||
|
if (event.target.id !== "preview-modal") return;
|
||||||
|
const modal = document.getElementById("preview-modal");
|
||||||
|
modal.classList.remove("open");
|
||||||
|
modal.setAttribute("aria-hidden", "true");
|
||||||
|
});
|
||||||
|
|
||||||
|
bootstrap().catch((error) => {
|
||||||
|
document.getElementById("table-body").innerHTML = `<tr><td colspan="5" class="empty">${escapeHtml(error.message || "DB 상태를 불러오지 못했습니다.")}</td></tr>`;
|
||||||
|
document.getElementById("batch-body").innerHTML = '<tr><td colspan="3" class="empty">배치 정보를 불러오지 못했습니다.</td></tr>';
|
||||||
|
document.getElementById("binary-body").innerHTML = '<tr><td colspan="3" class="empty">바이너리 원본 정보를 불러오지 못했습니다.</td></tr>';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
<link rel="stylesheet" href="/design-patterns.css?v=20260401-01">
|
<link rel="stylesheet" href="/design-patterns.css?v=20260401-01">
|
||||||
<link rel="stylesheet" href="/legacy/static/common.css">
|
<link rel="stylesheet" href="/legacy/static/common.css">
|
||||||
<!-- Keep login and common hub defaults aligned with 8080. -->
|
<!-- Keep login and common hub defaults aligned with 8080. -->
|
||||||
<link rel="stylesheet" href="/styles.css?v=20260330-01">
|
<link rel="stylesheet" href="/styles.css?v=20260402-01">
|
||||||
<!-- 8081-only hub overrides must not restyle the login screen. -->
|
<!-- 8081-only hub overrides must not restyle the login screen. -->
|
||||||
<link rel="stylesheet" href="/styles-8081-design.css?v=20260401-01">
|
<link rel="stylesheet" href="/styles-8081-design.css?v=20260401-01">
|
||||||
</head>
|
</head>
|
||||||
@@ -79,6 +79,7 @@
|
|||||||
<button class="nav-pill" type="button" data-view="project">프로젝트별 분석</button>
|
<button class="nav-pill" type="button" data-view="project">프로젝트별 분석</button>
|
||||||
<button class="nav-pill" type="button" data-view="team">팀/개인별 분석</button>
|
<button class="nav-pill" type="button" data-view="team">팀/개인별 분석</button>
|
||||||
<button class="nav-pill active" type="button" data-view="organization">조직 현황</button>
|
<button class="nav-pill active" type="button" data-view="organization">조직 현황</button>
|
||||||
|
<button class="nav-pill" type="button" data-view="db-status">DB 상태</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
@@ -104,7 +105,7 @@
|
|||||||
<section id="organization-stage" class="main-stage">
|
<section id="organization-stage" class="main-stage">
|
||||||
<div class="stage-frame">
|
<div class="stage-frame">
|
||||||
<!-- Legacy organization keeps its own CSS/JS responsibility under /legacy/static. -->
|
<!-- 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>
|
<iframe id="organization-frame" src="/legacy/organization?v=20260402-02" data-src="/legacy/organization?v=20260402-02" title="조직도 메인 화면"></iframe>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section id="project-stage" class="main-stage" hidden>
|
<section id="project-stage" class="main-stage" hidden>
|
||||||
@@ -119,6 +120,11 @@
|
|||||||
<iframe id="team-frame" src="/integrations/mh" data-src="/integrations/mh" title="팀/개인별 분석 화면"></iframe>
|
<iframe id="team-frame" src="/integrations/mh" data-src="/integrations/mh" title="팀/개인별 분석 화면"></iframe>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<section id="db-status-stage" class="main-stage" hidden>
|
||||||
|
<div class="stage-frame">
|
||||||
|
<iframe id="db-status-frame" src="/db-status.html?v=20260401-02" data-src="/db-status.html?v=20260401-02" title="DB 상태 화면"></iframe>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
<section id="seatmap-admin-stage" class="main-stage" hidden>
|
<section id="seatmap-admin-stage" class="main-stage" hidden>
|
||||||
<div class="seatmap-layout">
|
<div class="seatmap-layout">
|
||||||
<div class="seatmap-topbar">
|
<div class="seatmap-topbar">
|
||||||
@@ -175,6 +181,7 @@
|
|||||||
<span class="hidden">구성원 검색</span>
|
<span class="hidden">구성원 검색</span>
|
||||||
<input id="seatmap-admin-search" type="search" placeholder="이름 또는 부서 검색">
|
<input id="seatmap-admin-search" type="search" placeholder="이름 또는 부서 검색">
|
||||||
</label>
|
</label>
|
||||||
|
<div id="seatmap-admin-context" class="seatmap-context-panel hidden"></div>
|
||||||
<div id="seatmap-admin-unassigned" class="seatmap-member-list"></div>
|
<div id="seatmap-admin-unassigned" class="seatmap-member-list"></div>
|
||||||
</section>
|
</section>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -214,6 +221,7 @@
|
|||||||
<span class="hidden">구성원 검색</span>
|
<span class="hidden">구성원 검색</span>
|
||||||
<input id="seatmap-readonly-search" type="search" placeholder="이름 또는 부서 검색">
|
<input id="seatmap-readonly-search" type="search" placeholder="이름 또는 부서 검색">
|
||||||
</label>
|
</label>
|
||||||
|
<div id="seatmap-readonly-context" class="seatmap-context-panel hidden"></div>
|
||||||
<div id="seatmap-readonly-unassigned" class="seatmap-member-list"></div>
|
<div id="seatmap-readonly-unassigned" class="seatmap-member-list"></div>
|
||||||
</section>
|
</section>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -344,6 +344,12 @@ body {
|
|||||||
padding-right: 8px;
|
padding-right: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-date-field select option {
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
.header-date-sep {
|
.header-date-sep {
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -913,6 +919,39 @@ body {
|
|||||||
padding: var(--seatmap-gap);
|
padding: var(--seatmap-gap);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.seatmap-team-overlay {
|
||||||
|
pointer-events: none;
|
||||||
|
align-self: stretch;
|
||||||
|
justify-self: stretch;
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 2px solid color-mix(in srgb, var(--seatmap-team-accent, rgba(13, 148, 136, 0.3)) 62%, white);
|
||||||
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
color-mix(in srgb, var(--seatmap-team-soft, rgba(13, 148, 136, 0.12)) 100%, transparent),
|
||||||
|
color-mix(in srgb, var(--seatmap-team-soft, rgba(13, 148, 136, 0.08)) 90%, transparent)
|
||||||
|
);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.22), 0 8px 18px rgba(16, 37, 29, 0.08);
|
||||||
|
margin: 4px;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-team-overlay::before {
|
||||||
|
content: attr(data-team-label);
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
left: 12px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 26px;
|
||||||
|
padding: 0 11px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--seatmap-team-soft, rgba(13, 148, 136, 0.14)) 84%, white);
|
||||||
|
color: color-mix(in srgb, var(--seatmap-team-accent, #0f766e) 86%, #10251d);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
.seatmap-cell {
|
.seatmap-cell {
|
||||||
position: relative;
|
position: relative;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -920,6 +959,7 @@ body {
|
|||||||
border: 1px dashed rgba(15, 23, 42, 0.14);
|
border: 1px dashed rgba(15, 23, 42, 0.14);
|
||||||
background: rgba(255, 255, 255, 0.12);
|
background: rgba(255, 255, 255, 0.12);
|
||||||
transition: border-color 0.18s ease, background 0.18s ease;
|
transition: border-color 0.18s ease, background 0.18s ease;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.seatmap-cell.editable:hover {
|
.seatmap-cell.editable:hover {
|
||||||
@@ -931,6 +971,13 @@ body {
|
|||||||
background: rgba(255, 255, 255, 0.18);
|
background: rgba(255, 255, 255, 0.18);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.seatmap-cell.occupied.team-colored {
|
||||||
|
border-color: color-mix(in srgb, var(--seatmap-team-accent, rgba(15, 118, 110, 0.72)) 62%, white);
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, color-mix(in srgb, var(--seatmap-team-soft, rgba(15, 118, 110, 0.14)) 86%, transparent), rgba(255, 255, 255, 0.14)),
|
||||||
|
rgba(255, 255, 255, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
.seatmap-cell-label {
|
.seatmap-cell-label {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 4px;
|
top: 4px;
|
||||||
@@ -959,6 +1006,12 @@ body {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.seatmap-member-card.team-colored {
|
||||||
|
border-color: color-mix(in srgb, var(--seatmap-team-accent, rgba(245, 158, 11, 0.95)) 42%, rgba(255,255,255,0.4));
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, color-mix(in srgb, var(--seatmap-team-soft, rgba(245, 158, 11, 0.18)) 68%, rgba(15, 23, 42, 0.84)) 0%, rgba(15, 23, 42, 0.84) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
.seatmap-member-card.draggable {
|
.seatmap-member-card.draggable {
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
}
|
}
|
||||||
@@ -1008,11 +1061,26 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.seatmap-member-text em {
|
.seatmap-member-text em {
|
||||||
color: rgba(226, 232, 240, 0.84);
|
color: rgba(255, 247, 237, 0.96);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
|
font-weight: 800;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.seatmap-team-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--seatmap-team-soft, rgba(245, 158, 11, 0.18)) 88%, white);
|
||||||
|
color: color-mix(in srgb, var(--seatmap-team-accent, #b45309) 85%, #10251d);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
.seatmap-sidebar {
|
.seatmap-sidebar {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -1195,6 +1263,11 @@ body {
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.seatmap-member-search-card.team-colored {
|
||||||
|
border-color: color-mix(in srgb, var(--seatmap-team-accent, rgba(245, 158, 11, 0.24)) 34%, rgba(226, 232, 240, 0.9));
|
||||||
|
background: linear-gradient(180deg, color-mix(in srgb, var(--seatmap-team-soft, rgba(245, 158, 11, 0.1)) 78%, white), #fff);
|
||||||
|
}
|
||||||
|
|
||||||
.seatmap-member-badge {
|
.seatmap-member-badge {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -1219,8 +1292,8 @@ body {
|
|||||||
min-height: 42px;
|
min-height: 42px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: #3f4658;
|
background: transparent;
|
||||||
border: 1px solid rgba(148, 163, 184, 0.14);
|
border: 1px solid rgba(194, 170, 134, 0.34);
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1232,12 +1305,14 @@ body {
|
|||||||
|
|
||||||
.seatmap-member-text-inline strong {
|
.seatmap-member-text-inline strong {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
color: #10251d;
|
||||||
}
|
}
|
||||||
|
|
||||||
.seatmap-member-text-inline em {
|
.seatmap-member-text-inline em {
|
||||||
display: inline;
|
display: inline;
|
||||||
color: rgba(226, 232, 240, 0.8);
|
color: #6f5b3e;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
}
|
}
|
||||||
|
|
||||||
.seatmap-slot .seatmap-member-card {
|
.seatmap-slot .seatmap-member-card {
|
||||||
@@ -1266,6 +1341,81 @@ body {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.seatmap-context-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid rgba(194, 170, 134, 0.28);
|
||||||
|
border-radius: 18px;
|
||||||
|
background: linear-gradient(180deg, rgba(255, 252, 247, 0.96), rgba(246, 239, 227, 0.92));
|
||||||
|
box-shadow: 0 14px 28px rgba(16, 37, 29, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-context-panel.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-context-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-context-title {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-context-title strong {
|
||||||
|
color: #10251d;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-context-title span {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-context-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--seatmap-team-soft, rgba(245, 158, 11, 0.14)) 88%, white);
|
||||||
|
color: color-mix(in srgb, var(--seatmap-team-accent, #b45309) 84%, #10251d);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-context-tree {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-context-node {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 0 11px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.84);
|
||||||
|
border: 1px solid rgba(194, 170, 134, 0.28);
|
||||||
|
color: #30423a;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-context-empty {
|
||||||
|
color: #7b7b6c;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
.seatmap-dxf-stage {
|
.seatmap-dxf-stage {
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,8 @@
|
|||||||
|
|
||||||
- backend `/integrations/payment`, `/integrations/mh`는 위 `served/*`만 읽는다.
|
- backend `/integrations/payment`, `/integrations/mh`는 위 `served/*`만 읽는다.
|
||||||
- backend `/integrations/ledger`와 `/integrations/ledger-assets/*`도 `served/ledger/*`만 읽는다.
|
- backend `/integrations/ledger`와 `/integrations/ledger-assets/*`도 `served/ledger/*`만 읽는다.
|
||||||
- 새 기능을 붙일 때도 실제 서비스 파일은 `served/` 기준으로 수정한다.
|
- 다만 `payment`, `mh`, `ledger`, `db-status`는 이제 앱 소스가 따로 있으므로, 실제 수정은 `frontend/apps/*`에서 하고 publish 스크립트로 `served/`에 반영한다.
|
||||||
|
- 즉 `served/`는 runtime 기준이고, 사람이 직접 먼저 수정하는 source-of-truth는 아니다.
|
||||||
|
|
||||||
## Reference
|
## Reference
|
||||||
|
|
||||||
@@ -30,9 +31,4 @@
|
|||||||
- 디자인 비교용 파일
|
- 디자인 비교용 파일
|
||||||
- `reference/ledger/MH 통합 대시보드_260320.html`
|
- `reference/ledger/MH 통합 대시보드_260320.html`
|
||||||
- `reference/ledger/MH 통합 대시보드_260320.css`
|
- `reference/ledger/MH 통합 대시보드_260320.css`
|
||||||
|
- `reference/ledger/사업관리대장-1.xlsx`
|
||||||
## Temporary Comparison Copies
|
|
||||||
|
|
||||||
- 현재 루트의 `payment.html`, `mh.html`은 당장 삭제하지 않는다.
|
|
||||||
- 이 두 파일은 기존 recovery 작업본과 현재 `served/*`를 비교하거나 되돌릴 때만 본다.
|
|
||||||
- 다음 차수에서 안전성이 확보되면 `reference/` 하위로 재배치 여부를 검토한다.
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -8,8 +8,7 @@
|
|||||||
|
|
||||||
- `ledger/`
|
- `ledger/`
|
||||||
- 사업관리대장 원본 wrapper/html/css/xlsx
|
- 사업관리대장 원본 wrapper/html/css/xlsx
|
||||||
- 이전 override 복사본
|
- 이전 override 참고 파일
|
||||||
- 중첩 백업 디렉터리
|
|
||||||
|
|
||||||
규칙:
|
규칙:
|
||||||
|
|
||||||
|
|||||||
22
incoming-files/reference/ledger/README.md
Normal file
22
incoming-files/reference/ledger/README.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# reference/ledger
|
||||||
|
|
||||||
|
이 디렉터리는 `사업관리대장` 원본 참고 자산만 둔다.
|
||||||
|
|
||||||
|
원칙:
|
||||||
|
|
||||||
|
- 직접 서빙하지 않는다.
|
||||||
|
- 비교, 복구, 기준 확인이 필요할 때만 본다.
|
||||||
|
- 실제 수정 원본은 `frontend/apps/ledger/*`다.
|
||||||
|
- 실제 runtime 응답은 `incoming-files/served/ledger/*`다.
|
||||||
|
|
||||||
|
현재 포함:
|
||||||
|
|
||||||
|
- 원본 HTML/CSS
|
||||||
|
- 원본 XLSX
|
||||||
|
- 과거 override 참고 파일
|
||||||
|
- 단일 canonical reference set만 유지
|
||||||
|
|
||||||
|
주의:
|
||||||
|
|
||||||
|
- `reference/ledger` 아래에 다시 `ledger/` 같은 중첩 복사본을 만들지 않는다.
|
||||||
|
- 원본 정리가 필요하면 이 디렉터리에서만 구조를 맞춘다.
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
Binary file not shown.
5
incoming-files/served/db-status/README.md
Normal file
5
incoming-files/served/db-status/README.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
## DB Status Served Output
|
||||||
|
|
||||||
|
- 이 디렉터리는 `frontend/apps/db-status` publish 결과물만 둔다.
|
||||||
|
- backend `/admin/db-status`는 여기의 `index.html`만 서빙한다.
|
||||||
|
- 수정은 직접 여기서 하지 말고 `./scripts/publish_db_status_app.sh`를 사용한다.
|
||||||
918
incoming-files/served/db-status/index.html
Normal file
918
incoming-files/served/db-status/index.html
Normal file
@@ -0,0 +1,918 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>DB 상태</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Pretendard:wght@400;600;700;800&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="/design-tokens.css?v=20260401-01">
|
||||||
|
<link rel="stylesheet" href="/design-patterns.css?v=20260401-01">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "Pretendard", sans-serif;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(247, 217, 119, 0.28), transparent 30%),
|
||||||
|
linear-gradient(180deg, var(--ds-bg, #f5f1e8) 0%, #efe6d5 100%);
|
||||||
|
color: var(--ds-ink, #2f2419);
|
||||||
|
}
|
||||||
|
.page {
|
||||||
|
max-width: 2000px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 28px;
|
||||||
|
display: grid;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
.hero {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 28px 30px;
|
||||||
|
border: 1px solid rgba(134, 98, 47, 0.14);
|
||||||
|
border-radius: 28px;
|
||||||
|
background: linear-gradient(135deg, rgba(255, 250, 240, 0.96), rgba(242, 232, 214, 0.92));
|
||||||
|
box-shadow: 0 28px 68px rgba(88, 61, 23, 0.15);
|
||||||
|
}
|
||||||
|
.hero h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 30px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
}
|
||||||
|
.hero p {
|
||||||
|
margin: 0;
|
||||||
|
color: rgba(76, 58, 35, 0.82);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.overview {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.kpi {
|
||||||
|
padding: 18px 20px;
|
||||||
|
border-radius: 22px;
|
||||||
|
background: rgba(255, 252, 247, 0.92);
|
||||||
|
border: 1px solid rgba(140, 110, 59, 0.14);
|
||||||
|
box-shadow: 0 14px 34px rgba(81, 58, 23, 0.08);
|
||||||
|
}
|
||||||
|
.kpi-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: rgba(112, 84, 41, 0.72);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
.kpi-value {
|
||||||
|
display: block;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #3d2e1d;
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.4fr 1fr;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
.panel {
|
||||||
|
border-radius: 24px;
|
||||||
|
background: rgba(255, 251, 245, 0.96);
|
||||||
|
border: 1px solid rgba(142, 110, 54, 0.14);
|
||||||
|
box-shadow: 0 18px 48px rgba(85, 60, 24, 0.08);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.panel-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 18px 22px 14px;
|
||||||
|
border-bottom: 1px solid rgba(128, 98, 48, 0.12);
|
||||||
|
}
|
||||||
|
.panel-head h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
.panel-head p {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(102, 77, 41, 0.72);
|
||||||
|
}
|
||||||
|
.panel-body {
|
||||||
|
padding: 16px 18px 20px;
|
||||||
|
}
|
||||||
|
.panel-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.panel-body.tight {
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
.meta-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 11px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(251, 236, 196, 0.8);
|
||||||
|
color: #7a5923;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
th, td {
|
||||||
|
padding: 12px 10px;
|
||||||
|
vertical-align: top;
|
||||||
|
border-bottom: 1px solid rgba(130, 100, 53, 0.1);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: rgba(104, 79, 40, 0.76);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
tbody tr:hover {
|
||||||
|
background: rgba(250, 240, 213, 0.34);
|
||||||
|
}
|
||||||
|
.domain-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 9px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
background: rgba(90, 122, 94, 0.14);
|
||||||
|
color: #456b4c;
|
||||||
|
}
|
||||||
|
.domain-tag.integration { background: rgba(196, 143, 58, 0.16); color: #8c5f18; }
|
||||||
|
.domain-tag.history { background: rgba(120, 92, 156, 0.14); color: #6a4b8b; }
|
||||||
|
.domain-tag.auth { background: rgba(103, 114, 154, 0.14); color: #48567c; }
|
||||||
|
.domain-tag.other { background: rgba(131, 112, 80, 0.12); color: #6a5637; }
|
||||||
|
.group-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
background: rgba(240, 231, 214, 0.95);
|
||||||
|
color: #674d27;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.group-tag.keep { background: rgba(112, 143, 87, 0.18); color: #49623c; }
|
||||||
|
.group-tag.caution { background: rgba(214, 167, 84, 0.18); color: #8f5d17; }
|
||||||
|
.group-tag.trace { background: rgba(113, 120, 168, 0.16); color: #56628c; }
|
||||||
|
.group-tag.cleanup { background: rgba(184, 111, 84, 0.16); color: #884d39; }
|
||||||
|
.table-title {
|
||||||
|
font-weight: 800;
|
||||||
|
color: #2f2419;
|
||||||
|
}
|
||||||
|
.table-trigger {
|
||||||
|
all: unset;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #2f2419;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.table-trigger:hover {
|
||||||
|
color: #80591f;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.table-desc {
|
||||||
|
margin-top: 5px;
|
||||||
|
color: rgba(98, 75, 42, 0.72);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.view-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.view-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(86, 119, 93, 0.12);
|
||||||
|
color: #456b4c;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.toolbar-input {
|
||||||
|
width: min(360px, 100%);
|
||||||
|
padding: 11px 14px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid rgba(132, 102, 54, 0.16);
|
||||||
|
background: rgba(255, 251, 244, 0.96);
|
||||||
|
color: #2f2419;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
.toolbar-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: rgba(129, 88, 31, 0.34);
|
||||||
|
box-shadow: 0 0 0 4px rgba(208, 176, 116, 0.16);
|
||||||
|
}
|
||||||
|
.toolbar-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.action-button {
|
||||||
|
border: 1px solid rgba(128, 98, 48, 0.14);
|
||||||
|
background: rgba(250, 240, 213, 0.62);
|
||||||
|
color: #6c4a1a;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 9px 14px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.action-button:hover {
|
||||||
|
background: rgba(244, 228, 186, 0.8);
|
||||||
|
}
|
||||||
|
.action-button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
.notes {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
color: rgba(84, 65, 38, 0.84);
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
.mapping-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.mapping-card {
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(255, 249, 239, 0.92);
|
||||||
|
border: 1px solid rgba(132, 102, 54, 0.12);
|
||||||
|
}
|
||||||
|
.mapping-card h3 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
.mapping-card p {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: rgba(98, 75, 42, 0.74);
|
||||||
|
line-height: 1.55;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.mapping-table-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.preview-meta {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 16px 18px 0;
|
||||||
|
}
|
||||||
|
.preview-columns {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.column-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(240, 231, 214, 0.9);
|
||||||
|
color: #634a25;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.column-pill em {
|
||||||
|
font-style: normal;
|
||||||
|
color: rgba(99, 74, 37, 0.68);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.preview-table-wrap {
|
||||||
|
overflow: auto;
|
||||||
|
max-height: 520px;
|
||||||
|
border-top: 1px solid rgba(128, 98, 48, 0.12);
|
||||||
|
}
|
||||||
|
.sticky-head th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: rgba(255, 248, 236, 0.98);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.muted {
|
||||||
|
color: rgba(110, 86, 50, 0.72);
|
||||||
|
}
|
||||||
|
.empty {
|
||||||
|
padding: 22px;
|
||||||
|
text-align: center;
|
||||||
|
color: rgba(102, 77, 41, 0.72);
|
||||||
|
}
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
background: rgba(44, 31, 16, 0.42);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
.modal-overlay.open {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.modal-card {
|
||||||
|
width: min(1600px, 100%);
|
||||||
|
max-height: min(88vh, 980px);
|
||||||
|
border-radius: 28px;
|
||||||
|
background: rgba(255, 250, 243, 0.98);
|
||||||
|
border: 1px solid rgba(142, 110, 54, 0.18);
|
||||||
|
box-shadow: 0 32px 80px rgba(59, 40, 16, 0.28);
|
||||||
|
overflow: hidden;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto auto 1fr;
|
||||||
|
}
|
||||||
|
.modal-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 22px 24px 16px;
|
||||||
|
border-bottom: 1px solid rgba(128, 98, 48, 0.12);
|
||||||
|
}
|
||||||
|
.modal-head h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
}
|
||||||
|
.modal-close {
|
||||||
|
border: 0;
|
||||||
|
background: rgba(240, 229, 206, 0.9);
|
||||||
|
color: #6d5127;
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 800;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.modal-close:hover {
|
||||||
|
background: rgba(225, 208, 174, 0.96);
|
||||||
|
}
|
||||||
|
.modal-body {
|
||||||
|
overflow: hidden;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto 1fr;
|
||||||
|
}
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.grid { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.page { padding: 16px; }
|
||||||
|
.hero { padding: 22px 20px; }
|
||||||
|
.kpi-value { font-size: 24px; }
|
||||||
|
th, td { padding: 10px 8px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page">
|
||||||
|
<section class="hero">
|
||||||
|
<span class="meta-chip">#2 백엔드 영속 저장 구조 운영</span>
|
||||||
|
<h1>DB 상태와 저장 구조를 화면에서 바로 확인</h1>
|
||||||
|
<p>
|
||||||
|
이 화면은 현재 운영 DB의 핵심 테이블, 적재 상태, 최근 import 흐름을 SQL 없이 확인하기 위한 관리자용 뷰어입니다.
|
||||||
|
이후 저장 구조 검증과 데이터 정합성 작업은 이 화면을 기준으로 진행합니다.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
`원본 import 배치`는 업로드한 원본 파일이 몇 행으로 적재됐는지 보여주고, `바이너리 원본 보관`은 엑셀 같은 파일 자체를 DB에 보관하는 상태를 보여줍니다.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
아래 표는 전체 테이블을 보여주고, 오른쪽 패널은 화면별 데이터 소스와 저장 흐름을 운영 관점으로 묶어서 보여줍니다.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="overview" class="overview"></section>
|
||||||
|
|
||||||
|
<section class="grid">
|
||||||
|
<article class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<h2>전체 테이블 현황</h2>
|
||||||
|
<p>현재 운영 DB의 전체 26개 테이블을 보여주며, 테이블명을 누르면 샘플 row를 바로 확인할 수 있습니다.</p>
|
||||||
|
</div>
|
||||||
|
<span id="generated-at" class="meta-chip">로딩 중</span>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div class="panel-toolbar">
|
||||||
|
<input id="table-search" class="toolbar-input" type="search" placeholder="테이블명, 설명, 화면명으로 검색" />
|
||||||
|
<div class="toolbar-actions">
|
||||||
|
<span id="table-count" class="meta-chip">0 / 0</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>도메인</th>
|
||||||
|
<th>테이블</th>
|
||||||
|
<th>Rows</th>
|
||||||
|
<th>최근 갱신</th>
|
||||||
|
<th>연결 화면</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="table-body">
|
||||||
|
<tr><td colspan="5" class="empty">DB 상태를 불러오는 중입니다.</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<div style="display:grid; gap:20px;">
|
||||||
|
<article class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<h2>원본 import 배치</h2>
|
||||||
|
<p>현재 적재된 원본 파일 배치와 row 수입니다.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Source</th>
|
||||||
|
<th>Rows</th>
|
||||||
|
<th>Imported</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="batch-body">
|
||||||
|
<tr><td colspan="3" class="empty">로딩 중</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<h2>바이너리 원본 보관</h2>
|
||||||
|
<p>엑셀 같은 바이너리 원본의 DB 보관 상태입니다.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Source</th>
|
||||||
|
<th>파일</th>
|
||||||
|
<th>크기</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="binary-body">
|
||||||
|
<tr><td colspan="3" class="empty">로딩 중</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<h2>운영 메모</h2>
|
||||||
|
<p>#2에서 확인해야 할 저장 구조 핵심 포인트입니다.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<ol id="notes" class="notes"></ol>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<h2>운영 분류</h2>
|
||||||
|
<p>유지/주의/원본·추적/정리 후보 기준과 제품 관점 묶음을 같이 봅니다.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div id="group-summary"></div>
|
||||||
|
<div id="product-summary" style="margin-top:18px;"></div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<h2>화면별 데이터 소스</h2>
|
||||||
|
<p>각 탭/기능이 실제로 어떤 테이블을 읽고 저장하는지 빠르게 확인합니다.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="screen-map" class="panel-body mapping-list"></div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="preview-modal" class="modal-overlay" aria-hidden="true">
|
||||||
|
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="preview-title">
|
||||||
|
<div class="modal-head">
|
||||||
|
<div>
|
||||||
|
<h2 id="preview-title">테이블 내용 미리보기</h2>
|
||||||
|
<p id="preview-subtitle" class="muted">선택한 테이블의 컬럼과 최대 50개 row를 표시합니다.</p>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-actions">
|
||||||
|
<button id="preview-download" class="action-button" type="button" disabled>CSV 다운로드</button>
|
||||||
|
<button id="preview-close" class="modal-close" type="button" aria-label="닫기">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div id="preview-meta" class="preview-meta"></div>
|
||||||
|
<div class="panel-body tight">
|
||||||
|
<div class="preview-table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead id="preview-head" class="sticky-head"></thead>
|
||||||
|
<tbody id="preview-body">
|
||||||
|
<tr><td class="empty">왼쪽 표에서 테이블을 선택하세요.</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let allTables = [];
|
||||||
|
let currentPreview = null;
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value ?? "")
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("'", "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(value) {
|
||||||
|
return new Intl.NumberFormat("ko-KR").format(Number(value || 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value) {
|
||||||
|
if (!value) return '<span class="muted">-</span>';
|
||||||
|
const parsed = new Date(value);
|
||||||
|
if (Number.isNaN(parsed.getTime())) return escapeHtml(value);
|
||||||
|
return parsed.toLocaleString("ko-KR", { hour12: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(value) {
|
||||||
|
const size = Number(value || 0);
|
||||||
|
if (size <= 0) return "0 B";
|
||||||
|
const units = ["B", "KB", "MB", "GB"];
|
||||||
|
let current = size;
|
||||||
|
let unit = 0;
|
||||||
|
while (current >= 1024 && unit < units.length - 1) {
|
||||||
|
current /= 1024;
|
||||||
|
unit += 1;
|
||||||
|
}
|
||||||
|
return `${current.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderOverview(overview) {
|
||||||
|
const target = document.getElementById("overview");
|
||||||
|
target.innerHTML = [
|
||||||
|
["핵심 테이블", overview.visible_tables],
|
||||||
|
["전체 테이블", overview.total_tables],
|
||||||
|
["등록 인원", overview.registered_members],
|
||||||
|
["재직 인원", overview.active_members],
|
||||||
|
["고정 오피스 도면", overview.fixed_office_maps],
|
||||||
|
["현재 active 도면", overview.active_seat_maps],
|
||||||
|
["Import 배치", overview.import_batches],
|
||||||
|
["바이너리 원본", overview.binary_sources],
|
||||||
|
].map(([label, value]) => `
|
||||||
|
<article class="kpi">
|
||||||
|
<span class="kpi-label">${escapeHtml(label)}</span>
|
||||||
|
<span class="kpi-value">${formatNumber(value)}</span>
|
||||||
|
</article>
|
||||||
|
`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTables(items) {
|
||||||
|
const target = document.getElementById("table-body");
|
||||||
|
const countTarget = document.getElementById("table-count");
|
||||||
|
if (countTarget) {
|
||||||
|
countTarget.textContent = `${formatNumber(items.length)} / ${formatNumber(allTables.length)}`;
|
||||||
|
}
|
||||||
|
if (!items.length) {
|
||||||
|
target.innerHTML = '<tr><td colspan="5" class="empty">표시할 테이블이 없습니다.</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
target.innerHTML = items.map((item) => `
|
||||||
|
<tr>
|
||||||
|
<td><span class="domain-tag ${escapeHtml(item.domain)}">${escapeHtml(item.domain)}</span></td>
|
||||||
|
<td>
|
||||||
|
<div style="margin-bottom:8px;">
|
||||||
|
<span class="group-tag ${item.group === '유지' ? 'keep' : item.group === '원본·추적' ? 'trace' : item.group === '정리 후보' ? 'cleanup' : 'caution'}">${escapeHtml(item.group || '주의')}</span>
|
||||||
|
</div>
|
||||||
|
<button class="table-trigger" type="button" data-schema="${escapeHtml(item.schema)}" data-table="${escapeHtml(item.table_name)}">${escapeHtml(item.label)}</button>
|
||||||
|
<div class="muted">${escapeHtml(item.table_ref)}</div>
|
||||||
|
<div class="table-desc">${escapeHtml(item.description)}</div>
|
||||||
|
</td>
|
||||||
|
<td>${formatNumber(item.row_count)}</td>
|
||||||
|
<td>${formatDateTime(item.last_event_at)}</td>
|
||||||
|
<td>
|
||||||
|
<div class="view-list">
|
||||||
|
${(item.related_views || []).map((view) => `<span class="view-pill">${escapeHtml(view)}</span>`).join("")}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join("");
|
||||||
|
target.querySelectorAll(".table-trigger").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
loadTablePreview(button.dataset.schema, button.dataset.table);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBatches(items) {
|
||||||
|
const target = document.getElementById("batch-body");
|
||||||
|
if (!items.length) {
|
||||||
|
target.innerHTML = '<tr><td colspan="3" class="empty">적재 배치가 없습니다.</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
target.innerHTML = items.map((item) => `
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="table-title">${escapeHtml(item.source_name)}</div>
|
||||||
|
<div class="muted">${escapeHtml(item.source_key)}</div>
|
||||||
|
</td>
|
||||||
|
<td>${formatNumber(item.row_count)}</td>
|
||||||
|
<td>${formatDateTime(item.imported_at)}</td>
|
||||||
|
</tr>
|
||||||
|
`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBinarySources(items) {
|
||||||
|
const target = document.getElementById("binary-body");
|
||||||
|
if (!items.length) {
|
||||||
|
target.innerHTML = '<tr><td colspan="3" class="empty">보관 중인 바이너리 원본이 없습니다.</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
target.innerHTML = items.map((item) => `
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="table-title">${escapeHtml(item.source_name)}</div>
|
||||||
|
<div class="muted">${escapeHtml(item.source_key)}</div>
|
||||||
|
</td>
|
||||||
|
<td>${escapeHtml(item.filename)}</td>
|
||||||
|
<td>${formatBytes(item.byte_size)}</td>
|
||||||
|
</tr>
|
||||||
|
`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNotes(notes) {
|
||||||
|
const target = document.getElementById("notes");
|
||||||
|
target.innerHTML = (notes || []).map((note) => `<li>${escapeHtml(note)}</li>`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGroupSummary(summary) {
|
||||||
|
const target = document.getElementById("group-summary");
|
||||||
|
const groups = [
|
||||||
|
["유지", "keep"],
|
||||||
|
["주의", "caution"],
|
||||||
|
["원본·추적", "trace"],
|
||||||
|
["정리 후보", "cleanup"],
|
||||||
|
];
|
||||||
|
target.innerHTML = groups.map(([label, klass]) => `
|
||||||
|
<div style="display:grid; gap:8px; margin-bottom:16px;">
|
||||||
|
<div><span class="group-tag ${klass}">${escapeHtml(label)}</span></div>
|
||||||
|
<div class="view-list">
|
||||||
|
${((summary && summary[label]) || []).map((item) => `<span class="view-pill">${escapeHtml(item)}</span>`).join("") || '<span class="muted">없음</span>'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProductSummary(summary) {
|
||||||
|
const target = document.getElementById("product-summary");
|
||||||
|
const groups = [
|
||||||
|
"탭 데이터",
|
||||||
|
"로그인·권한",
|
||||||
|
"히스토리",
|
||||||
|
"로우데이터·적재",
|
||||||
|
"보정·보조",
|
||||||
|
];
|
||||||
|
target.innerHTML = groups.map((label) => `
|
||||||
|
<div style="display:grid; gap:8px; margin-bottom:16px;">
|
||||||
|
<div><span class="group-tag keep">${escapeHtml(label)}</span></div>
|
||||||
|
<div class="view-list">
|
||||||
|
${((summary && summary[label]) || []).map((item) => `<span class="view-pill">${escapeHtml(item)}</span>`).join("") || '<span class="muted">없음</span>'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderScreenMap(items) {
|
||||||
|
const target = document.getElementById("screen-map");
|
||||||
|
if (!items || !items.length) {
|
||||||
|
target.innerHTML = '<div class="empty">화면별 데이터 소스 정보가 없습니다.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
target.innerHTML = items.map((item) => `
|
||||||
|
<article class="mapping-card">
|
||||||
|
<h3>${escapeHtml(item.screen || "")}</h3>
|
||||||
|
<div class="mapping-table-list">
|
||||||
|
${(item.tables || []).map((table) => `<span class="view-pill">${escapeHtml(table)}</span>`).join("")}
|
||||||
|
</div>
|
||||||
|
<p>${escapeHtml(item.write_flow || "")}</p>
|
||||||
|
</article>
|
||||||
|
`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTablePreview(payload) {
|
||||||
|
currentPreview = payload;
|
||||||
|
const previewModal = document.getElementById("preview-modal");
|
||||||
|
const downloadButton = document.getElementById("preview-download");
|
||||||
|
const previewMeta = document.getElementById("preview-meta");
|
||||||
|
const previewTitle = document.getElementById("preview-title");
|
||||||
|
const previewSubtitle = document.getElementById("preview-subtitle");
|
||||||
|
const previewHead = document.getElementById("preview-head");
|
||||||
|
const previewBody = document.getElementById("preview-body");
|
||||||
|
|
||||||
|
previewTitle.textContent = `${payload.label} · ${payload.table_ref}`;
|
||||||
|
previewSubtitle.textContent = `${formatNumber(payload.row_count)} rows / 최대 ${formatNumber(payload.limit)}개 표시`;
|
||||||
|
if (downloadButton) {
|
||||||
|
downloadButton.disabled = !(payload.rows && payload.rows.length);
|
||||||
|
}
|
||||||
|
previewMeta.innerHTML = `
|
||||||
|
<div>
|
||||||
|
<div class="table-title">${escapeHtml(payload.label)}</div>
|
||||||
|
<div class="muted">${escapeHtml(payload.description || "")}</div>
|
||||||
|
</div>
|
||||||
|
<div class="preview-columns">
|
||||||
|
${(payload.columns || []).map((column) => `
|
||||||
|
<span class="column-pill">${escapeHtml(column.name)} <em>${escapeHtml(column.type)}</em></span>
|
||||||
|
`).join("")}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const columns = payload.columns || [];
|
||||||
|
previewHead.innerHTML = `<tr>${columns.map((column) => `<th>${escapeHtml(column.name)}</th>`).join("")}</tr>`;
|
||||||
|
if (!payload.rows || !payload.rows.length) {
|
||||||
|
previewBody.innerHTML = `<tr><td colspan="${Math.max(columns.length, 1)}" class="empty">표시할 row가 없습니다.</td></tr>`;
|
||||||
|
previewModal.classList.add("open");
|
||||||
|
previewModal.setAttribute("aria-hidden", "false");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
previewBody.innerHTML = payload.rows.map((row) => `
|
||||||
|
<tr>
|
||||||
|
${columns.map((column) => `<td>${escapeHtml(row[column.name] ?? "")}</td>`).join("")}
|
||||||
|
</tr>
|
||||||
|
`).join("");
|
||||||
|
previewModal.classList.add("open");
|
||||||
|
previewModal.setAttribute("aria-hidden", "false");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTablePreview(schema, table) {
|
||||||
|
const previewModal = document.getElementById("preview-modal");
|
||||||
|
const previewMeta = document.getElementById("preview-meta");
|
||||||
|
const previewTitle = document.getElementById("preview-title");
|
||||||
|
const previewSubtitle = document.getElementById("preview-subtitle");
|
||||||
|
const previewHead = document.getElementById("preview-head");
|
||||||
|
const previewBody = document.getElementById("preview-body");
|
||||||
|
previewTitle.textContent = `${table}`;
|
||||||
|
previewSubtitle.textContent = "테이블 내용을 불러오는 중입니다.";
|
||||||
|
previewMeta.innerHTML = "";
|
||||||
|
previewHead.innerHTML = "";
|
||||||
|
previewBody.innerHTML = `<tr><td class="empty">테이블 내용을 불러오는 중입니다.</td></tr>`;
|
||||||
|
previewModal.classList.add("open");
|
||||||
|
previewModal.setAttribute("aria-hidden", "false");
|
||||||
|
const response = await fetch(`/api/admin/db-status/table?schema=${encodeURIComponent(schema)}&table=${encodeURIComponent(table)}`, { cache: "no-store" });
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`테이블 내용을 불러오지 못했습니다. (${response.status})`);
|
||||||
|
}
|
||||||
|
const payload = await response.json();
|
||||||
|
renderTablePreview(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
const response = await fetch("/api/admin/db-status", { cache: "no-store" });
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`DB 상태를 불러오지 못했습니다. (${response.status})`);
|
||||||
|
}
|
||||||
|
const payload = await response.json();
|
||||||
|
allTables = payload.tables || [];
|
||||||
|
document.getElementById("generated-at").textContent = payload.generated_at
|
||||||
|
? `갱신 ${formatDateTime(payload.generated_at)}`
|
||||||
|
: "갱신 시각 없음";
|
||||||
|
renderOverview(payload.overview || {});
|
||||||
|
renderTables(allTables);
|
||||||
|
renderBatches(payload.import_batches || []);
|
||||||
|
renderBinarySources(payload.binary_sources || []);
|
||||||
|
renderNotes(payload.notes || []);
|
||||||
|
renderGroupSummary(payload.group_summary || {});
|
||||||
|
renderProductSummary(payload.product_summary || {});
|
||||||
|
renderScreenMap(payload.screen_map || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toCsvValue(value) {
|
||||||
|
const text = String(value ?? "");
|
||||||
|
if (!/[",\n]/.test(text)) return text;
|
||||||
|
return `"${text.replaceAll('"', '""')}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadPreviewCsv() {
|
||||||
|
if (!currentPreview || !currentPreview.columns || !currentPreview.rows || !currentPreview.rows.length) return;
|
||||||
|
const headers = currentPreview.columns.map((column) => column.name);
|
||||||
|
const lines = [
|
||||||
|
headers.map(toCsvValue).join(","),
|
||||||
|
...currentPreview.rows.map((row) => headers.map((header) => toCsvValue(row[header] ?? "")).join(",")),
|
||||||
|
];
|
||||||
|
const blob = new Blob(["\ufeff" + lines.join("\n")], { type: "text/csv;charset=utf-8;" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = `${currentPreview.table_name || "table-preview"}.csv`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTableSearch(query) {
|
||||||
|
const normalized = String(query || "").trim().toLowerCase();
|
||||||
|
if (!normalized) {
|
||||||
|
renderTables(allTables);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const filtered = allTables.filter((item) => {
|
||||||
|
const haystack = [
|
||||||
|
item.label,
|
||||||
|
item.table_ref,
|
||||||
|
item.description,
|
||||||
|
...(item.related_views || []),
|
||||||
|
item.domain,
|
||||||
|
item.group,
|
||||||
|
].join(" ").toLowerCase();
|
||||||
|
return haystack.includes(normalized);
|
||||||
|
});
|
||||||
|
renderTables(filtered);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("preview-close").addEventListener("click", () => {
|
||||||
|
const modal = document.getElementById("preview-modal");
|
||||||
|
modal.classList.remove("open");
|
||||||
|
modal.setAttribute("aria-hidden", "true");
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("preview-download").addEventListener("click", downloadPreviewCsv);
|
||||||
|
document.getElementById("table-search").addEventListener("input", (event) => {
|
||||||
|
applyTableSearch(event.target.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("preview-modal").addEventListener("click", (event) => {
|
||||||
|
if (event.target.id !== "preview-modal") return;
|
||||||
|
const modal = document.getElementById("preview-modal");
|
||||||
|
modal.classList.remove("open");
|
||||||
|
modal.setAttribute("aria-hidden", "true");
|
||||||
|
});
|
||||||
|
|
||||||
|
bootstrap().catch((error) => {
|
||||||
|
document.getElementById("table-body").innerHTML = `<tr><td colspan="5" class="empty">${escapeHtml(error.message || "DB 상태를 불러오지 못했습니다.")}</td></tr>`;
|
||||||
|
document.getElementById("batch-body").innerHTML = '<tr><td colspan="3" class="empty">배치 정보를 불러오지 못했습니다.</td></tr>';
|
||||||
|
document.getElementById("binary-body").innerHTML = '<tr><td colspan="3" class="empty">바이너리 원본 정보를 불러오지 못했습니다.</td></tr>';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -4,8 +4,8 @@
|
|||||||
|
|
||||||
source-of-truth:
|
source-of-truth:
|
||||||
|
|
||||||
- [frontend/apps/ledger](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/apps/ledger)
|
- [frontend/apps/ledger](../../../frontend/apps/ledger)
|
||||||
- 반영 스크립트: [scripts/publish_ledger_app.sh](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/scripts/publish_ledger_app.sh)
|
- 반영 스크립트: [scripts/publish_ledger_app.sh](../../../scripts/publish_ledger_app.sh)
|
||||||
|
|
||||||
- `index.html`: `/integrations/ledger` 응답 본문
|
- `index.html`: `/integrations/ledger` 응답 본문
|
||||||
- `frontend/apps/ledger/index.html` 템플릿에서 publish 시 생성
|
- `frontend/apps/ledger/index.html` 템플릿에서 publish 시 생성
|
||||||
|
|||||||
@@ -10,6 +10,10 @@
|
|||||||
return new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
return new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function bgNormalizeText(value) {
|
||||||
|
return String(value || "").replace(/\s+/g, " ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
function bgParseDate(value) {
|
function bgParseDate(value) {
|
||||||
var text = String(value || "").trim();
|
var text = String(value || "").trim();
|
||||||
if (!text) return null;
|
if (!text) return null;
|
||||||
@@ -36,6 +40,44 @@
|
|||||||
return bgYearFromText(row && row.eDate);
|
return bgYearFromText(row && row.eDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizedCategory(row) {
|
||||||
|
var category = bgNormalizeText(row && row.cat);
|
||||||
|
if (category.indexOf("가족사") >= 0) return "가족사";
|
||||||
|
var corp = bgNormalizeText(row && row.corp);
|
||||||
|
if (corp && corp !== "바론") return "가족사";
|
||||||
|
return "바론";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSupportServiceRow(row) {
|
||||||
|
return bgNormalizeText(row && row.name).indexOf("경영 및 기술지원 서비스") >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectTypeLabel(row) {
|
||||||
|
if (isSupportServiceRow(row)) return "기술지원서비스";
|
||||||
|
return normalizedCategory(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectTypeRank(row) {
|
||||||
|
var label = projectTypeLabel(row);
|
||||||
|
if (label === "바론") return 0;
|
||||||
|
if (label === "가족사") return 1;
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStatusLabel(status) {
|
||||||
|
var value = bgNormalizeText(status);
|
||||||
|
if (!value) return "-";
|
||||||
|
if (value === "완료") return "준공";
|
||||||
|
if (value === "진행") return "과업진행중";
|
||||||
|
if (value === "대기") return "계약대기";
|
||||||
|
if (value === "중지") return "과업중지";
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowStatusLabel(row) {
|
||||||
|
return normalizeStatusLabel(row && row.status);
|
||||||
|
}
|
||||||
|
|
||||||
function bgDisplayYear(row) {
|
function bgDisplayYear(row) {
|
||||||
var start = bgStartYear(row);
|
var start = bgStartYear(row);
|
||||||
if (start) return start;
|
if (start) return start;
|
||||||
@@ -82,7 +124,7 @@
|
|||||||
if (!(cutoff && yearStart && startDate)) return false;
|
if (!(cutoff && yearStart && startDate)) return false;
|
||||||
if (startDate > cutoff) return false;
|
if (startDate > cutoff) return false;
|
||||||
if (endDate && endDate < yearStart) return false;
|
if (endDate && endDate < yearStart) return false;
|
||||||
return !(endDate && endDate <= cutoff);
|
return rowStatusLabel(row) === "과업진행중";
|
||||||
}
|
}
|
||||||
|
|
||||||
function bgStartedInYear(row, year) {
|
function bgStartedInYear(row, year) {
|
||||||
@@ -96,7 +138,7 @@
|
|||||||
var cutoff = bgYearCutoff(year);
|
var cutoff = bgYearCutoff(year);
|
||||||
var endDate = bgDateOrYearEnd(row);
|
var endDate = bgDateOrYearEnd(row);
|
||||||
if (!(cutoff && endDate)) return false;
|
if (!(cutoff && endDate)) return false;
|
||||||
return endDate.getFullYear() === Number(year || 0) && endDate <= cutoff;
|
return rowStatusLabel(row) === "준공" && endDate.getFullYear() === Number(year || 0) && endDate <= cutoff;
|
||||||
}
|
}
|
||||||
|
|
||||||
function bgYearRange(row) {
|
function bgYearRange(row) {
|
||||||
@@ -140,16 +182,32 @@
|
|||||||
}, { c: 0, col: 0, recv: 0 });
|
}, { c: 0, col: 0, recv: 0 });
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSupportServiceRow(row) {
|
function isBaronProjectRow(row) {
|
||||||
var category = String((row && row.cat) || "").trim();
|
return projectTypeLabel(row) === "바론";
|
||||||
return category.indexOf("경영지원") >= 0 || category.indexOf("서비스") >= 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isBaronProjectRow(row) {
|
function isSoftwareProjectRow(row) {
|
||||||
var category = String((row && row.cat) || "").trim();
|
var name = bgNormalizeText(row && row.name).toLowerCase();
|
||||||
if (category.indexOf("바론") < 0) return false;
|
if (!name) return false;
|
||||||
if (isSupportServiceRow(row)) return false;
|
return [
|
||||||
return true;
|
"프로그램",
|
||||||
|
"소프트웨어",
|
||||||
|
"software",
|
||||||
|
" sw",
|
||||||
|
"sw ",
|
||||||
|
"erp",
|
||||||
|
"tova",
|
||||||
|
"ipipe",
|
||||||
|
"eg-bim",
|
||||||
|
"cad"
|
||||||
|
].some(function (keyword) {
|
||||||
|
return name.indexOf(keyword) >= 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldSinkProjectName(row) {
|
||||||
|
var name = bgNormalizeText(row && row.name);
|
||||||
|
return name.indexOf("프로그램") >= 0 || name.indexOf("사용") >= 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function bgSummarize(rows, selectedYear) {
|
function bgSummarize(rows, selectedYear) {
|
||||||
@@ -158,14 +216,18 @@
|
|||||||
var activeRows = items.filter(function (row) { return bgActiveInYear(row, targetYear); });
|
var activeRows = items.filter(function (row) { return bgActiveInYear(row, targetYear); });
|
||||||
var newProjectRows = items.filter(function (row) { return bgStartedInYear(row, targetYear); });
|
var newProjectRows = items.filter(function (row) { return bgStartedInYear(row, targetYear); });
|
||||||
var completedRows = items.filter(function (row) { return bgCompletedInYear(row, targetYear); });
|
var completedRows = items.filter(function (row) { return bgCompletedInYear(row, targetYear); });
|
||||||
var managementRows = newProjectRows.filter(isSupportServiceRow);
|
var managementRows = activeRows.filter(isSupportServiceRow);
|
||||||
|
var baronActiveRows = activeRows.filter(isBaronProjectRow);
|
||||||
return {
|
return {
|
||||||
targetYear: targetYear,
|
targetYear: targetYear,
|
||||||
activeRows: activeRows,
|
activeRows: activeRows,
|
||||||
newProjectRows: newProjectRows,
|
newProjectRows: newProjectRows,
|
||||||
completedRows: completedRows,
|
completedRows: completedRows,
|
||||||
managementRows: managementRows,
|
managementRows: managementRows,
|
||||||
managementTotals: bgTotals(managementRows)
|
managementTotals: bgTotals(managementRows),
|
||||||
|
baronActiveRows: baronActiveRows,
|
||||||
|
baronProjectTotals: bgTotals(baronActiveRows),
|
||||||
|
baronSoftwareCount: baronActiveRows.filter(isSoftwareProjectRow).length
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,13 +239,6 @@
|
|||||||
return bgActiveInYear(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) {
|
function formatSplitPercent(split) {
|
||||||
var numeric = parseFloat(String(split || "").replace(/[^0-9.\-]/g, ""));
|
var numeric = parseFloat(String(split || "").replace(/[^0-9.\-]/g, ""));
|
||||||
if (!Number.isFinite(numeric) || numeric === 0) return "분담율 -%";
|
if (!Number.isFinite(numeric) || numeric === 0) return "분담율 -%";
|
||||||
@@ -204,17 +259,57 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function groupSortRank(row) {
|
function groupSortRank(row) {
|
||||||
var selectedYear = Number((S.dashboard && S.dashboard.year) || projectYear(row) || 0);
|
|
||||||
var startYear = Number(projectYear(row) || 0);
|
var startYear = Number(projectYear(row) || 0);
|
||||||
if (typeof bgCompletedInYear === "function" && bgCompletedInYear(row, String(selectedYear))) return 9999;
|
|
||||||
if (!startYear) return 9998;
|
if (!startYear) return 9998;
|
||||||
return startYear;
|
return startYear;
|
||||||
}
|
}
|
||||||
|
|
||||||
function tableGroupLabel(row) {
|
function tableGroupLabel(row) {
|
||||||
var startYear = projectYear(row);
|
var startYear = projectYear(row);
|
||||||
if (/^20\d{2}$/.test(startYear)) return startYear + "년";
|
if (/^20\d{2}$/.test(startYear)) return startYear + " " + projectTypeLabel(row);
|
||||||
return "미지정";
|
return "미지정 " + projectTypeLabel(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareDashboardRows(a, b) {
|
||||||
|
var typeRankDiff = projectTypeRank(a) - projectTypeRank(b);
|
||||||
|
if (typeRankDiff !== 0) return typeRankDiff;
|
||||||
|
var groupDiff = groupSortRank(a) - groupSortRank(b);
|
||||||
|
if (groupDiff !== 0) return groupDiff;
|
||||||
|
var sinkDiff = Number(shouldSinkProjectName(a)) - Number(shouldSinkProjectName(b));
|
||||||
|
if (sinkDiff !== 0) return sinkDiff;
|
||||||
|
return bgNormalizeText(a && a.name).localeCompare(bgNormalizeText(b && b.name), "ko");
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterCategoryLabel(row) {
|
||||||
|
return projectTypeLabel(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterClientLabel(row) {
|
||||||
|
if (typeof normalizeClientDisplay === "function") {
|
||||||
|
return normalizeClientDisplay(row && row.client);
|
||||||
|
}
|
||||||
|
return bgNormalizeText(row && row.client) || "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterOrderLabel(row) {
|
||||||
|
return bgNormalizeText(row && row.order) || "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
function receivableFilterLabel(row) {
|
||||||
|
var amount = Number((row && row.recv) || 0);
|
||||||
|
if (amount <= 0) return "미수 없음";
|
||||||
|
if (amount < 10000000) return "1천만 미만";
|
||||||
|
if (amount < 100000000) return "1천만 이상";
|
||||||
|
return "1억 이상";
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshFilterDom() {
|
||||||
|
E.filterButtons = Object.fromEntries(Array.from(document.querySelectorAll(".th-trigger")).map(function (el) {
|
||||||
|
return [el.dataset.filter, el];
|
||||||
|
}));
|
||||||
|
E.filterMenus = Object.fromEntries(Array.from(document.querySelectorAll(".th-menu")).map(function (el) {
|
||||||
|
return [el.dataset.filter, el];
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderLedgerTable() {
|
function renderLedgerTable() {
|
||||||
@@ -236,12 +331,7 @@
|
|||||||
+ '<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>'
|
+ '<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>";
|
+ "</tr>";
|
||||||
}
|
}
|
||||||
var rows = (Array.isArray(S.viewRows) ? S.viewRows : []).slice().sort(function (a, b) {
|
var rows = (Array.isArray(S.viewRows) ? S.viewRows : []).slice().sort(compareDashboardRows);
|
||||||
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;
|
S.viewRows = rows;
|
||||||
var lastGroupLabel = "";
|
var lastGroupLabel = "";
|
||||||
E.tbody.innerHTML = rows.map(function (r) {
|
E.tbody.innerHTML = rows.map(function (r) {
|
||||||
@@ -259,7 +349,7 @@
|
|||||||
+ '<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><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 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>' + esc(r.order || "-") + '</div></td>'
|
||||||
+ '<td><div class="badge ' + (String(r.status || "").indexOf("완료") >= 0 ? 'ok' : '') + '">' + esc(normalizeStatusLabel(r.status)) + '</div></td>'
|
+ '<td><div class="badge ' + (rowStatusLabel(r) === "준공" ? 'ok' : '') + '">' + esc(rowStatusLabel(r)) + '</div></td>'
|
||||||
+ '<td class="num"><strong>' + esc(won(r.cSup || 0)) + '</strong></td>'
|
+ '<td class="num"><strong>' + esc(won(r.cSup || 0)) + '</strong></td>'
|
||||||
+ '<td class="num"><strong>' + esc(r.outsourceCost ? won(r.outsourceCost) : "-") + '</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.recv || 0)) + '</strong></td>'
|
||||||
@@ -267,6 +357,8 @@
|
|||||||
+ '<td class="num"><strong style="color:' + (isSettledRow(r) ? '#b7aa93' : '#1a5645') + '">' + esc((Number(r.rate || 0)).toFixed(2) + "%") + '</strong></td>'
|
+ '<td class="num"><strong style="color:' + (isSettledRow(r) ? '#b7aa93' : '#1a5645') + '">' + esc((Number(r.rate || 0)).toFixed(2) + "%") + '</strong></td>'
|
||||||
+ '</tr>';
|
+ '</tr>';
|
||||||
}).join("");
|
}).join("");
|
||||||
|
refreshFilterDom();
|
||||||
|
if (typeof syncColumnFilters === "function") syncColumnFilters(S.all);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderCollectionBoard(r) {
|
function renderCollectionBoard(r) {
|
||||||
@@ -379,10 +471,8 @@
|
|||||||
}
|
}
|
||||||
var years = bgEnsureYear(S.all);
|
var years = bgEnsureYear(S.all);
|
||||||
var summary = bgSummarize(S.all, S.dashboard.year);
|
var summary = bgSummarize(S.all, S.dashboard.year);
|
||||||
var rows = Array.isArray(S.rows) ? S.rows : [];
|
var totals = summary.baronProjectTotals;
|
||||||
var visibleBaronProjectRows = rows.filter(isBaronProjectRow);
|
var totalRate = totals.c > 0 ? (totals.col / totals.c) * 100 : 0;
|
||||||
var totals = bgTotals(visibleBaronProjectRows);
|
|
||||||
var totalRate = typeof rate === "function" ? rate("", totals.col, totals.col + totals.recv) : 0;
|
|
||||||
var toolbarHtml = '<div class="cards-toolbar">'
|
var toolbarHtml = '<div class="cards-toolbar">'
|
||||||
+ '<div class="cards-toolbar-row">'
|
+ '<div class="cards-toolbar-row">'
|
||||||
+ years.map(function (year) {
|
+ years.map(function (year) {
|
||||||
@@ -393,14 +483,14 @@
|
|||||||
+ '<div class="cards-toolbar-metrics">'
|
+ '<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 === "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 === "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>'
|
+ '<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>";
|
+ "</div></div>";
|
||||||
var cards = [
|
var cards = [
|
||||||
{ label: summary.targetYear + "년 프로젝트", value: visibleBaronProjectRows.length.toLocaleString("ko-KR") + " 건", note: "" },
|
{ label: summary.targetYear + "년 프로젝트", value: summary.baronActiveRows.length.toLocaleString("ko-KR") + "건 (" + summary.baronSoftwareCount.toLocaleString("ko-KR") + "건)", note: "바론 수행중 프로젝트 / SW" },
|
||||||
{ label: "계약금", value: won(totals.c), note: "" },
|
{ label: "계약금 (VAT별도)", value: won(totals.c), note: "" },
|
||||||
{ label: "수금액", value: won(totals.col), note: "" },
|
{ label: "수금액", value: won(totals.col), note: "" },
|
||||||
{ label: "미수금", value: won(totals.recv), note: "" },
|
{ label: "미수금", value: won(totals.recv), note: "" },
|
||||||
{ label: "수금률(%)", value: totalRate.toFixed(2) + "%", note: "" },
|
{ label: "수금율", value: totalRate.toFixed(2) + "%", note: "계약금 대비 수금액" },
|
||||||
{ label: "경영지원서비스 금액", value: won(summary.managementTotals.c), note: "", className: "management" }
|
{ label: "경영지원서비스 금액", value: won(summary.managementTotals.c), note: "", className: "management" }
|
||||||
];
|
];
|
||||||
E.cards.innerHTML = toolbarHtml + cards.map(function (card) {
|
E.cards.innerHTML = toolbarHtml + cards.map(function (card) {
|
||||||
@@ -429,9 +519,62 @@
|
|||||||
S.rows = searched.filter(function (r) {
|
S.rows = searched.filter(function (r) {
|
||||||
return bgMatches(r) && matchesColumnFilters(r);
|
return bgMatches(r) && matchesColumnFilters(r);
|
||||||
});
|
});
|
||||||
|
S.rows.sort(compareDashboardRows);
|
||||||
render();
|
render();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
filterDefinitions = function () {
|
||||||
|
return [
|
||||||
|
{ key: "cat", map: filterCategoryLabel },
|
||||||
|
{ key: "code", map: function (r) { return r.code || "-"; } },
|
||||||
|
{ key: "name", map: function (r) { return r.name || "-"; } },
|
||||||
|
{ key: "client", map: filterClientLabel },
|
||||||
|
{ key: "order", map: filterOrderLabel },
|
||||||
|
{ key: "status", map: rowStatusLabel },
|
||||||
|
{ key: "amount", map: amountFilterLabel },
|
||||||
|
{ key: "outsource", map: outsourceFilterLabel },
|
||||||
|
{ key: "receivable", map: receivableFilterLabel },
|
||||||
|
{ key: "collected", map: collectedFilterLabel },
|
||||||
|
{ key: "rate", map: rateFilterLabel }
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
updateFilterButtons = function () {
|
||||||
|
Object.keys(E.filterButtons || {}).forEach(function (key) {
|
||||||
|
var btn = E.filterButtons[key];
|
||||||
|
if (!btn) return;
|
||||||
|
var active = !!S.filters[key];
|
||||||
|
btn.classList.toggle("active", active);
|
||||||
|
btn.title = active ? ((btn.dataset.label || "") + ": " + S.filters[key]) : (btn.dataset.label || "");
|
||||||
|
var mark = btn.querySelector(".th-mark");
|
||||||
|
if (mark) mark.textContent = active ? "•" : "";
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
syncColumnFilters = function (rows) {
|
||||||
|
filterDefinitions().forEach(function (def) {
|
||||||
|
var values = uniqueFilterValues(rows, def.map);
|
||||||
|
if (S.filters[def.key] && !values.includes(S.filters[def.key])) delete S.filters[def.key];
|
||||||
|
renderFilterMenu(def.key, values);
|
||||||
|
});
|
||||||
|
updateFilterButtons();
|
||||||
|
};
|
||||||
|
|
||||||
|
matchesColumnFilters = function (r) {
|
||||||
|
if (S.filters.cat && filterCategoryLabel(r) !== S.filters.cat) return false;
|
||||||
|
if (S.filters.code && (r.code || "-") !== S.filters.code) return false;
|
||||||
|
if (S.filters.name && (r.name || "-") !== S.filters.name) return false;
|
||||||
|
if (S.filters.client && filterClientLabel(r) !== S.filters.client) return false;
|
||||||
|
if (S.filters.order && filterOrderLabel(r) !== S.filters.order) return false;
|
||||||
|
if (S.filters.status && rowStatusLabel(r) !== S.filters.status) return false;
|
||||||
|
if (S.filters.amount && amountFilterLabel(r) !== S.filters.amount) return false;
|
||||||
|
if (S.filters.outsource && outsourceFilterLabel(r) !== S.filters.outsource) return false;
|
||||||
|
if (S.filters.receivable && receivableFilterLabel(r) !== S.filters.receivable) return false;
|
||||||
|
if (S.filters.collected && collectedFilterLabel(r) !== S.filters.collected) return false;
|
||||||
|
if (S.filters.rate && rateFilterLabel(r) !== S.filters.rate) return false;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
if (E.cards && !E.cards.dataset.dashboardBound) {
|
if (E.cards && !E.cards.dataset.dashboardBound) {
|
||||||
E.cards.dataset.dashboardBound = "true";
|
E.cards.dataset.dashboardBound = "true";
|
||||||
E.cards.addEventListener("click", function (event) {
|
E.cards.addEventListener("click", function (event) {
|
||||||
@@ -472,6 +615,26 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var panel = document.querySelector(".panel");
|
||||||
|
if (panel && !panel.dataset.ledgerFilterBound) {
|
||||||
|
panel.dataset.ledgerFilterBound = "true";
|
||||||
|
panel.addEventListener("click", function (event) {
|
||||||
|
var trigger = event.target && event.target.closest ? event.target.closest(".th-trigger") : null;
|
||||||
|
if (trigger) {
|
||||||
|
refreshFilterDom();
|
||||||
|
event.stopPropagation();
|
||||||
|
toggleFilterMenu(trigger.dataset.filter);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var option = event.target && event.target.closest ? event.target.closest("button[data-filter-value]") : null;
|
||||||
|
var menu = event.target && event.target.closest ? event.target.closest(".th-menu") : null;
|
||||||
|
if (option && menu) {
|
||||||
|
event.stopPropagation();
|
||||||
|
setFilterValue(menu.dataset.filter, option.getAttribute("data-filter-value") || "");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
setTimeout(function () {
|
setTimeout(function () {
|
||||||
try {
|
try {
|
||||||
filter();
|
filter();
|
||||||
|
|||||||
@@ -338,11 +338,16 @@ body {
|
|||||||
|
|
||||||
.modal-content.wide #modal-fields {
|
.modal-content.wide #modal-fields {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-content.wide #modal-footer-area {
|
.modal-content.wide #modal-footer-area {
|
||||||
margin-top: 0 !important;
|
margin-top: 0 !important;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
background: var(--color-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-photo-field {
|
.member-photo-field {
|
||||||
@@ -761,6 +766,7 @@ body {
|
|||||||
height: 300px;
|
height: 300px;
|
||||||
border: 0;
|
border: 0;
|
||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.seat-preview-card.is-assigned .seat-preview-frame {
|
.seat-preview-card.is-assigned .seat-preview-frame {
|
||||||
@@ -1033,7 +1039,8 @@ body {
|
|||||||
.modal-footer-actions {
|
.modal-footer-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
width: 100%;
|
width: auto;
|
||||||
|
flex: 1 1 auto;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
@@ -1045,10 +1052,12 @@ body {
|
|||||||
padding: 14px 18px;
|
padding: 14px 18px;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
|
white-space: nowrap;
|
||||||
|
writing-mode: horizontal-tb;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-btn-cancel {
|
.modal-btn-cancel {
|
||||||
flex: 1 1 0;
|
flex: 0 1 140px;
|
||||||
background: var(--color-surface-strong);
|
background: var(--color-surface-strong);
|
||||||
color: var(--color-text-soft);
|
color: var(--color-text-soft);
|
||||||
border-color: var(--color-border);
|
border-color: var(--color-border);
|
||||||
@@ -1059,7 +1068,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modal-btn-save {
|
.modal-btn-save {
|
||||||
flex: 1 1 0;
|
flex: 0 1 140px;
|
||||||
background: var(--color-header);
|
background: var(--color-header);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
box-shadow: 0 10px 22px rgba(47, 153, 115, 0.2);
|
box-shadow: 0 10px 22px rgba(47, 153, 115, 0.2);
|
||||||
@@ -1070,6 +1079,8 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modal-btn-delete {
|
.modal-btn-delete {
|
||||||
|
flex: 0 0 132px;
|
||||||
|
min-width: 132px;
|
||||||
background: rgba(198, 71, 56, 0.12);
|
background: rgba(198, 71, 56, 0.12);
|
||||||
color: #a33427;
|
color: #a33427;
|
||||||
border-color: rgba(198, 71, 56, 0.2);
|
border-color: rgba(198, 71, 56, 0.2);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ let selectedDept = '전체';
|
|||||||
let editingMembers = [];
|
let editingMembers = [];
|
||||||
let collapsedUnits = new Set();
|
let collapsedUnits = new Set();
|
||||||
let isListMode = false;
|
let isListMode = false;
|
||||||
|
let isListDetailModal = false;
|
||||||
let emptyStateMessage = '서버에 조직 데이터가 없습니다. 상단의 업로드 버튼으로 초기 데이터를 넣어주세요.';
|
let emptyStateMessage = '서버에 조직 데이터가 없습니다. 상단의 업로드 버튼으로 초기 데이터를 넣어주세요.';
|
||||||
let photoPreviewObjectUrl = null;
|
let photoPreviewObjectUrl = null;
|
||||||
let seatMapLayoutCache = null;
|
let seatMapLayoutCache = null;
|
||||||
@@ -485,6 +486,24 @@ function createNodeDOM(node, parentId) {
|
|||||||
return nodeItem;
|
return nodeItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyMemberPlacementFromTarget(member, targetLevel, targetName, targetMember = null) {
|
||||||
|
const targetLevelIndex = levelOrder.indexOf(targetLevel);
|
||||||
|
if (targetLevelIndex === -1) {
|
||||||
|
return member;
|
||||||
|
}
|
||||||
|
for (let index = 0; index <= targetLevelIndex; index += 1) {
|
||||||
|
member[levelOrder[index]] = targetMember ? (targetMember[levelOrder[index]] || '') : (index === targetLevelIndex ? targetName : member[levelOrder[index]]);
|
||||||
|
}
|
||||||
|
if (!targetMember) {
|
||||||
|
member[targetLevel] = targetName;
|
||||||
|
}
|
||||||
|
for (let index = targetLevelIndex + 1; index < levelOrder.length; index += 1) {
|
||||||
|
member[levelOrder[index]] = '';
|
||||||
|
}
|
||||||
|
rebuildMemberPath(member);
|
||||||
|
return member;
|
||||||
|
}
|
||||||
|
|
||||||
function drawLines() {
|
function drawLines() {
|
||||||
const container = document.getElementById('tree-root');
|
const container = document.getElementById('tree-root');
|
||||||
const svg = document.getElementById('svg-canvas');
|
const svg = document.getElementById('svg-canvas');
|
||||||
@@ -628,6 +647,11 @@ function render() {
|
|||||||
deptBox.id = deptId;
|
deptBox.id = deptId;
|
||||||
deptBox.className = 'dept-box';
|
deptBox.className = 'dept-box';
|
||||||
deptBox.setAttribute('data-level', '부서');
|
deptBox.setAttribute('data-level', '부서');
|
||||||
|
if (isAdmin) {
|
||||||
|
deptBox.ondragover = (event) => handleDragOver(event);
|
||||||
|
deptBox.ondragleave = (event) => handleDragLeave(event);
|
||||||
|
deptBox.ondrop = (event) => handleDrop(event, '부서', deptName);
|
||||||
|
}
|
||||||
deptBox.innerHTML = `<div class="dept-header ${isAdmin ? 'clickable-title' : ''} ${hasMembers ? 'has-members' : ''}" ${isAdmin ? `onclick="openOrgEditModal('부서', '${jsString(deptName)}')"` : ''}>${deptName} (${totalCount})</div>`;
|
deptBox.innerHTML = `<div class="dept-header ${isAdmin ? 'clickable-title' : ''} ${hasMembers ? 'has-members' : ''}" ${isAdmin ? `onclick="openOrgEditModal('부서', '${jsString(deptName)}')"` : ''}>${deptName} (${totalCount})</div>`;
|
||||||
|
|
||||||
if (hasMembers) {
|
if (hasMembers) {
|
||||||
@@ -827,6 +851,53 @@ function openAddModal(event) {
|
|||||||
openModal(null);
|
openModal(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderListViewShell(defaultDate) {
|
||||||
|
const modal = document.getElementById('modal');
|
||||||
|
modal.querySelector('.modal-content').classList.add('wide');
|
||||||
|
document.getElementById('modal-title').innerText = '인원 명단';
|
||||||
|
const fieldsArea = document.getElementById('modal-fields');
|
||||||
|
fieldsArea.className = 'flex flex-col w-full overflow-hidden';
|
||||||
|
fieldsArea.style.maxHeight = '75vh';
|
||||||
|
fieldsArea.style.overflowY = 'hidden';
|
||||||
|
fieldsArea.innerHTML = `
|
||||||
|
<div class="list-toolbar">
|
||||||
|
<div class="list-toolbar-row">
|
||||||
|
<div class="list-toolbar-group">
|
||||||
|
<button type="button" onclick="showCurrentListView()" class="list-mode-btn">현재 명단</button>
|
||||||
|
</div>
|
||||||
|
<div class="list-toolbar-divider" aria-hidden="true"></div>
|
||||||
|
<div class="list-toolbar-group list-date-group">
|
||||||
|
<input type="date" id="list-snapshot-date" value="${escapeHtml(defaultDate)}" class="list-date-input">
|
||||||
|
<button type="button" onclick="loadSnapshotListView()" class="list-mode-btn">기준일 조회</button>
|
||||||
|
</div>
|
||||||
|
<div class="list-toolbar-divider" aria-hidden="true"></div>
|
||||||
|
<div class="list-toolbar-group list-date-group">
|
||||||
|
<input type="date" id="list-compare-from" value="${escapeHtml(defaultDate)}" class="list-date-input">
|
||||||
|
<span class="list-date-separator">~</span>
|
||||||
|
<input type="date" id="list-compare-to" value="${escapeHtml(defaultDate)}" class="list-date-input">
|
||||||
|
<button type="button" onclick="loadCompareListView()" class="list-mode-btn">변경 비교</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="list-toolbar-row">
|
||||||
|
<input type="text" id="list-search-input" placeholder="이름 또는 부서/팀 검색 (Enter 시 이동)" class="flex-1 bg-[#f6eddd] border-2 border-[#e0d0b4] p-3 rounded-xl text-sm outline-none font-bold text-[#1f2f25] focus:border-[#d68a3a] transition-all" onkeydown="if(event.key==='Enter') handleListSearch(this.value)">
|
||||||
|
<button type="button" onclick="handleListSearch(document.getElementById('list-search-input').value)" class="bg-[#214634] text-white px-5 rounded-xl font-bold text-sm">검색</button>
|
||||||
|
</div>
|
||||||
|
<div id="list-view-status" class="list-view-status"></div>
|
||||||
|
</div>
|
||||||
|
<div id="list-table-container" class="overflow-y-auto flex-1 border rounded-xl"></div>
|
||||||
|
`;
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
function returnToListViewModal() {
|
||||||
|
if (!isListMode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isListDetailModal = false;
|
||||||
|
renderListViewShell(listViewState.snapshotDate || getDefaultHistoryDate());
|
||||||
|
renderListViewModalContent();
|
||||||
|
}
|
||||||
|
|
||||||
function updateParentList() {
|
function updateParentList() {
|
||||||
const type = document.getElementById('new-unit-type').value;
|
const type = document.getElementById('new-unit-type').value;
|
||||||
const parentSelect = document.getElementById('new-unit-parent');
|
const parentSelect = document.getElementById('new-unit-parent');
|
||||||
@@ -1159,7 +1230,8 @@ function openModal(id) {
|
|||||||
modal.querySelector('.modal-content').classList.add('wide');
|
modal.querySelector('.modal-content').classList.add('wide');
|
||||||
fieldsArea.className = 'flex flex-col w-full';
|
fieldsArea.className = 'flex flex-col w-full';
|
||||||
fieldsArea.style.maxHeight = 'none';
|
fieldsArea.style.maxHeight = 'none';
|
||||||
fieldsArea.style.overflowY = 'visible';
|
fieldsArea.style.overflowY = 'auto';
|
||||||
|
isListDetailModal = isListMode;
|
||||||
|
|
||||||
const sourceValues = isListMode ? editingMembers : members;
|
const sourceValues = isListMode ? editingMembers : members;
|
||||||
let orgFields = '<div id="modal-sec-org" class="hidden grid grid-cols-2 gap-3 modal-form-grid">';
|
let orgFields = '<div id="modal-sec-org" class="hidden grid grid-cols-2 gap-3 modal-form-grid">';
|
||||||
@@ -1279,11 +1351,17 @@ function openModal(id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function closeModal() {
|
function closeModal() {
|
||||||
|
if (isListMode && isListDetailModal) {
|
||||||
|
returnToListViewModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
resetPhotoPreviewObjectUrl();
|
resetPhotoPreviewObjectUrl();
|
||||||
document.getElementById('modal').style.display = 'none';
|
document.getElementById('modal').style.display = 'none';
|
||||||
document.getElementById('modal-fields').className = 'grid grid-cols-2 gap-x-8 gap-y-5';
|
document.getElementById('modal-fields').className = 'grid grid-cols-2 gap-x-8 gap-y-5';
|
||||||
document.getElementById('modal-fields').style.maxHeight = 'none';
|
document.getElementById('modal-fields').style.maxHeight = 'none';
|
||||||
|
document.getElementById('modal-fields').style.overflowY = 'visible';
|
||||||
document.querySelector('.modal-content').classList.remove('wide');
|
document.querySelector('.modal-content').classList.remove('wide');
|
||||||
|
isListDetailModal = false;
|
||||||
isListMode = false;
|
isListMode = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1337,8 +1415,7 @@ async function saveMember() {
|
|||||||
if (!id) {
|
if (!id) {
|
||||||
targetList.push(member);
|
targetList.push(member);
|
||||||
}
|
}
|
||||||
renderListViewTable();
|
returnToListViewModal();
|
||||||
closeModal();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1367,11 +1444,16 @@ async function deleteMember(id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isListMode) {
|
if (isListMode) {
|
||||||
|
const shouldReturnToList = isListDetailModal;
|
||||||
const idx = editingMembers.findIndex((member) => member._id === id);
|
const idx = editingMembers.findIndex((member) => member._id === id);
|
||||||
if (idx !== -1) {
|
if (idx !== -1) {
|
||||||
editingMembers.splice(idx, 1);
|
editingMembers.splice(idx, 1);
|
||||||
}
|
}
|
||||||
|
if (shouldReturnToList) {
|
||||||
|
returnToListViewModal();
|
||||||
|
} else {
|
||||||
renderListViewTable();
|
renderListViewTable();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1396,44 +1478,11 @@ function openListViewModal(event) {
|
|||||||
listViewState.compareToDate = defaultDate;
|
listViewState.compareToDate = defaultDate;
|
||||||
listViewState.snapshotMembers = [];
|
listViewState.snapshotMembers = [];
|
||||||
listViewState.compareItems = [];
|
listViewState.compareItems = [];
|
||||||
|
|
||||||
const modal = document.getElementById('modal');
|
|
||||||
modal.querySelector('.modal-content').classList.add('wide');
|
|
||||||
document.getElementById('modal-title').innerText = '인원 명단';
|
|
||||||
const fieldsArea = document.getElementById('modal-fields');
|
|
||||||
fieldsArea.className = 'flex flex-col w-full overflow-hidden';
|
|
||||||
fieldsArea.style.maxHeight = '75vh';
|
|
||||||
isListMode = true;
|
isListMode = true;
|
||||||
|
isListDetailModal = false;
|
||||||
editingMembers = cloneMembers(members);
|
editingMembers = cloneMembers(members);
|
||||||
fieldsArea.innerHTML = `
|
renderListViewShell(defaultDate);
|
||||||
<div class="list-toolbar">
|
|
||||||
<div class="list-toolbar-row">
|
|
||||||
<div class="list-toolbar-group">
|
|
||||||
<button type="button" onclick="showCurrentListView()" class="list-mode-btn">현재 명단</button>
|
|
||||||
</div>
|
|
||||||
<div class="list-toolbar-divider" aria-hidden="true"></div>
|
|
||||||
<div class="list-toolbar-group list-date-group">
|
|
||||||
<input type="date" id="list-snapshot-date" value="${escapeHtml(defaultDate)}" class="list-date-input">
|
|
||||||
<button type="button" onclick="loadSnapshotListView()" class="list-mode-btn">기준일 조회</button>
|
|
||||||
</div>
|
|
||||||
<div class="list-toolbar-divider" aria-hidden="true"></div>
|
|
||||||
<div class="list-toolbar-group list-date-group">
|
|
||||||
<input type="date" id="list-compare-from" value="${escapeHtml(defaultDate)}" class="list-date-input">
|
|
||||||
<span class="list-date-separator">~</span>
|
|
||||||
<input type="date" id="list-compare-to" value="${escapeHtml(defaultDate)}" class="list-date-input">
|
|
||||||
<button type="button" onclick="loadCompareListView()" class="list-mode-btn">변경 비교</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="list-toolbar-row">
|
|
||||||
<input type="text" id="list-search-input" placeholder="이름 또는 부서/팀 검색 (Enter 시 이동)" class="flex-1 bg-[#f6eddd] border-2 border-[#e0d0b4] p-3 rounded-xl text-sm outline-none font-bold text-[#1f2f25] focus:border-[#d68a3a] transition-all" onkeydown="if(event.key==='Enter') handleListSearch(this.value)">
|
|
||||||
<button type="button" onclick="handleListSearch(document.getElementById('list-search-input').value)" class="bg-[#214634] text-white px-5 rounded-xl font-bold text-sm">검색</button>
|
|
||||||
</div>
|
|
||||||
<div id="list-view-status" class="list-view-status"></div>
|
|
||||||
</div>
|
|
||||||
<div id="list-table-container" class="overflow-y-auto flex-1 border rounded-xl"></div>
|
|
||||||
`;
|
|
||||||
renderListViewModalContent();
|
renderListViewModalContent();
|
||||||
modal.style.display = 'flex';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function applyListViewChanges() {
|
async function applyListViewChanges() {
|
||||||
@@ -1697,12 +1746,29 @@ function toggleUnitCollapse(level, name) {
|
|||||||
|
|
||||||
let draggedGroup = null;
|
let draggedGroup = null;
|
||||||
function handleListGroupDragStart(event, level, name) {
|
function handleListGroupDragStart(event, level, name) {
|
||||||
|
draggedIdx = null;
|
||||||
draggedGroup = { level, name };
|
draggedGroup = { level, name };
|
||||||
event.dataTransfer.effectAllowed = 'move';
|
event.dataTransfer.effectAllowed = 'move';
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleListGroupDrop(event, targetLevel, targetName) {
|
function handleListGroupDrop(event, targetLevel, targetName) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
if (draggedIdx !== null) {
|
||||||
|
const movingMember = editingMembers[draggedIdx];
|
||||||
|
if (!movingMember) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const moved = editingMembers.splice(draggedIdx, 1)[0];
|
||||||
|
applyMemberPlacementFromTarget(moved, targetLevel, targetName, null);
|
||||||
|
let targetIdx = editingMembers.findIndex((member) => member[targetLevel] === targetName);
|
||||||
|
if (targetIdx === -1) {
|
||||||
|
targetIdx = editingMembers.length;
|
||||||
|
}
|
||||||
|
editingMembers.splice(targetIdx, 0, moved);
|
||||||
|
draggedIdx = null;
|
||||||
|
renderListViewTable();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!draggedGroup || (draggedGroup.level === targetLevel && draggedGroup.name === targetName)) {
|
if (!draggedGroup || (draggedGroup.level === targetLevel && draggedGroup.name === targetName)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1743,6 +1809,7 @@ function handleListSearch(value) {
|
|||||||
|
|
||||||
let draggedIdx = null;
|
let draggedIdx = null;
|
||||||
function handleListDragStart(event, index) {
|
function handleListDragStart(event, index) {
|
||||||
|
draggedGroup = null;
|
||||||
draggedIdx = index;
|
draggedIdx = index;
|
||||||
event.dataTransfer.effectAllowed = 'move';
|
event.dataTransfer.effectAllowed = 'move';
|
||||||
event.target.classList.add('dragging');
|
event.target.classList.add('dragging');
|
||||||
@@ -1753,7 +1820,11 @@ function handleListDrop(event, targetIdx) {
|
|||||||
if (draggedIdx === null || draggedIdx === targetIdx) {
|
if (draggedIdx === null || draggedIdx === targetIdx) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const targetMember = editingMembers[targetIdx] || null;
|
||||||
const moved = editingMembers.splice(draggedIdx, 1)[0];
|
const moved = editingMembers.splice(draggedIdx, 1)[0];
|
||||||
|
if (targetMember) {
|
||||||
|
applyMemberPlacementFromTarget(moved, levelOrder[levelOrder.length - 1], targetMember[levelOrder[levelOrder.length - 1]] || '', targetMember);
|
||||||
|
}
|
||||||
editingMembers.splice(targetIdx, 0, moved);
|
editingMembers.splice(targetIdx, 0, moved);
|
||||||
draggedIdx = null;
|
draggedIdx = null;
|
||||||
renderListViewTable();
|
renderListViewTable();
|
||||||
@@ -1804,13 +1875,10 @@ async function handleDrop(event, targetLevel, targetName) {
|
|||||||
}
|
}
|
||||||
const nextMembers = cloneMembers(members);
|
const nextMembers = cloneMembers(members);
|
||||||
const moved = nextMembers[memberIndex];
|
const moved = nextMembers[memberIndex];
|
||||||
for (let index = 0; index <= targetLevelIndex; index += 1) {
|
if (targetLevelIndex === -1) {
|
||||||
moved[levelOrder[index]] = targetMember ? targetMember[levelOrder[index]] : targetName;
|
return;
|
||||||
}
|
}
|
||||||
for (let index = targetLevelIndex + 1; index < levelOrder.length; index += 1) {
|
applyMemberPlacementFromTarget(moved, targetLevel, targetName, targetMember);
|
||||||
moved[levelOrder[index]] = '';
|
|
||||||
}
|
|
||||||
rebuildMemberPath(moved);
|
|
||||||
nextMembers.splice(memberIndex, 1);
|
nextMembers.splice(memberIndex, 1);
|
||||||
nextMembers.push(moved);
|
nextMembers.push(moved);
|
||||||
await syncMembers(nextMembers);
|
await syncMembers(nextMembers);
|
||||||
@@ -1859,10 +1927,7 @@ async function handleDropMember(event, targetId) {
|
|||||||
}
|
}
|
||||||
const moved = nextMembers[movingIdx];
|
const moved = nextMembers[movingIdx];
|
||||||
const target = nextMembers[targetIdx];
|
const target = nextMembers[targetIdx];
|
||||||
levelOrder.forEach((level) => {
|
applyMemberPlacementFromTarget(moved, levelOrder[levelOrder.length - 1], target[levelOrder[levelOrder.length - 1]] || '', target);
|
||||||
moved[level] = target[level];
|
|
||||||
});
|
|
||||||
rebuildMemberPath(moved);
|
|
||||||
nextMembers.splice(movingIdx, 1);
|
nextMembers.splice(movingIdx, 1);
|
||||||
targetIdx = nextMembers.findIndex((member) => member._id === targetId);
|
targetIdx = nextMembers.findIndex((member) => member._id === targetId);
|
||||||
nextMembers.splice(insertAfter ? targetIdx + 1 : targetIdx, 0, moved);
|
nextMembers.splice(insertAfter ? targetIdx + 1 : targetIdx, 0, moved);
|
||||||
|
|||||||
@@ -32,6 +32,13 @@ server {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location /admin/ {
|
||||||
|
proxy_pass http://backend:8000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://frontend:80;
|
proxy_pass http://frontend:80;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
|
|||||||
69
scripts/check_8081_smoke.sh
Normal file
69
scripts/check_8081_smoke.sh
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
echo "[smoke] checking dev containers"
|
||||||
|
docker ps --format '{{.Names}}' | grep -qx 'mh-dashboard-organization-dev-backend-1'
|
||||||
|
docker ps --format '{{.Names}}' | grep -qx 'mh-dashboard-organization-dev-proxy-1'
|
||||||
|
|
||||||
|
echo "[smoke] running 8081 endpoint checks"
|
||||||
|
docker exec mh-dashboard-organization-dev-backend-1 python - <<'PY'
|
||||||
|
import sys
|
||||||
|
import urllib.request
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
checks = [
|
||||||
|
("health", "http://127.0.0.1:8000/api/health", b'"status"'),
|
||||||
|
("db-status-api", "http://127.0.0.1:8000/api/admin/db-status", b'"tables"'),
|
||||||
|
("ledger-default-api", "http://127.0.0.1:8000/api/integration/business-ledger-default", b'PK'),
|
||||||
|
("legacy-organization", "http://127.0.0.1:8000/legacy/organization", b'organization'),
|
||||||
|
("payment", "http://127.0.0.1:8000/integrations/payment", b'const App = () =>'),
|
||||||
|
("ledger", "http://127.0.0.1:8000/integrations/ledger", b'xlsx.full.min.js'),
|
||||||
|
("mh", "http://127.0.0.1:8000/integrations/mh", b'const App = () =>'),
|
||||||
|
("proxy-root", "http://proxy/", b'app.js'),
|
||||||
|
("proxy-db-status", "http://proxy/db-status.html", b'전체 테이블 현황'),
|
||||||
|
]
|
||||||
|
|
||||||
|
failed = []
|
||||||
|
|
||||||
|
for name, url, needle in checks:
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(url, timeout=8) as response:
|
||||||
|
body = response.read()
|
||||||
|
status = getattr(response, "status", response.getcode())
|
||||||
|
if status != 200:
|
||||||
|
failed.append(f"{name}: unexpected status {status}")
|
||||||
|
continue
|
||||||
|
if needle not in body:
|
||||||
|
failed.append(f"{name}: missing expected marker {needle!r}")
|
||||||
|
continue
|
||||||
|
print(f"[ok] {name} -> {status}")
|
||||||
|
except Exception as exc:
|
||||||
|
failed.append(f"{name}: {exc}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen("http://127.0.0.1:8000/api/integration/summary", timeout=8) as response:
|
||||||
|
payload = json.loads(response.read().decode())
|
||||||
|
counts = payload.get("counts") or {}
|
||||||
|
work_logs = int(counts.get("work_logs") or 0)
|
||||||
|
vouchers = int(counts.get("vouchers") or 0)
|
||||||
|
if work_logs <= 0:
|
||||||
|
failed.append(f"analysis-summary: work_logs is {work_logs}")
|
||||||
|
if vouchers <= 0:
|
||||||
|
failed.append(f"analysis-summary: vouchers is {vouchers}")
|
||||||
|
if work_logs > 0 and vouchers > 0:
|
||||||
|
print(f"[ok] analysis-summary -> work_logs={work_logs}, vouchers={vouchers}")
|
||||||
|
except Exception as exc:
|
||||||
|
failed.append(f"analysis-summary: {exc}")
|
||||||
|
|
||||||
|
if failed:
|
||||||
|
print("[smoke] failures detected:")
|
||||||
|
for item in failed:
|
||||||
|
print(f" - {item}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print("[smoke] all checks passed")
|
||||||
|
PY
|
||||||
14
scripts/publish_db_status_app.sh
Executable file
14
scripts/publish_db_status_app.sh
Executable file
@@ -0,0 +1,14 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||||
|
APP_SRC_DIR="${REPO_ROOT}/frontend/apps/db-status"
|
||||||
|
SERVED_DIR="${REPO_ROOT}/incoming-files/served/db-status"
|
||||||
|
FRONTEND_PUBLIC_DIR="${REPO_ROOT}/frontend/public"
|
||||||
|
|
||||||
|
mkdir -p "${SERVED_DIR}"
|
||||||
|
cp "${APP_SRC_DIR}/index.html" "${SERVED_DIR}/index.html"
|
||||||
|
cp "${APP_SRC_DIR}/index.html" "${FRONTEND_PUBLIC_DIR}/db-status.html"
|
||||||
|
|
||||||
|
echo "Published db-status app to ${SERVED_DIR} and ${FRONTEND_PUBLIC_DIR}/db-status.html"
|
||||||
13
scripts/publish_organization_app.sh
Executable file
13
scripts/publish_organization_app.sh
Executable file
@@ -0,0 +1,13 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
APP_DIR="${ROOT_DIR}/frontend/apps/organization"
|
||||||
|
|
||||||
|
cp "${APP_DIR}/index.html" "${ROOT_DIR}/DashBoard-organization.html"
|
||||||
|
cp "${APP_DIR}/assets/common.css" "${ROOT_DIR}/legacy/static/common.css"
|
||||||
|
cp "${APP_DIR}/assets/organization.css" "${ROOT_DIR}/legacy/static/organization.css"
|
||||||
|
cp "${APP_DIR}/assets/organization.js" "${ROOT_DIR}/legacy/static/organization.js"
|
||||||
|
|
||||||
|
echo "Published organization app source to legacy runtime files"
|
||||||
@@ -5,9 +5,7 @@ set -euo pipefail
|
|||||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
APP_DIR="${ROOT_DIR}/frontend/apps/payment"
|
APP_DIR="${ROOT_DIR}/frontend/apps/payment"
|
||||||
TARGET_FILE="${ROOT_DIR}/incoming-files/served/payment.html"
|
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" "${TARGET_FILE}"
|
||||||
cp "${APP_DIR}/index.html" "${COMPARE_FILE}"
|
|
||||||
|
|
||||||
echo "Published payment app source to ${TARGET_FILE}"
|
echo "Published payment app source to ${TARGET_FILE}"
|
||||||
|
|||||||
@@ -5,9 +5,7 @@ set -euo pipefail
|
|||||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
APP_DIR="${ROOT_DIR}/frontend/apps/team"
|
APP_DIR="${ROOT_DIR}/frontend/apps/team"
|
||||||
TARGET_FILE="${ROOT_DIR}/incoming-files/served/mh.html"
|
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" "${TARGET_FILE}"
|
||||||
cp "${APP_DIR}/index.html" "${COMPARE_FILE}"
|
|
||||||
|
|
||||||
echo "Published team app source to ${TARGET_FILE}"
|
echo "Published team app source to ${TARGET_FILE}"
|
||||||
|
|||||||
@@ -9,6 +9,28 @@ DEV_PROJECT_NAME="${DEV_PROJECT_NAME:-mh-dashboard-organization-dev}"
|
|||||||
DEV_COMPOSE_FILE="${DEV_COMPOSE_FILE:-${DEV_DIR}/docker-compose.8081.yml}"
|
DEV_COMPOSE_FILE="${DEV_COMPOSE_FILE:-${DEV_DIR}/docker-compose.8081.yml}"
|
||||||
SCOPE="${1:-minimal}"
|
SCOPE="${1:-minimal}"
|
||||||
|
|
||||||
|
ANALYSIS_TABLES=(
|
||||||
|
integration_import_batches
|
||||||
|
integration_raw_organization_rows
|
||||||
|
integration_raw_mh_rows
|
||||||
|
integration_raw_mh_pm_rows
|
||||||
|
integration_raw_payment_rows
|
||||||
|
integration_project_aliases
|
||||||
|
integration_project_category_mappings
|
||||||
|
integration_project_pm_assignments
|
||||||
|
integration_projects
|
||||||
|
integration_work_logs
|
||||||
|
integration_work_log_segments
|
||||||
|
integration_vouchers
|
||||||
|
)
|
||||||
|
|
||||||
|
MINIMAL_PRESERVE_TABLES=(
|
||||||
|
integration_project_pm_assignments
|
||||||
|
integration_work_logs
|
||||||
|
integration_work_log_segments
|
||||||
|
integration_vouchers
|
||||||
|
)
|
||||||
|
|
||||||
if [[ ! -f "${PROD_DIR}/docker-compose.yml" ]]; then
|
if [[ ! -f "${PROD_DIR}/docker-compose.yml" ]]; then
|
||||||
echo "Production workspace not found: ${PROD_DIR}" >&2
|
echo "Production workspace not found: ${PROD_DIR}" >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -38,35 +60,11 @@ case "${SCOPE}" in
|
|||||||
)
|
)
|
||||||
;;
|
;;
|
||||||
analysis)
|
analysis)
|
||||||
TABLES=(
|
TABLES=("${ANALYSIS_TABLES[@]}")
|
||||||
integration_import_batches
|
|
||||||
integration_raw_organization_rows
|
|
||||||
integration_raw_mh_rows
|
|
||||||
integration_raw_mh_pm_rows
|
|
||||||
integration_raw_payment_rows
|
|
||||||
integration_project_aliases
|
|
||||||
integration_project_category_mappings
|
|
||||||
integration_project_pm_assignments
|
|
||||||
integration_projects
|
|
||||||
integration_work_logs
|
|
||||||
integration_work_log_segments
|
|
||||||
integration_vouchers
|
|
||||||
)
|
|
||||||
;;
|
;;
|
||||||
full)
|
full)
|
||||||
TABLES=(
|
TABLES=(
|
||||||
integration_import_batches
|
"${ANALYSIS_TABLES[@]}"
|
||||||
integration_raw_organization_rows
|
|
||||||
integration_raw_mh_rows
|
|
||||||
integration_raw_mh_pm_rows
|
|
||||||
integration_raw_payment_rows
|
|
||||||
integration_project_aliases
|
|
||||||
integration_project_category_mappings
|
|
||||||
integration_project_pm_assignments
|
|
||||||
integration_projects
|
|
||||||
integration_work_logs
|
|
||||||
integration_work_log_segments
|
|
||||||
integration_vouchers
|
|
||||||
member_aliases
|
member_aliases
|
||||||
member_overrides
|
member_overrides
|
||||||
member_retirements
|
member_retirements
|
||||||
@@ -81,6 +79,16 @@ case "${SCOPE}" in
|
|||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
PRESERVE_TABLES=()
|
||||||
|
if [[ "${SCOPE}" == "minimal" ]]; then
|
||||||
|
PRESERVE_TABLES=("${MINIMAL_PRESERVE_TABLES[@]}")
|
||||||
|
fi
|
||||||
|
|
||||||
|
DUMP_TABLES=("${TABLES[@]}")
|
||||||
|
if [[ ${#PRESERVE_TABLES[@]} -gt 0 ]]; then
|
||||||
|
DUMP_TABLES+=("${PRESERVE_TABLES[@]}")
|
||||||
|
fi
|
||||||
|
|
||||||
PROD_COMPOSE=(docker compose --project-directory "${PROD_DIR}")
|
PROD_COMPOSE=(docker compose --project-directory "${PROD_DIR}")
|
||||||
DEV_COMPOSE=(docker compose -p "${DEV_PROJECT_NAME}" --env-file "${DEV_DIR}/.env" -f "${DEV_COMPOSE_FILE}")
|
DEV_COMPOSE=(docker compose -p "${DEV_PROJECT_NAME}" --env-file "${DEV_DIR}/.env" -f "${DEV_COMPOSE_FILE}")
|
||||||
|
|
||||||
@@ -129,7 +137,7 @@ echo "[4/8] Building truncate script for ${SCOPE} scope"
|
|||||||
|
|
||||||
echo "[5/8] Dumping ${SCOPE} data from 8080 source DB"
|
echo "[5/8] Dumping ${SCOPE} data from 8080 source DB"
|
||||||
TABLE_ARGS=()
|
TABLE_ARGS=()
|
||||||
for table in "${TABLES[@]}"; do
|
for table in "${DUMP_TABLES[@]}"; do
|
||||||
TABLE_ARGS+=(-t "public.${table}")
|
TABLE_ARGS+=(-t "public.${table}")
|
||||||
done
|
done
|
||||||
run_compose "${PROD_DIR}" "${PROD_COMPOSE[@]}" exec -T db \
|
run_compose "${PROD_DIR}" "${PROD_COMPOSE[@]}" exec -T db \
|
||||||
@@ -193,7 +201,7 @@ echo "[7.8/8] Resetting serial sequences"
|
|||||||
echo "SELECT setval(pg_get_serial_sequence('public.member_retirements', 'id'), COALESCE((SELECT MAX(id) FROM public.member_retirements), 1), true);"
|
echo "SELECT setval(pg_get_serial_sequence('public.member_retirements', 'id'), COALESCE((SELECT MAX(id) FROM public.member_retirements), 1), true);"
|
||||||
echo "SELECT setval(pg_get_serial_sequence('public.seat_maps', 'id'), COALESCE((SELECT MAX(id) FROM public.seat_maps), 1), true);"
|
echo "SELECT setval(pg_get_serial_sequence('public.seat_maps', 'id'), COALESCE((SELECT MAX(id) FROM public.seat_maps), 1), true);"
|
||||||
echo "SELECT setval(pg_get_serial_sequence('public.seat_slots', 'id'), COALESCE((SELECT MAX(id) FROM public.seat_slots), 1), true);"
|
echo "SELECT setval(pg_get_serial_sequence('public.seat_slots', 'id'), COALESCE((SELECT MAX(id) FROM public.seat_slots), 1), true);"
|
||||||
if [[ "${SCOPE}" == "analysis" || "${SCOPE}" == "full" ]]; then
|
if [[ "${SCOPE}" == "analysis" || "${SCOPE}" == "full" || "${#PRESERVE_TABLES[@]}" -gt 0 ]]; then
|
||||||
echo "SELECT setval(pg_get_serial_sequence('public.integration_import_batches', 'id'), COALESCE((SELECT MAX(id) FROM public.integration_import_batches), 1), true);"
|
echo "SELECT setval(pg_get_serial_sequence('public.integration_import_batches', 'id'), COALESCE((SELECT MAX(id) FROM public.integration_import_batches), 1), true);"
|
||||||
echo "SELECT setval(pg_get_serial_sequence('public.integration_raw_organization_rows', 'id'), COALESCE((SELECT MAX(id) FROM public.integration_raw_organization_rows), 1), true);"
|
echo "SELECT setval(pg_get_serial_sequence('public.integration_raw_organization_rows', 'id'), COALESCE((SELECT MAX(id) FROM public.integration_raw_organization_rows), 1), true);"
|
||||||
echo "SELECT setval(pg_get_serial_sequence('public.integration_raw_mh_rows', 'id'), COALESCE((SELECT MAX(id) FROM public.integration_raw_mh_rows), 1), true);"
|
echo "SELECT setval(pg_get_serial_sequence('public.integration_raw_mh_rows', 'id'), COALESCE((SELECT MAX(id) FROM public.integration_raw_mh_rows), 1), true);"
|
||||||
@@ -236,7 +244,7 @@ UNION ALL
|
|||||||
SELECT 'auth_users', COUNT(*)::text FROM auth.users
|
SELECT 'auth_users', COUNT(*)::text FROM auth.users
|
||||||
ORDER BY table_name;
|
ORDER BY table_name;
|
||||||
SQL
|
SQL
|
||||||
if [[ "${SCOPE}" == "analysis" || "${SCOPE}" == "full" ]]; then
|
if [[ "${SCOPE}" == "analysis" || "${SCOPE}" == "full" || "${#PRESERVE_TABLES[@]}" -gt 0 ]]; then
|
||||||
cat <<'SQL'
|
cat <<'SQL'
|
||||||
SELECT 'integration_work_logs', COUNT(*)::text FROM public.integration_work_logs
|
SELECT 'integration_work_logs', COUNT(*)::text FROM public.integration_work_logs
|
||||||
UNION ALL
|
UNION ALL
|
||||||
|
|||||||
Reference in New Issue
Block a user