8 Commits

Author SHA1 Message Date
hyunho
4c52fa53e8 Clarify branch and environment rules 2026-04-02 11:29:41 +09:00
hyunho
f5c68ada80 Polish onboarding and reference asset layout 2026-04-02 11:22:52 +09:00
hyunho
c0564ee326 Improve ledger filters and dev sync checks 2026-04-02 11:17:01 +09:00
hyunho
f8ea345882 Refactor app structure and simplify team docs 2026-04-02 11:13:43 +09:00
hyunho
8125193378 Fix organization member editing and drag sync 2026-04-02 10:38:47 +09:00
hyunho
a4480c3435 feat: add seatmap context panel and smoke checks 2026-04-01 18:01:59 +09:00
hyunho
19c8c6ade1 feat: improve db status table browsing 2026-04-01 17:06:39 +09:00
hyunho
c3afc0c772 refactor: split backend route domains 2026-04-01 16:58:51 +09:00
68 changed files with 7047 additions and 11191 deletions

59
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,59 @@
# Contributing
## 기본 규칙
- `main`은 팀 기준 브랜치로 사용합니다.
- `dev`는 팀 개발 통합 브랜치로 사용합니다.
- `main`, `dev`는 브랜치이고 `8080`, `8081`은 실행 환경입니다.
- 권장 운영은 `main -> 8080`, `dev 또는 작업 브랜치 -> 8081`입니다.
- 기능 개발과 버그 수정은 각자 작업 브랜치에서 진행합니다.
- 직접 `8080` 기준 파일을 수정하지 않습니다.
- 검증은 먼저 `8081` 개발 환경에서 수행합니다.
- 커밋은 한 기능 또는 한 버그 단위로 작게 나눕니다.
- 작업 시작 전 [docs/TEAM_GUIDE.md](docs/TEAM_GUIDE.md)를 먼저 읽습니다.
## 권장 브랜치 이름
- `feature/<name>-<topic>`
- `fix/<name>-<topic>`
- `chore/<name>-<topic>`
예:
- `fix/alex-organization-save`
- `feature/minsu-ledger-filter`
## 작업 순서
1. `main` 최신 상태를 받습니다.
2. 작업 브랜치를 만듭니다.
3. `.env.example``.env`로 복사합니다.
4. 필요한 경우 `./scripts/prepare_dev_worktree.sh`로 격리된 개발 워크스페이스를 준비합니다.
5. `8081`에서 수정과 검증을 진행합니다.
6. 관련 publish 스크립트가 있는 화면은 publish 후 실제 런타임 파일까지 확인합니다.
7. `docs/REGRESSION_CHECKLIST.md` 기준으로 필요한 시나리오를 점검합니다.
8. 커밋 후 PR을 생성합니다.
## PR 규칙
- PR 하나에는 한 주제만 담습니다.
- PR 본문에 아래 내용을 포함합니다.
- 작업 목적
- 변경 범위
- 검증 방법
- DB 영향 여부
- 공용 구조 파일 수정 시 영향 화면을 명시합니다.
## 파일 수정 기준
- 탭 화면 수정은 먼저 `frontend/apps/*`를 봅니다.
- 조직현황은 `frontend/apps/organization``legacy/static/*` 구조를 함께 확인합니다.
- integration 화면 런타임은 `incoming-files/served/*`지만, 직접 수정 원본은 `frontend/apps/*`입니다.
- 원본 참고 파일은 `incoming-files/reference/*`에만 둡니다.
## 문서 기준
- 저장소 진입: [README.md](README.md)
- 작업 시작 기준: [docs/TEAM_GUIDE.md](docs/TEAM_GUIDE.md)
- 개발/운영 DB 원칙: [docs/DEV_PROD_DB_PROTOCOL.md](docs/DEV_PROD_DB_PROTOCOL.md)
- 실제 서빙 책임 맵: [docs/architecture/8081_SERVING_MAP.md](docs/architecture/8081_SERVING_MAP.md)

View File

@@ -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>

63
README.md Normal file
View File

@@ -0,0 +1,63 @@
# MH Dashboard Organization
조직현황, 자리배치도, 프로젝트별 분석, 팀/개인별 분석을 하나의 대시보드로 제공하는 사내 웹 애플리케이션입니다.
## 구성
- `frontend/`
- 허브 화면과 공통 스타일
- `frontend/apps/`
- 화면별 source-of-truth 앱 소스
- `legacy/static/`
- 조직현황 레거시 런타임 자산
- `incoming-files/served/`
- integration 화면의 실제 런타임 서빙 자산
- `incoming-files/reference/`
- 원본 참고 자산
- `backend/app/`
- FastAPI 백엔드
- `scripts/`
- 실행, publish, 검증, 동기화 스크립트
## 핵심 원칙
- `frontend/apps/*`가 탭별 수정 원본입니다.
- `incoming-files/served/*``legacy/static/*`는 런타임 자산입니다.
- 조직현황/멤버/자리배치 관련 검증은 `8081` 개발 환경에서 먼저 수행합니다.
- `8080`은 기준 데이터와 공개 환경, `8081`은 검증 환경으로 다룹니다.
- `main`, `dev`는 Git 브랜치이고 `8080`, `8081`은 실행 환경입니다.
- 권장 운영은 `main -> 8080`, `dev 또는 작업 브랜치 -> 8081`입니다.
## 시작 문서
- 첫 문서: [docs/TEAM_GUIDE.md](docs/TEAM_GUIDE.md)
- 협업 규칙: [CONTRIBUTING.md](CONTRIBUTING.md)
- 개발/운영 DB 원칙: [docs/DEV_PROD_DB_PROTOCOL.md](docs/DEV_PROD_DB_PROTOCOL.md)
- 실제 서빙 책임 맵: [docs/architecture/8081_SERVING_MAP.md](docs/architecture/8081_SERVING_MAP.md)
- 디자인 기준: [docs/architecture/DESIGN_SSOT.md](docs/architecture/DESIGN_SSOT.md)
## 빠른 실행
기본 공개 환경:
```bash
cp .env.example .env
docker compose up -d --build
```
격리된 `8081` 개발 환경:
```bash
cp .env.example .env
./scripts/prepare_dev_worktree.sh
cd .dev-worktree-8081
docker compose -p mh-dashboard-organization-dev --env-file .env -f docker-compose.8081.yml up -d --build
```
## publish 스크립트
- 조직현황: `./scripts/publish_organization_app.sh`
- 프로젝트별 분석: `./scripts/publish_payment_app.sh`
- 팀/개인별 분석: `./scripts/publish_team_app.sh`
- 사업관리대장: `./scripts/publish_ledger_app.sh`
- DB 상태: `./scripts/publish_db_status_app.sh`

172
backend/app/auth_routes.py Normal file
View 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",
}

View 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()

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
import hashlib import hashlib
import json import json
from pathlib import Path from pathlib import Path
from urllib.parse import quote
from fastapi import HTTPException from fastapi import HTTPException
from fastapi.responses import FileResponse, Response from fastapi.responses import FileResponse, Response
@@ -88,7 +89,7 @@ def build_business_ledger_default_response(cur) -> Response:
headers = { headers = {
"Content-Disposition": 'inline; filename="business-ledger-default.xlsx"', "Content-Disposition": 'inline; filename="business-ledger-default.xlsx"',
"X-Source-Filename": "business-ledger-default.xlsx", "X-Source-Filename": "business-ledger-default.xlsx",
"X-Original-Filename": filename, "X-Original-Filename": quote(filename),
"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0", "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
"Pragma": "no-cache", "Pragma": "no-cache",
} }

File diff suppressed because it is too large Load Diff

View 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)}

View 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",
]

View 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()

View 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",
]

View File

@@ -0,0 +1,3 @@
from ..auth_routes import register_auth_routes
__all__ = ["register_auth_routes"]

View File

@@ -0,0 +1,3 @@
from ..integration_routes import register_integration_routes
__all__ = ["register_integration_routes"]

View File

@@ -0,0 +1,3 @@
from ..member_routes import register_member_routes
__all__ = ["register_member_routes"]

View File

@@ -0,0 +1,3 @@
from ..seatmap_routes import register_seatmap_routes
__all__ = ["register_seatmap_routes"]

View File

@@ -0,0 +1,3 @@
from ..system_routes import register_system_routes
__all__ = ["register_system_routes"]

View File

@@ -0,0 +1,15 @@
from .organization import (
MemberBulkPayload,
MemberPayload,
SeatLayoutPayload,
SeatLayoutPlacementPayload,
SeatMapPayload,
)
__all__ = [
"MemberBulkPayload",
"MemberPayload",
"SeatLayoutPayload",
"SeatLayoutPlacementPayload",
"SeatMapPayload",
]

View 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]

View 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)}

View 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",
]

View 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)

View File

@@ -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

View File

@@ -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` 에서 데이터가 조회되는지 확인합니다.

View File

@@ -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,7 +226,7 @@
### 설계 문서 ### 설계 문서
- [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)
## Next Focus ## Next Focus

View File

@@ -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 경로로 나와야 한다
규칙: 규칙:

View File

@@ -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`

View File

@@ -1,158 +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)
- DB 테이블 분류 기준 문서:
- [architecture/DB_TABLE_CATALOG.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/architecture/DB_TABLE_CATALOG.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)로 한다.
- DB 상태 화면 수정 원본은 [frontend/apps/db-status/index.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/apps/db-status/index.html) 이고, 반영은 [scripts/publish_db_status_app.sh](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/scripts/publish_db_status_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)
- `/db-status.html`:
- [incoming-files/served/db-status/index.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/incoming-files/served/db-status/index.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
- `#2` 백엔드 영속 저장 구조 운영 마무리
- `#14` 누적된 임시 로직 정리 및 중복 코드 제거
- `#16` 사업관리대장 메인 후속 정리 및 기준 분석
- `#19` 8081 백엔드 라우터/서빙 deeper 모듈 분리
- `#21` organization 레거시 구조 승격 및 장기 고도화
## Recommended Next Work Order
1. `#2` 기준으로 DB 상태 화면과 저장 구조 검증 흐름 고도화
2. `#21` 이후 기준으로 실제 서비스 파일과 reference 파일 경계를 유지
3. 사업관리대장 세부 데이터 정합성 보정은 원본 규칙 분석 후 진행
4. 필요 시 `#19` 잔여 정리 항목 재평가
## 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`
- `#2` 기준 DB 상태 확인은 `/api/admin/db-status` 또는 허브의 `DB 상태` 탭(`/db-status.html`)을 먼저 본다.
- DB 테이블 유지/주의/원본·추적/정리 후보 분류는 [architecture/DB_TABLE_CATALOG.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/architecture/DB_TABLE_CATALOG.md) 기준으로 본다.
- 작업 전 `git status`, dev 컨테이너 상태, `/api/health`, `/legacy/organization`, `/integrations/payment`, `/integrations/ledger`, `/integrations/mh`, `/api/admin/db-status`를 먼저 확인

View File

@@ -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`)를 반환해야 한다.
## 작업 유형별 필수 추가 확인 ## 작업 유형별 필수 추가 확인
### 조직도 / 관리자모드 수정 시 ### 조직도 / 관리자모드 수정 시

172
docs/TEAM_GUIDE.md Normal file
View File

@@ -0,0 +1,172 @@
# Team Guide
이 문서는 이 저장소에서 작업할 때 가장 먼저 읽는 기준 문서다.
목표는 세 가지다.
- 어디를 수정해야 하는지 바로 알 수 있게 하기
- `8080``8081`을 헷갈리지 않게 하기
- 작업 순서와 검증 기준을 하나의 문서로 시작하게 하기
## 1. 먼저 이해할 구조
- `frontend/apps/*`
- 화면별 source-of-truth
- `incoming-files/served/*`
- integration 화면의 실제 런타임 파일
- `legacy/static/*`
- 조직현황 레거시 런타임 파일
- `incoming-files/reference/*`
- 원본 참고 자산
- `backend/app/routes/*`
- API 엔드포인트 등록
- `backend/app/services/*`
- 비즈니스 로직
- `backend/app/repositories/*`
- DB 읽기 쿼리
원칙:
- source를 먼저 수정하고 runtime은 publish로 반영한다.
- reference 파일은 비교/복구용이지 직접 수정 기준이 아니다.
## 2. 환경 원칙
팀 규칙 문장:
- `main``dev`는 코드 브랜치다.
- `8080``8081`은 실행 환경이다.
- 브랜치 이름을 포트와 같은 뜻으로 쓰지 않는다.
- 기본 운영은 `main``8080` 기준 브랜치로 본다.
- 개발 검증은 `dev` 또는 작업 브랜치를 `8081`에서 먼저 확인한다.
- `8080`
- 공개 기준 환경
- 기준 데이터가 있는 쪽
- `8081`
- 개발/검증 환경
- 먼저 수정하고 검증하는 쪽
중요:
- 새 작업은 항상 `.dev-worktree-8081` 기준으로 시작한다.
- `8081`에서 검증되지 않은 변경을 바로 `8080`에 올리지 않는다.
- `8081` DB는 독립 정본이 아니라 검증용 복제본처럼 다룬다.
자세한 DB 운영 원칙은 [DEV_PROD_DB_PROTOCOL.md](DEV_PROD_DB_PROTOCOL.md)를 따른다.
## 3. 작업 시작 순서
1. 현재 브랜치와 변경 파일을 확인한다.
2. 연결된 이슈 또는 작업 목적을 확인한다.
3. 이번 작업의 source 파일과 runtime 파일을 구분한다.
4. 필요한 경우 `8081` 개발 환경을 띄운다.
5. 필요한 DB 동기화 범위를 결정한다.
6. 수정 후 관련 시나리오를 검증한다.
환경 준비:
- 최초 실행 전 `.env.example``.env`로 복사한다.
- `./scripts/prepare_dev_worktree.sh`는 dev worktree에 `.env`가 없으면 `.env.example`로 기본 파일을 만든다.
핵심 질문:
- 지금 고치는 파일이 실제 source-of-truth가 맞는가?
- 이 작업은 `8081`에서 먼저 검증해야 하는가?
- DB 차이 때문에 생긴 문제는 아닌가?
## 4. 수정 원칙
- 한 작업은 한 기능 또는 한 버그 단위로 작게 나눈다.
- 완료 기능은 관련 이슈 없이 함부로 건드리지 않는다.
- 임시 우회 로직은 이유와 제거 계획이 있어야 한다.
- 구조를 정리하더라도 기존 동작을 바꾸면 안 된다.
고위험 영역:
- `members`
- `seat_maps`
- `seat_slots`
- `seat_positions`
- `auth.*`
- 동기화 스크립트
- 스키마 변경
이 영역은 변경 이유, 영향 범위, 검증 결과를 반드시 남긴다.
## 5. 화면별 수정 기준
### 조직현황
- source: `frontend/apps/organization`
- runtime: `DashBoard-organization.html`, `legacy/static/*`
- publish: `./scripts/publish_organization_app.sh`
### 프로젝트별 분석
- source: `frontend/apps/payment/index.html`
- runtime: `incoming-files/served/payment.html`
- publish: `./scripts/publish_payment_app.sh`
### 팀/개인별 분석
- source: `frontend/apps/team/index.html`
- runtime: `incoming-files/served/mh.html`
- publish: `./scripts/publish_team_app.sh`
### 사업관리대장
- source: `frontend/apps/ledger/*`
- runtime: `incoming-files/served/ledger/*`
- publish: `./scripts/publish_ledger_app.sh`
### DB 상태
- source: `frontend/apps/db-status/index.html`
- runtime: `incoming-files/served/db-status/index.html`
- publish: `./scripts/publish_db_status_app.sh`
실제 서빙 책임은 [architecture/8081_SERVING_MAP.md](architecture/8081_SERVING_MAP.md)에서 확인한다.
## 6. 디자인 수정 원칙
- 먼저 `frontend/public/design-tokens.css`
- 다음 `frontend/public/design-patterns.css`
- 그 다음 [architecture/DESIGN_SSOT.md](architecture/DESIGN_SSOT.md)
- 마지막으로 화면별 파일
금지:
- reference 파일부터 수정하기
- 토큰/패턴으로 해결 가능한 것을 화면별 하드코딩으로 처리하기
- 예전 색 체계를 새 기본값으로 다시 넣기
## 7. 검증 원칙
- 완료 기준은 “코드를 썼다”가 아니라 “실제 동작을 검증했다”이다.
- 구조 정리나 라우트 분리 후에는 `./scripts/check_8081_smoke.sh`를 먼저 본다.
- 기능 수정 후에는 관련 화면만 보지 말고 주변 연동까지 확인한다.
검증 세부 항목은 [REGRESSION_CHECKLIST.md](REGRESSION_CHECKLIST.md)를 따른다.
자주 쓰는 DB 동기화:
- 조직현황/멤버/자리배치: `./scripts/sync_prod_db_to_dev.sh minimal`
- 분석 화면: `./scripts/sync_prod_db_to_dev.sh analysis`
- 전체 재검증: `./scripts/sync_prod_db_to_dev.sh full`
## 8. 커밋과 PR
- 커밋은 한 주제만 담는다.
- PR 본문에는 작업 목적, 변경 범위, 검증 방법, DB 영향 여부를 적는다.
- 공용 구조 파일을 수정했으면 영향 화면을 명시한다.
자세한 팀 작업 규칙은 [../CONTRIBUTING.md](../CONTRIBUTING.md)를 따른다.
## 9. 이 문서 다음에 읽을 것
- 협업 방식: [../CONTRIBUTING.md](../CONTRIBUTING.md)
- DB 운영 원칙: [DEV_PROD_DB_PROTOCOL.md](DEV_PROD_DB_PROTOCOL.md)
- 회귀 검증: [REGRESSION_CHECKLIST.md](REGRESSION_CHECKLIST.md)
- 실제 서빙 책임: [architecture/8081_SERVING_MAP.md](architecture/8081_SERVING_MAP.md)
- 디자인 기준: [architecture/DESIGN_SSOT.md](architecture/DESIGN_SSOT.md)

View File

@@ -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 운영 안정화를 우선하는 것이 맞다

View File

@@ -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 유지`, `조직현황 탭`, `프로젝트/팀 탭`

View File

@@ -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와 검증을 무시하지 않고, 기록을 남기면서 작업한다.

View File

@@ -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`

View File

@@ -61,7 +61,6 @@
- 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
@@ -95,8 +94,8 @@
- `260320.html` - `260320.html`
- `sample style.css` - `sample style.css`
- `opayment.html` - `reference/opayment.html`
- `omh.html` - `reference/omh.html`
- `reference/ledger/MH 통합 대시보드_260320.html` - `reference/ledger/MH 통합 대시보드_260320.html`
- `reference/ledger/MH 통합 대시보드_260320.css` - `reference/ledger/MH 통합 대시보드_260320.css`
- 원본 xlsx/csv - 원본 xlsx/csv

View File

@@ -2,7 +2,7 @@
## Purpose ## Purpose
이 문서는 `8081 / work-8081` 기준 현재 PostgreSQL 테이블 26개를 역할별로 분류한 운영 기준 문서다. 이 문서는 현재 PostgreSQL 테이블 역할별로 분류한 운영 기준 문서다.
핵심 원칙: 핵심 원칙:

View File

@@ -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.

View File

@@ -110,6 +110,14 @@
.panel-body { .panel-body {
padding: 16px 18px 20px; 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 { .panel-body.tight {
padding-top: 0; padding-top: 0;
} }
@@ -211,6 +219,44 @@
font-size: 11px; font-size: 11px;
font-weight: 700; 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 { .notes {
margin: 0; margin: 0;
padding-left: 18px; padding-left: 18px;
@@ -389,6 +435,12 @@
<span id="generated-at" class="meta-chip">로딩 중</span> <span id="generated-at" class="meta-chip">로딩 중</span>
</div> </div>
<div class="panel-body"> <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> <table>
<thead> <thead>
<tr> <tr>
@@ -499,7 +551,10 @@
<h2 id="preview-title">테이블 내용 미리보기</h2> <h2 id="preview-title">테이블 내용 미리보기</h2>
<p id="preview-subtitle" class="muted">선택한 테이블의 컬럼과 최대 50개 row를 표시합니다.</p> <p id="preview-subtitle" class="muted">선택한 테이블의 컬럼과 최대 50개 row를 표시합니다.</p>
</div> </div>
<button id="preview-close" class="modal-close" type="button" aria-label="닫기">×</button> <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>
<div class="modal-body"> <div class="modal-body">
<div id="preview-meta" class="preview-meta"></div> <div id="preview-meta" class="preview-meta"></div>
@@ -518,6 +573,9 @@
</div> </div>
<script> <script>
let allTables = [];
let currentPreview = null;
function escapeHtml(value) { function escapeHtml(value) {
return String(value ?? "") return String(value ?? "")
.replaceAll("&", "&amp;") .replaceAll("&", "&amp;")
@@ -572,6 +630,10 @@
function renderTables(items) { function renderTables(items) {
const target = document.getElementById("table-body"); 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) { if (!items.length) {
target.innerHTML = '<tr><td colspan="5" class="empty">표시할 테이블이 없습니다.</td></tr>'; target.innerHTML = '<tr><td colspan="5" class="empty">표시할 테이블이 없습니다.</td></tr>';
return; return;
@@ -699,7 +761,9 @@
} }
function renderTablePreview(payload) { function renderTablePreview(payload) {
currentPreview = payload;
const previewModal = document.getElementById("preview-modal"); const previewModal = document.getElementById("preview-modal");
const downloadButton = document.getElementById("preview-download");
const previewMeta = document.getElementById("preview-meta"); const previewMeta = document.getElementById("preview-meta");
const previewTitle = document.getElementById("preview-title"); const previewTitle = document.getElementById("preview-title");
const previewSubtitle = document.getElementById("preview-subtitle"); const previewSubtitle = document.getElementById("preview-subtitle");
@@ -708,6 +772,9 @@
previewTitle.textContent = `${payload.label} · ${payload.table_ref}`; previewTitle.textContent = `${payload.label} · ${payload.table_ref}`;
previewSubtitle.textContent = `${formatNumber(payload.row_count)} rows / 최대 ${formatNumber(payload.limit)}개 표시`; previewSubtitle.textContent = `${formatNumber(payload.row_count)} rows / 최대 ${formatNumber(payload.limit)}개 표시`;
if (downloadButton) {
downloadButton.disabled = !(payload.rows && payload.rows.length);
}
previewMeta.innerHTML = ` previewMeta.innerHTML = `
<div> <div>
<div class="table-title">${escapeHtml(payload.label)}</div> <div class="table-title">${escapeHtml(payload.label)}</div>
@@ -765,11 +832,12 @@
throw new Error(`DB 상태를 불러오지 못했습니다. (${response.status})`); throw new Error(`DB 상태를 불러오지 못했습니다. (${response.status})`);
} }
const payload = await response.json(); const payload = await response.json();
allTables = payload.tables || [];
document.getElementById("generated-at").textContent = payload.generated_at document.getElementById("generated-at").textContent = payload.generated_at
? `갱신 ${formatDateTime(payload.generated_at)}` ? `갱신 ${formatDateTime(payload.generated_at)}`
: "갱신 시각 없음"; : "갱신 시각 없음";
renderOverview(payload.overview || {}); renderOverview(payload.overview || {});
renderTables(payload.tables || []); renderTables(allTables);
renderBatches(payload.import_batches || []); renderBatches(payload.import_batches || []);
renderBinarySources(payload.binary_sources || []); renderBinarySources(payload.binary_sources || []);
renderNotes(payload.notes || []); renderNotes(payload.notes || []);
@@ -778,12 +846,61 @@
renderScreenMap(payload.screen_map || []); 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", () => { document.getElementById("preview-close").addEventListener("click", () => {
const modal = document.getElementById("preview-modal"); const modal = document.getElementById("preview-modal");
modal.classList.remove("open"); modal.classList.remove("open");
modal.setAttribute("aria-hidden", "true"); 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) => { document.getElementById("preview-modal").addEventListener("click", (event) => {
if (event.target.id !== "preview-modal") return; if (event.target.id !== "preview-modal") return;
const modal = document.getElementById("preview-modal"); const modal = document.getElementById("preview-modal");

View File

@@ -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();

View 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`를 실행해 서빙 파일로 반영한다.

View 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);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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>

View File

@@ -50,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"),
@@ -75,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"),
@@ -100,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;
@@ -135,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,
@@ -491,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;
@@ -837,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;
} }
@@ -871,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;
@@ -943,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}`);
@@ -1003,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>
`; `;
@@ -1015,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>
`; `;
} }
@@ -1027,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>
`; `;
} }
@@ -1054,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>
@@ -1072,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>
`; `;
} }
@@ -1175,6 +1319,7 @@ function renderSeatMapActions() {
function updateSeatMapDraftUi() { function updateSeatMapDraftUi() {
renderSeatMapActions(); renderSeatMapActions();
renderUnassignedMembers(); renderUnassignedMembers();
renderSeatMapMemberContext(seatMapState.selectedMemberId);
syncSeatMapViewerFrame(); syncSeatMapViewerFrame();
} }
@@ -1394,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) {
@@ -1437,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;
@@ -1450,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;
@@ -1690,7 +1840,7 @@ function setActiveView(view) {
dbStatusFrame.src = resolveAppUrl(frameSrc); dbStatusFrame.src = resolveAppUrl(frameSrc);
} }
if (isSeatMapAdmin || isSeatMapReadonly) { if (isSeatMapAdmin || isSeatMapReadonly) {
loadSeatMapData(); loadSeatMapData(previousView !== currentView);
} }
notifyEmbeddedTabActivated(); notifyEmbeddedTabActivated();
} }
@@ -1886,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;
@@ -1922,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()

View File

@@ -110,6 +110,14 @@
.panel-body { .panel-body {
padding: 16px 18px 20px; 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 { .panel-body.tight {
padding-top: 0; padding-top: 0;
} }
@@ -211,6 +219,44 @@
font-size: 11px; font-size: 11px;
font-weight: 700; 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 { .notes {
margin: 0; margin: 0;
padding-left: 18px; padding-left: 18px;
@@ -389,6 +435,12 @@
<span id="generated-at" class="meta-chip">로딩 중</span> <span id="generated-at" class="meta-chip">로딩 중</span>
</div> </div>
<div class="panel-body"> <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> <table>
<thead> <thead>
<tr> <tr>
@@ -499,7 +551,10 @@
<h2 id="preview-title">테이블 내용 미리보기</h2> <h2 id="preview-title">테이블 내용 미리보기</h2>
<p id="preview-subtitle" class="muted">선택한 테이블의 컬럼과 최대 50개 row를 표시합니다.</p> <p id="preview-subtitle" class="muted">선택한 테이블의 컬럼과 최대 50개 row를 표시합니다.</p>
</div> </div>
<button id="preview-close" class="modal-close" type="button" aria-label="닫기">×</button> <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>
<div class="modal-body"> <div class="modal-body">
<div id="preview-meta" class="preview-meta"></div> <div id="preview-meta" class="preview-meta"></div>
@@ -518,6 +573,9 @@
</div> </div>
<script> <script>
let allTables = [];
let currentPreview = null;
function escapeHtml(value) { function escapeHtml(value) {
return String(value ?? "") return String(value ?? "")
.replaceAll("&", "&amp;") .replaceAll("&", "&amp;")
@@ -572,6 +630,10 @@
function renderTables(items) { function renderTables(items) {
const target = document.getElementById("table-body"); 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) { if (!items.length) {
target.innerHTML = '<tr><td colspan="5" class="empty">표시할 테이블이 없습니다.</td></tr>'; target.innerHTML = '<tr><td colspan="5" class="empty">표시할 테이블이 없습니다.</td></tr>';
return; return;
@@ -699,7 +761,9 @@
} }
function renderTablePreview(payload) { function renderTablePreview(payload) {
currentPreview = payload;
const previewModal = document.getElementById("preview-modal"); const previewModal = document.getElementById("preview-modal");
const downloadButton = document.getElementById("preview-download");
const previewMeta = document.getElementById("preview-meta"); const previewMeta = document.getElementById("preview-meta");
const previewTitle = document.getElementById("preview-title"); const previewTitle = document.getElementById("preview-title");
const previewSubtitle = document.getElementById("preview-subtitle"); const previewSubtitle = document.getElementById("preview-subtitle");
@@ -708,6 +772,9 @@
previewTitle.textContent = `${payload.label} · ${payload.table_ref}`; previewTitle.textContent = `${payload.label} · ${payload.table_ref}`;
previewSubtitle.textContent = `${formatNumber(payload.row_count)} rows / 최대 ${formatNumber(payload.limit)}개 표시`; previewSubtitle.textContent = `${formatNumber(payload.row_count)} rows / 최대 ${formatNumber(payload.limit)}개 표시`;
if (downloadButton) {
downloadButton.disabled = !(payload.rows && payload.rows.length);
}
previewMeta.innerHTML = ` previewMeta.innerHTML = `
<div> <div>
<div class="table-title">${escapeHtml(payload.label)}</div> <div class="table-title">${escapeHtml(payload.label)}</div>
@@ -765,11 +832,12 @@
throw new Error(`DB 상태를 불러오지 못했습니다. (${response.status})`); throw new Error(`DB 상태를 불러오지 못했습니다. (${response.status})`);
} }
const payload = await response.json(); const payload = await response.json();
allTables = payload.tables || [];
document.getElementById("generated-at").textContent = payload.generated_at document.getElementById("generated-at").textContent = payload.generated_at
? `갱신 ${formatDateTime(payload.generated_at)}` ? `갱신 ${formatDateTime(payload.generated_at)}`
: "갱신 시각 없음"; : "갱신 시각 없음";
renderOverview(payload.overview || {}); renderOverview(payload.overview || {});
renderTables(payload.tables || []); renderTables(allTables);
renderBatches(payload.import_batches || []); renderBatches(payload.import_batches || []);
renderBinarySources(payload.binary_sources || []); renderBinarySources(payload.binary_sources || []);
renderNotes(payload.notes || []); renderNotes(payload.notes || []);
@@ -778,12 +846,61 @@
renderScreenMap(payload.screen_map || []); 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", () => { document.getElementById("preview-close").addEventListener("click", () => {
const modal = document.getElementById("preview-modal"); const modal = document.getElementById("preview-modal");
modal.classList.remove("open"); modal.classList.remove("open");
modal.setAttribute("aria-hidden", "true"); 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) => { document.getElementById("preview-modal").addEventListener("click", (event) => {
if (event.target.id !== "preview-modal") return; if (event.target.id !== "preview-modal") return;
const modal = document.getElementById("preview-modal"); const modal = document.getElementById("preview-modal");

View File

@@ -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>
@@ -105,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>
@@ -181,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>
@@ -220,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>

View File

@@ -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;
} }

View File

@@ -29,12 +29,8 @@
- 샘플 스타일 파일 - 샘플 스타일 파일
- 원본/백업 HTML - 원본/백업 HTML
- 디자인 비교용 파일 - 디자인 비교용 파일
- `reference/omh.html`
- `reference/opayment.html`
- `reference/ledger/MH 통합 대시보드_260320.html` - `reference/ledger/MH 통합 대시보드_260320.html`
- `reference/ledger/MH 통합 대시보드_260320.css` - `reference/ledger/MH 통합 대시보드_260320.css`
- `reference/ledger/사업관리대장-1.xlsx` - `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

View File

@@ -8,8 +8,7 @@
- `ledger/` - `ledger/`
- 사업관리대장 원본 wrapper/html/css/xlsx - 사업관리대장 원본 wrapper/html/css/xlsx
- 이전 override 복사본 - 이전 override 참고 파일
- 중첩 백업 디렉터리
규칙: 규칙:

View File

@@ -14,6 +14,7 @@
- 원본 HTML/CSS - 원본 HTML/CSS
- 원본 XLSX - 원본 XLSX
- 과거 override 참고 파일 - 과거 override 참고 파일
- 단일 canonical reference set만 유지
주의: 주의:

File diff suppressed because one or more lines are too long

View File

@@ -110,6 +110,14 @@
.panel-body { .panel-body {
padding: 16px 18px 20px; 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 { .panel-body.tight {
padding-top: 0; padding-top: 0;
} }
@@ -211,6 +219,44 @@
font-size: 11px; font-size: 11px;
font-weight: 700; 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 { .notes {
margin: 0; margin: 0;
padding-left: 18px; padding-left: 18px;
@@ -389,6 +435,12 @@
<span id="generated-at" class="meta-chip">로딩 중</span> <span id="generated-at" class="meta-chip">로딩 중</span>
</div> </div>
<div class="panel-body"> <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> <table>
<thead> <thead>
<tr> <tr>
@@ -499,7 +551,10 @@
<h2 id="preview-title">테이블 내용 미리보기</h2> <h2 id="preview-title">테이블 내용 미리보기</h2>
<p id="preview-subtitle" class="muted">선택한 테이블의 컬럼과 최대 50개 row를 표시합니다.</p> <p id="preview-subtitle" class="muted">선택한 테이블의 컬럼과 최대 50개 row를 표시합니다.</p>
</div> </div>
<button id="preview-close" class="modal-close" type="button" aria-label="닫기">×</button> <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>
<div class="modal-body"> <div class="modal-body">
<div id="preview-meta" class="preview-meta"></div> <div id="preview-meta" class="preview-meta"></div>
@@ -518,6 +573,9 @@
</div> </div>
<script> <script>
let allTables = [];
let currentPreview = null;
function escapeHtml(value) { function escapeHtml(value) {
return String(value ?? "") return String(value ?? "")
.replaceAll("&", "&amp;") .replaceAll("&", "&amp;")
@@ -572,6 +630,10 @@
function renderTables(items) { function renderTables(items) {
const target = document.getElementById("table-body"); 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) { if (!items.length) {
target.innerHTML = '<tr><td colspan="5" class="empty">표시할 테이블이 없습니다.</td></tr>'; target.innerHTML = '<tr><td colspan="5" class="empty">표시할 테이블이 없습니다.</td></tr>';
return; return;
@@ -699,7 +761,9 @@
} }
function renderTablePreview(payload) { function renderTablePreview(payload) {
currentPreview = payload;
const previewModal = document.getElementById("preview-modal"); const previewModal = document.getElementById("preview-modal");
const downloadButton = document.getElementById("preview-download");
const previewMeta = document.getElementById("preview-meta"); const previewMeta = document.getElementById("preview-meta");
const previewTitle = document.getElementById("preview-title"); const previewTitle = document.getElementById("preview-title");
const previewSubtitle = document.getElementById("preview-subtitle"); const previewSubtitle = document.getElementById("preview-subtitle");
@@ -708,6 +772,9 @@
previewTitle.textContent = `${payload.label} · ${payload.table_ref}`; previewTitle.textContent = `${payload.label} · ${payload.table_ref}`;
previewSubtitle.textContent = `${formatNumber(payload.row_count)} rows / 최대 ${formatNumber(payload.limit)}개 표시`; previewSubtitle.textContent = `${formatNumber(payload.row_count)} rows / 최대 ${formatNumber(payload.limit)}개 표시`;
if (downloadButton) {
downloadButton.disabled = !(payload.rows && payload.rows.length);
}
previewMeta.innerHTML = ` previewMeta.innerHTML = `
<div> <div>
<div class="table-title">${escapeHtml(payload.label)}</div> <div class="table-title">${escapeHtml(payload.label)}</div>
@@ -765,11 +832,12 @@
throw new Error(`DB 상태를 불러오지 못했습니다. (${response.status})`); throw new Error(`DB 상태를 불러오지 못했습니다. (${response.status})`);
} }
const payload = await response.json(); const payload = await response.json();
allTables = payload.tables || [];
document.getElementById("generated-at").textContent = payload.generated_at document.getElementById("generated-at").textContent = payload.generated_at
? `갱신 ${formatDateTime(payload.generated_at)}` ? `갱신 ${formatDateTime(payload.generated_at)}`
: "갱신 시각 없음"; : "갱신 시각 없음";
renderOverview(payload.overview || {}); renderOverview(payload.overview || {});
renderTables(payload.tables || []); renderTables(allTables);
renderBatches(payload.import_batches || []); renderBatches(payload.import_batches || []);
renderBinarySources(payload.binary_sources || []); renderBinarySources(payload.binary_sources || []);
renderNotes(payload.notes || []); renderNotes(payload.notes || []);
@@ -778,12 +846,61 @@
renderScreenMap(payload.screen_map || []); 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", () => { document.getElementById("preview-close").addEventListener("click", () => {
const modal = document.getElementById("preview-modal"); const modal = document.getElementById("preview-modal");
modal.classList.remove("open"); modal.classList.remove("open");
modal.setAttribute("aria-hidden", "true"); 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) => { document.getElementById("preview-modal").addEventListener("click", (event) => {
if (event.target.id !== "preview-modal") return; if (event.target.id !== "preview-modal") return;
const modal = document.getElementById("preview-modal"); const modal = document.getElementById("preview-modal");

View File

@@ -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 시 생성

View File

@@ -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();

View File

@@ -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);

View File

@@ -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);
} }
renderListViewTable(); if (shouldReturnToList) {
returnToListViewModal();
} else {
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);

View 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

View File

@@ -38,6 +38,10 @@ fi
echo "[4/6] Copying local runtime env when available" echo "[4/6] Copying local runtime env when available"
copy_optional_path ".env" copy_optional_path ".env"
if [[ ! -f "${DEV_DIR}/.env" && -f "${DEV_DIR}/.env.example" ]]; then
cp "${DEV_DIR}/.env.example" "${DEV_DIR}/.env"
echo "Created ${DEV_DIR}/.env from .env.example"
fi
echo "[5/6] Copying local-only incoming design assets when available" echo "[5/6] Copying local-only incoming design assets when available"
copy_optional_path "incoming-files/1.png" copy_optional_path "incoming-files/1.png"

View File

@@ -0,0 +1,13 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
APP_DIR="${ROOT_DIR}/frontend/apps/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"

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

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

View 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