Refactor app structure and simplify team docs

This commit is contained in:
hyunho
2026-04-02 11:13:43 +09:00
parent 8125193378
commit f8ea345882
47 changed files with 4921 additions and 10495 deletions

55
CONTRIBUTING.md Normal file
View File

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

59
README.md Normal file
View File

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

View File

@@ -24,20 +24,36 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from openpyxl import load_workbook
from pydantic import BaseModel, Field
from .auth_routes import register_auth_routes
from .config import BASE_DIR, LEGACY_DIR, MOCK_LOGIN_ENABLED, UPLOAD_DIR
from .db import get_conn, init_db
from .integration_routes import register_integration_routes
from .ledger_runtime import (
build_business_ledger_default_response,
build_ledger_index_response,
sync_default_business_ledger_source,
)
from .member_routes import register_member_routes
from .seatmap_routes import register_seatmap_routes
from .system_routes import register_system_routes
from .routes import (
register_auth_routes,
register_integration_routes,
register_member_routes,
register_seatmap_routes,
register_system_routes,
)
from .repositories import fetch_history_revision, fetch_history_revision_created_at, fetch_history_revisions, fetch_members, fetch_members_as_of
from .schemas import (
MemberBulkPayload,
MemberPayload,
SeatLayoutPayload,
SeatMapPayload,
)
from .services import (
create_history_revision,
normalize_phone,
replace_members,
serialize_member_payload,
sync_member_versions,
sync_seat_assignment_versions,
)
app = FastAPI(title="MH Dashboard Organization API")
@@ -104,61 +120,6 @@ app.mount(
)
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 SeatPlacementPayload(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[SeatPlacementPayload]
LEGACY_HEADER_MAP = {
"이름": "name",
"name": "name",
@@ -203,42 +164,6 @@ LEGACY_HEADER_MAP = {
}
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: MemberPayload, 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 _encode_auth_bytes(value: bytes) -> str:
return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=")
@@ -430,21 +355,6 @@ def sync_auth_users_from_members(cur) -> None:
ensure_default_admin_user(cur)
def fetch_members() -> 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 parse_as_of(as_of: str | None) -> datetime | None:
raw = str(as_of or "").strip()
if not raw:
@@ -461,223 +371,6 @@ def parse_as_of(as_of: str | None) -> datetime | None:
raise HTTPException(status_code=400, detail="Invalid as_of format. Use YYYY-MM-DD or ISO datetime.") from exc
def create_history_revision(cur, label_prefix: str, note: str) -> 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 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 sync_member_versions(cur, member_ids: list[int], change_reason: str, revision_no: int) -> None:
if not member_ids:
return
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 = NOW() WHERE id = %s AND valid_to IS NULL",
(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 = NOW() WHERE id = %s AND valid_to IS NULL",
(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, NOW(), NULL, %s, NULL, %s)
""",
(member_id, *current_tuple, revision_no, change_reason),
)
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 sync_seat_assignment_versions(cur, member_ids: list[int], change_reason: str, revision_no: int) -> None:
if not member_ids:
return
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 = NOW() WHERE id = %s AND valid_to IS NULL",
(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, NOW(), NULL, %s, NULL, %s)
""",
(
member_id,
current.get("seat_map_id"),
current.get("seat_slot_id"),
str(current.get("seat_label") or ""),
revision_no,
change_reason,
),
)
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
FROM member_versions mv
LEFT JOIN members m
ON m.id = mv.member_id
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()
def build_member_compare_items(from_items: list[dict[str, object]], to_items: list[dict[str, object]]) -> list[dict[str, object]]:
tracked_fields = (
("company", "소속회사", "기본"),
@@ -2297,9 +1990,15 @@ def save_seat_layout(seat_map_id: int, payload: SeatLayoutPayload) -> list[dict[
)
affected_member_ids = sorted(set(previous_member_ids + member_ids))
if affected_member_ids:
revision_no = create_history_revision(cur, "seat-layout", f"Seat layout saved for seat_map_id={seat_map_id}")
sync_seat_assignment_versions(cur, affected_member_ids, f"seat-layout:{seat_map_id}", revision_no)
sync_member_versions(cur, affected_member_ids, f"seat-layout:{seat_map_id}", revision_no)
revision_no = create_history_revision(
cur,
"seat-layout",
f"Seat layout saved for seat_map_id={seat_map_id}",
app_timezone=APP_TIMEZONE,
)
revision_created_at = fetch_history_revision_created_at(cur, revision_no)
sync_seat_assignment_versions(cur, affected_member_ids, f"seat-layout:{seat_map_id}", revision_no, revision_created_at)
sync_member_versions(cur, affected_member_ids, f"seat-layout:{seat_map_id}", revision_no, revision_created_at)
conn.commit()
return fetch_seat_layout(seat_map_id)["placements"]
@@ -2312,126 +2011,6 @@ def get_member_count() -> int:
return int(cur.fetchone()["count"])
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]) -> 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")
sync_member_versions(cur, affected_member_ids, "members-bulk-sync", revision_no)
sync_seat_assignment_versions(cur, affected_member_ids, "members-bulk-sync", revision_no)
conn.commit()
return fetch_members()
def rows_to_member_payloads(rows: list[list[object]]) -> list[MemberPayload]:
def normalize_header(value: object) -> str:
return str(value or "").strip().lower()
@@ -2933,7 +2512,7 @@ def parse_project_category_mapping_source(path: Path) -> list[dict[str, str]]:
def fetch_member_lookup() -> tuple[dict[str, dict[str, object]], dict[str, list[dict[str, object]]]]:
members = fetch_members()
members = fetch_members(get_conn)
by_employee_id = {
clean_text(member.get("employee_id")): member
for member in members
@@ -3119,7 +2698,12 @@ def import_integration_sources() -> dict[str, object]:
payment_raw_rows, payment_vouchers = parse_payment_source(payment_path)
project_category_mappings = parse_project_category_mapping_source(project_mapping_path) if project_mapping_path.exists() else []
replace_members(organization_members)
replace_members(
organization_members,
get_conn=get_conn,
sync_auth_users_from_members=sync_auth_users_from_members,
app_timezone=APP_TIMEZONE,
)
members_by_employee_id, members_by_name = fetch_member_lookup()
with get_conn() as conn:
@@ -4073,15 +3657,26 @@ register_member_routes(
member_payload_cls=MemberPayload,
member_bulk_payload_cls=MemberBulkPayload,
parse_as_of=parse_as_of,
fetch_members=fetch_members,
fetch_members=lambda: fetch_members(get_conn),
fetch_members_as_of=fetch_members_as_of,
build_member_compare_items=build_member_compare_items,
serialize_member_payload=serialize_member_payload,
sync_auth_users_from_members=sync_auth_users_from_members,
create_history_revision=create_history_revision,
create_history_revision=lambda cur, label_prefix, note: create_history_revision(
cur,
label_prefix,
note,
app_timezone=APP_TIMEZONE,
),
fetch_history_revision_created_at=fetch_history_revision_created_at,
sync_member_versions=sync_member_versions,
sync_seat_assignment_versions=sync_seat_assignment_versions,
replace_members=replace_members,
replace_members=lambda items: replace_members(
items,
get_conn=get_conn,
sync_auth_users_from_members=sync_auth_users_from_members,
app_timezone=APP_TIMEZONE,
),
parse_import_rows=parse_import_rows,
)
register_integration_routes(

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,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 로그인 제거.
변경 대상:
- [frontend/public/app.js](/home/hyunho/projects/mh-dashboard-organization/frontend/public/app.js)
- [backend/app/main.py](/home/hyunho/projects/mh-dashboard-organization/backend/app/main.py)
- [backend/app/config.py](/home/hyunho/projects/mh-dashboard-organization/backend/app/config.py)
- [frontend/public/app.js](../frontend/public/app.js)
- [backend/app/main.py](../backend/app/main.py)
- [backend/app/config.py](../backend/app/config.py)
### Phase 4

View File

@@ -23,19 +23,10 @@
- 이 저장소를 서버로 복사합니다.
- `.env.example`을 기준으로 `.env` 파일을 만들고 실제 DB 비밀번호를 넣습니다.
## 4-1. 현재 로컬 PC 기준 WSL 작업 표준
- 현재 로컬 개발 서버는 `WSL2 + Ubuntu-24.04` 기준으로 구성했습니다.
- 기본 작업 사용자는 `hyunho`니다.
- 앞으로의 기준 작업 경로는 아래입니다.
- `/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 경로, 실행 환경이 실제 서버와 가장 비슷하게 맞춰집니다.
## 4-1. 로컬 개발 환경 원칙
- 로컬 개발은 Linux 계열 개발 환경을 권장합니다.
- Docker, Python, 파일 경로가 실제 배포 환경과 최대한 비슷한 환경에서 작업하는 것이 안전합니다.
- Windows 호스트를 사용하는 경우에도, 실제 실행 경로와 편집 경로가 어긋나지 않도록 주의합니다.
## 5. Docker 설치 관련 메모
- 메신저에 공유된 명령어는 Ubuntu 서버에 Docker를 설치하기 위한 절차입니다.
@@ -73,8 +64,6 @@
## 10. 현재 로컬 테스트 접속 정보
- 접속 주소: `http://localhost:8080`
- 상태 확인 API: `http://localhost:8080/api/health`
- WSL 내부 실행 경로:
- `/home/hyunho/projects/mh-dashboard-organization`
## 11. 운영 검증 체크포인트
- 엑셀 또는 CSV 업로드 후 `GET /api/members` 에서 데이터가 조회되는지 확인합니다.

View File

@@ -2,7 +2,7 @@
## 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. 인증 기본 구조 추가
@@ -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

View File

@@ -10,15 +10,15 @@
### 코드 경로
- 공개용 `8080`: `/home/hyunho/projects/mh-dashboard-organization`
- 작업용 `8081`: `/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081`
- 공개용 `8080`: 메인 workspace
- 작업용 `8081`: 메인 workspace 아래의 격리 worktree
### 작업용 Compose 기준
- 공개용 `8080` stack: `docker-compose.yml`
- 작업용 `8081` stack: `docker-compose.8081.yml`
- 작업용 project name 기본값: `mh-dashboard-organization-dev`
- 작업용 `8081`는 반드시 `/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081`에서 띄운다
- 작업용 `8081`는 반드시 격리된 worktree에서 띄운다
### DB 볼륨
@@ -102,7 +102,7 @@
1. `8080``8081` 모두 기동 상태 확인
2. 이번 작업이 `코드 변경`인지 `데이터 변경`인지 먼저 구분
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. 기능 개발 중
@@ -154,7 +154,7 @@
2. 공개용 기준 데이터가 필요한지 판단
3. 필요하면 `8081` DB를 `8080` 기준으로 먼저 동기화
4. 그 뒤 기능 개발과 검증 수행
5. 검증은 [REGRESSION_CHECKLIST.md](/home/hyunho/projects/mh-dashboard-organization/docs/REGRESSION_CHECKLIST.md) 기준으로 수행
5. 검증은 [REGRESSION_CHECKLIST.md](REGRESSION_CHECKLIST.md) 기준으로 수행
6. 검증 완료 후 공개용에 코드 승격
## 다음 액션
@@ -167,14 +167,14 @@
반복 가능한 동기화 스크립트:
- [sync_prod_db_to_dev.sh](/home/hyunho/projects/mh-dashboard-organization/scripts/sync_prod_db_to_dev.sh)
- [docker-compose.8081.yml](/home/hyunho/projects/mh-dashboard-organization/docker-compose.8081.yml)
- [sync_prod_db_to_dev.sh](../scripts/sync_prod_db_to_dev.sh)
- [docker-compose.8081.yml](../docker-compose.8081.yml)
사용 방법:
```bash
./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
./scripts/sync_prod_db_to_dev.sh minimal
./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를 직접 마운트하면 안 된다
- 컨테이너가 `/home/hyunho/projects/mh-dashboard-organization/...` 물고 있으면 분리 상태가 깨진 것이다
- 정상 상태는 `docker inspect mh-dashboard-organization-dev-backend-1` 기준 마운트 소스가 `/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/...`로 나와야 한다
- 컨테이너가 메인 workspace를 직접 물고 있으면 분리 상태가 깨진 것이다
- 정상 상태는 `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/members` 에서 `items` 비어 있지 않음
- 다른 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. 컨테이너 기동
- `docker compose build`

View File

@@ -1,181 +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` 백엔드 영속 저장 구조 운영 마무리
- `#7` 자리배치도 팀별 구역 색상 오버레이
- `#8` 자리배치도 좌석 클릭 시 개인 상위 조직 트리 표시
- `#14` 누적된 임시 로직 정리 및 중복 코드 제거
- `#16` 사업관리대장 메인 후속 정리 및 기준 분석
- `#19` 8081 백엔드 라우터/서빙 deeper 모듈 분리
- `#21` organization 레거시 구조 승격 및 장기 고도화
## Current Seatmap Work Note
- `#8`:
- 자리배치도 우측 패널에 선택 인원 상위 조직 트리 표시 로직을 추가했다.
- 좌석 클릭 / 구성원 카드 클릭 / fixed viewer 선택 이벤트가 부모 화면으로 올라오도록 연결했다.
- 검색 카드 클릭은 상단 패널을 띄우지 않고 좌석 포커스만 하도록 분리했다.
- `#7`:
- 해석 기준은 `개별 좌석 색칠`이 아니라 `팀이 모여 있는 좌석 군집을 하나의 구역처럼 보여주는 오버레이`다.
- grid 배치도와 fixed/DXF viewer 양쪽에 팀 구역 오버레이 코드를 넣었다.
- 현재 증상은 `처음 반짝 보였다가 사라지는 현상`이며, 데이터 부재가 아니라 viewer draw/layer 타이밍 문제로 보인다.
- 다음 작업 시작 시 `#7`을 먼저 재확인한다.
- 자리배치도 카드 텍스트 규칙:
- `이름 - 직급 - 팀(또는 다음 조직 fallback)` 순서
- 검색 목록 카드에는 `chair-00` 같은 좌석 배지를 노출하지 않음
- 미배치 인원 카드는 외곽선만 사용하고 내부 채움색은 사용하지 않음
- 사업관리대장 기본 원본 API 오류:
- 원인은 DB가 아니라 `X-Original-Filename` 한글 헤더 인코딩 오류였음
- [backend/app/ledger_runtime.py](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/backend/app/ledger_runtime.py) 에서 URL-encoded 헤더로 수정
- `./scripts/check_8081_smoke.sh``ledger-default-api` 체크를 넣음
## 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`를 먼저 확인
- 구조 정리나 라우트 변경 직후에는 `./scripts/check_8081_smoke.sh` 를 먼저 실행해 `ledger-default-api` 까지 확인

View File

@@ -15,8 +15,8 @@
관련 문서:
- [DEV_PROD_DB_PROTOCOL.md](/home/hyunho/projects/mh-dashboard-organization/docs/DEV_PROD_DB_PROTOCOL.md)
- [INFRA_VALIDATION_CHECKLIST.md](/home/hyunho/projects/mh-dashboard-organization/docs/INFRA_VALIDATION_CHECKLIST.md)
- [DEV_PROD_DB_PROTOCOL.md](DEV_PROD_DB_PROTOCOL.md)
- [INFRA_VALIDATION_CHECKLIST.md](INFRA_VALIDATION_CHECKLIST.md)
## 작업 시작 전

159
docs/TEAM_GUIDE.md Normal file
View File

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

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,270 +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 동기화
- 구조 정리나 서빙 경로 수정 직후에는 `./scripts/check_8081_smoke.sh` 로 핵심 런타임을 먼저 확인
## 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,286 +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`
- 구조 정리, 라우트 분리, 기본 원본 API 변경 후에는 `./scripts/check_8081_smoke.sh` 를 먼저 통과시킨다.
## 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/`로 갱신한다.
- `사업관리대장``#21`부터 wrapper decode 방식 대신 `served/ledger/index.html``served/ledger/*`를 직접 서빙한다.
- `사업관리대장` 수정 원본은 `#21` 다음 단계부터 `frontend/apps/ledger/*`를 먼저 보고, `scripts/publish_ledger_app.sh`로 runtime served 파일에 반영한다.
- 기존 루트 `incoming-files/payment.html`, `incoming-files/mh.html`는 안전한 비교/복구를 위해 당분간 남겨둔다.
## Seat Map

View File

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

View File

@@ -2,9 +2,9 @@
## Source of truth
- Primary visual source: [incoming-files/sample style.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/incoming-files/sample%20style.css)
- Runtime token file: [design-tokens.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/design-tokens.css)
- Runtime pattern file: [design-patterns.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/design-patterns.css)
- Primary visual source: [incoming-files/sample style.css](../../incoming-files/sample%20style.css)
- Runtime token file: [design-tokens.css](../../frontend/public/design-tokens.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.

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

@@ -32,9 +32,3 @@
- `reference/ledger/MH 통합 대시보드_260320.html`
- `reference/ledger/MH 통합 대시보드_260320.css`
- `reference/ledger/사업관리대장-1.xlsx`
## Temporary Comparison Copies
- 현재 루트의 `payment.html`, `mh.html`은 당장 삭제하지 않는다.
- 이 두 파일은 기존 recovery 작업본과 현재 `served/*`를 비교하거나 되돌릴 때만 본다.
- 다음 차수에서 안전성이 확보되면 `reference/` 하위로 재배치 여부를 검토한다.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

@@ -4,8 +4,8 @@
source-of-truth:
- [frontend/apps/ledger](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/apps/ledger)
- 반영 스크립트: [scripts/publish_ledger_app.sh](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/scripts/publish_ledger_app.sh)
- [frontend/apps/ledger](../../../frontend/apps/ledger)
- 반영 스크립트: [scripts/publish_ledger_app.sh](../../../scripts/publish_ledger_app.sh)
- `index.html`: `/integrations/ledger` 응답 본문
- `frontend/apps/ledger/index.html` 템플릿에서 publish 시 생성

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)"
APP_DIR="${ROOT_DIR}/frontend/apps/payment"
TARGET_FILE="${ROOT_DIR}/incoming-files/served/payment.html"
COMPARE_FILE="${ROOT_DIR}/incoming-files/payment.html"
cp "${APP_DIR}/index.html" "${TARGET_FILE}"
cp "${APP_DIR}/index.html" "${COMPARE_FILE}"
echo "Published payment app source to ${TARGET_FILE}"

View File

@@ -5,9 +5,7 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
APP_DIR="${ROOT_DIR}/frontend/apps/team"
TARGET_FILE="${ROOT_DIR}/incoming-files/served/mh.html"
COMPARE_FILE="${ROOT_DIR}/incoming-files/mh.html"
cp "${APP_DIR}/index.html" "${TARGET_FILE}"
cp "${APP_DIR}/index.html" "${COMPARE_FILE}"
echo "Published team app source to ${TARGET_FILE}"