diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..cb67845 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,55 @@ +# Contributing + +## 기본 규칙 + +- `main`은 팀 기준 브랜치로 사용합니다. +- 기능 개발과 버그 수정은 각자 작업 브랜치에서 진행합니다. +- 직접 `8080` 기준 파일을 수정하지 않습니다. +- 검증은 먼저 `8081` 개발 환경에서 수행합니다. +- 커밋은 한 기능 또는 한 버그 단위로 작게 나눕니다. +- 작업 시작 전 [docs/TEAM_GUIDE.md](docs/TEAM_GUIDE.md)를 먼저 읽습니다. + +## 권장 브랜치 이름 + +- `feature/-` +- `fix/-` +- `chore/-` + +예: + +- `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) diff --git a/README.md b/README.md new file mode 100644 index 0000000..e08769e --- /dev/null +++ b/README.md @@ -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` diff --git a/backend/app/main.py b/backend/app/main.py index 88f2abb..b594005 100755 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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( diff --git a/backend/app/repositories/__init__.py b/backend/app/repositories/__init__.py new file mode 100644 index 0000000..ccb3a81 --- /dev/null +++ b/backend/app/repositories/__init__.py @@ -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", +] diff --git a/backend/app/repositories/organization.py b/backend/app/repositories/organization.py new file mode 100644 index 0000000..92056bf --- /dev/null +++ b/backend/app/repositories/organization.py @@ -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() diff --git a/backend/app/routes/__init__.py b/backend/app/routes/__init__.py new file mode 100644 index 0000000..265e2e4 --- /dev/null +++ b/backend/app/routes/__init__.py @@ -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", +] diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py new file mode 100644 index 0000000..4d6201c --- /dev/null +++ b/backend/app/routes/auth.py @@ -0,0 +1,3 @@ +from ..auth_routes import register_auth_routes + +__all__ = ["register_auth_routes"] diff --git a/backend/app/routes/integration.py b/backend/app/routes/integration.py new file mode 100644 index 0000000..9686350 --- /dev/null +++ b/backend/app/routes/integration.py @@ -0,0 +1,3 @@ +from ..integration_routes import register_integration_routes + +__all__ = ["register_integration_routes"] diff --git a/backend/app/routes/organization.py b/backend/app/routes/organization.py new file mode 100644 index 0000000..cd57a7d --- /dev/null +++ b/backend/app/routes/organization.py @@ -0,0 +1,3 @@ +from ..member_routes import register_member_routes + +__all__ = ["register_member_routes"] diff --git a/backend/app/routes/seatmap.py b/backend/app/routes/seatmap.py new file mode 100644 index 0000000..b50f608 --- /dev/null +++ b/backend/app/routes/seatmap.py @@ -0,0 +1,3 @@ +from ..seatmap_routes import register_seatmap_routes + +__all__ = ["register_seatmap_routes"] diff --git a/backend/app/routes/system.py b/backend/app/routes/system.py new file mode 100644 index 0000000..c73c654 --- /dev/null +++ b/backend/app/routes/system.py @@ -0,0 +1,3 @@ +from ..system_routes import register_system_routes + +__all__ = ["register_system_routes"] diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..9ccbe30 --- /dev/null +++ b/backend/app/schemas/__init__.py @@ -0,0 +1,15 @@ +from .organization import ( + MemberBulkPayload, + MemberPayload, + SeatLayoutPayload, + SeatLayoutPlacementPayload, + SeatMapPayload, +) + +__all__ = [ + "MemberBulkPayload", + "MemberPayload", + "SeatLayoutPayload", + "SeatLayoutPlacementPayload", + "SeatMapPayload", +] diff --git a/backend/app/schemas/organization.py b/backend/app/schemas/organization.py new file mode 100644 index 0000000..600df5e --- /dev/null +++ b/backend/app/schemas/organization.py @@ -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] diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..ff5f2f0 --- /dev/null +++ b/backend/app/services/__init__.py @@ -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", +] diff --git a/backend/app/services/organization.py b/backend/app/services/organization.py new file mode 100644 index 0000000..ccc05f2 --- /dev/null +++ b/backend/app/services/organization.py @@ -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) diff --git a/docs/AUTH_DB_DESIGN.md b/docs/AUTH_DB_DESIGN.md index 06d5554..b4062c3 100644 --- a/docs/AUTH_DB_DESIGN.md +++ b/docs/AUTH_DB_DESIGN.md @@ -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 diff --git a/docs/DEPLOYMENT_GUIDE.md b/docs/DEPLOYMENT_GUIDE.md index 9b736e6..0e6abdf 100755 --- a/docs/DEPLOYMENT_GUIDE.md +++ b/docs/DEPLOYMENT_GUIDE.md @@ -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` 에서 데이터가 조회되는지 확인합니다. diff --git a/docs/DEVELOPMENT_HISTORY.md b/docs/DEVELOPMENT_HISTORY.md index e33fc51..5f1d20e 100644 --- a/docs/DEVELOPMENT_HISTORY.md +++ b/docs/DEVELOPMENT_HISTORY.md @@ -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 diff --git a/docs/DEV_PROD_DB_PROTOCOL.md b/docs/DEV_PROD_DB_PROTOCOL.md index b1b1128..3f2d295 100644 --- a/docs/DEV_PROD_DB_PROTOCOL.md +++ b/docs/DEV_PROD_DB_PROTOCOL.md @@ -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 /.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 경로로 나와야 한다 규칙: diff --git a/docs/INFRA_VALIDATION_CHECKLIST.md b/docs/INFRA_VALIDATION_CHECKLIST.md index b7230db..8075ca1 100644 --- a/docs/INFRA_VALIDATION_CHECKLIST.md +++ b/docs/INFRA_VALIDATION_CHECKLIST.md @@ -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` diff --git a/docs/NEXT_SESSION_CHECKPOINT.md b/docs/NEXT_SESSION_CHECKPOINT.md deleted file mode 100644 index f3efe97..0000000 --- a/docs/NEXT_SESSION_CHECKPOINT.md +++ /dev/null @@ -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` 까지 확인 diff --git a/docs/REGRESSION_CHECKLIST.md b/docs/REGRESSION_CHECKLIST.md index ac6509a..a25454e 100644 --- a/docs/REGRESSION_CHECKLIST.md +++ b/docs/REGRESSION_CHECKLIST.md @@ -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) ## 작업 시작 전 diff --git a/docs/TEAM_GUIDE.md b/docs/TEAM_GUIDE.md new file mode 100644 index 0000000..6289208 --- /dev/null +++ b/docs/TEAM_GUIDE.md @@ -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) diff --git a/docs/TODAY_WORK_PREP_2026-03-30.md b/docs/TODAY_WORK_PREP_2026-03-30.md deleted file mode 100644 index b7e1865..0000000 --- a/docs/TODAY_WORK_PREP_2026-03-30.md +++ /dev/null @@ -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 운영 안정화를 우선하는 것이 맞다 diff --git a/docs/WORK_EXECUTION_FLOW.md b/docs/WORK_EXECUTION_FLOW.md deleted file mode 100644 index 7ea207a..0000000 --- a/docs/WORK_EXECUTION_FLOW.md +++ /dev/null @@ -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 유지`, `조직현황 탭`, `프로젝트/팀 탭` diff --git a/docs/WORK_RULEBOOK.md b/docs/WORK_RULEBOOK.md deleted file mode 100644 index 918e91a..0000000 --- a/docs/WORK_RULEBOOK.md +++ /dev/null @@ -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와 검증을 무시하지 않고, 기록을 남기면서 작업한다. diff --git a/docs/WSL_WORKSPACE_GUIDE.md b/docs/WSL_WORKSPACE_GUIDE.md deleted file mode 100755 index af03ac6..0000000 --- a/docs/WSL_WORKSPACE_GUIDE.md +++ /dev/null @@ -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` diff --git a/docs/architecture/8081_SERVING_MAP.md b/docs/architecture/8081_SERVING_MAP.md index 3727185..7e2124d 100644 --- a/docs/architecture/8081_SERVING_MAP.md +++ b/docs/architecture/8081_SERVING_MAP.md @@ -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 diff --git a/docs/architecture/DB_TABLE_CATALOG.md b/docs/architecture/DB_TABLE_CATALOG.md index 397f266..5622f0b 100644 --- a/docs/architecture/DB_TABLE_CATALOG.md +++ b/docs/architecture/DB_TABLE_CATALOG.md @@ -2,7 +2,7 @@ ## Purpose -이 문서는 `8081 / work-8081` 기준 현재 PostgreSQL 테이블 26개를 역할별로 분류한 운영 기준 문서다. +이 문서는 현재 PostgreSQL 테이블을 역할별로 분류한 운영 기준 문서다. 핵심 원칙: diff --git a/docs/architecture/DESIGN_SSOT.md b/docs/architecture/DESIGN_SSOT.md index 03fa7e5..55b39a9 100644 --- a/docs/architecture/DESIGN_SSOT.md +++ b/docs/architecture/DESIGN_SSOT.md @@ -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. diff --git a/frontend/apps/organization/README.md b/frontend/apps/organization/README.md new file mode 100644 index 0000000..a9e6485 --- /dev/null +++ b/frontend/apps/organization/README.md @@ -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`를 실행해 서빙 파일로 반영한다. diff --git a/frontend/apps/organization/assets/common.css b/frontend/apps/organization/assets/common.css new file mode 100644 index 0000000..b0d2362 --- /dev/null +++ b/frontend/apps/organization/assets/common.css @@ -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); +} diff --git a/frontend/apps/organization/assets/organization.css b/frontend/apps/organization/assets/organization.css new file mode 100644 index 0000000..099aad9 --- /dev/null +++ b/frontend/apps/organization/assets/organization.css @@ -0,0 +1,1744 @@ +body { + overflow-x: hidden; + overflow-y: auto; + background: + radial-gradient(circle at top left, rgba(214, 138, 58, 0.14), transparent 24%), + radial-gradient(circle at top right, rgba(47, 153, 115, 0.08), transparent 20%), + linear-gradient(180deg, rgba(246, 239, 230, 0.98), rgba(241, 234, 223, 0.96)); +} + +.org-canvas { + padding: 20px 20px 40px; + display: flex; + flex-direction: column; + align-items: center; + gap: 40px; + width: 100%; + position: relative; +} + +.dept-section { + width: 100%; + max-width: 2000px; + display: flex; + flex-direction: column; + align-items: center; + margin-bottom: 40px; + position: relative; +} + +.dept-box { + width: fit-content; + min-width: 320px; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + box-shadow: var(--shadow-soft); + position: relative; + z-index: 20; + margin-bottom: 40px; +} + +.dept-header { + background: var(--color-header); + color: white; + padding: 12px; + text-align: center; + font-size: 17px; + font-weight: 900; + border-radius: var(--radius-md); +} + +.dept-header.has-members { + border-radius: 10px 10px 0 0; + border-bottom: none; + margin-bottom: 15px; +} + +.node-group { + display: flex; + flex-wrap: wrap; + justify-content: center; + width: 100%; + position: relative; + gap: 12px; +} + +.node-item { + display: flex; + flex-direction: column; + align-items: center; + position: relative; +} + +.box { + width: fit-content; + min-width: 112px; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + padding: 6px; + box-shadow: var(--shadow-soft); + position: relative; + z-index: 10; + margin-bottom: 40px; +} + +.box-name { + font-size: 13px; + font-weight: 800; + color: var(--color-text-soft); + text-align: center; + border-bottom: 1px solid var(--color-bg); + padding-bottom: 4px; + margin-bottom: 6px; + word-break: keep-all; +} + +.box-level-그룹 { + min-width: 250px; +} + +.box-level-그룹 .box-name { + background: var(--color-header-soft); + color: #ffffff; + padding: 8px; + border-radius: 6px 6px 0 0; + margin: -6px -6px 8px -6px; + border-bottom: none; +} + +.box-level-디비전 { + min-width: 150px; +} + +.box-level-디비전 .box-name { + background: #7b93ab; + color: #ffffff; + padding: 8px; + border-radius: 6px 6px 0 0; + margin: -6px -6px 8px -6px; + border-bottom: none; +} + +.box-level-팀 { + width: auto; + min-width: 120px; +} + +.box-team { + width: auto; + min-width: 120px; +} + +.member-grid { + display: grid; + grid-template-rows: repeat(10, auto); + grid-auto-flow: column; + gap: 3px; + column-gap: 8px; +} + +.cell-label { + grid-column: span 1; + background: var(--color-surface-strong); + color: var(--color-text-soft); + font-size: 10px; + font-weight: 900; + text-align: center; + padding: 3px; + border-radius: 4px; + margin: 2px 0; + height: fit-content; +} + +.spacer-box { + width: 100px; + height: 26px; + visibility: hidden; +} + +.member-card { + width: 100px; + padding: 4px 6px; + border-radius: 4px; + font-size: 11.5px; + text-align: left; + border: 1px solid var(--color-bg); + border-left: 4px solid var(--color-surface-strong); + background: var(--color-surface-soft); + cursor: pointer; + transition: all 0.2s ease-in-out; + display: flex; + flex-direction: column; + position: relative; +} + +.member-card.full-width { + width: 100% !important; +} + +.member-card:hover { + background: var(--color-surface); + border-color: var(--color-accent); + box-shadow: var(--shadow-soft); + transform: translateY(-2px); + z-index: 50; +} + +.drop-left::before { + content: ''; + position: absolute; + left: -6px; + top: 0; + width: 4px; + height: 100%; + background: var(--color-accent); + border-radius: 4px; + z-index: 20; +} + +.drop-right::after { + content: ''; + position: absolute; + right: -6px; + top: 0; + width: 4px; + height: 100%; + background: var(--color-accent); + border-radius: 4px; + z-index: 20; +} + +.co-삼안 { + border-left-color: #ffb366 !important; +} + +.co-한맥 { + border-left-color: #ef4444 !important; +} + +.co-피티씨 { + border-left-color: #a855f7 !important; +} + +.co-바론 { + border-left-color: #3b82f6 !important; +} + +.m-top { + display: flex; + align-items: baseline; + gap: 4px; +} + +.m-name { + font-weight: 900; + color: var(--color-text); + font-size: 12px; +} + +.m-rank { + color: var(--color-text-muted); + font-size: 8px; + font-weight: 500; + margin-left: auto; +} + +.m-role { + color: var(--color-header-soft); + font-weight: 800; + font-size: 8.5px; + margin-left: 3px; +} + +#modal { + position: fixed; + inset: 0; + background: rgba(34, 46, 39, 0.62); + backdrop-filter: blur(6px); + z-index: 2000; + display: none; + align-items: center; + justify-content: center; +} + +.modal-content { + background: var(--color-surface); + width: 100%; + max-width: 650px; + padding: 24px 24px 20px; + border-radius: 20px; + box-shadow: var(--shadow-float); + position: relative; + z-index: 2010; +} + +#last-updated { + z-index: 4000; +} + +@media print { + @page { + size: A3 landscape; + margin: 10mm; + } + + body { + background: white !important; + -webkit-print-color-adjust: exact !important; + print-color-adjust: exact !important; + } + + .top-wrap, + .search-container, + .tab-container, + .stat-section, + .fab-container, + .admin-mode-btn, + #last-updated, + .admin-mode-btn { + display: none !important; + } + + .main-content { + padding: 0 !important; + margin: 0 !important; + overflow: visible !important; + width: 100% !important; + } + + .dept-container { + page-break-inside: avoid; + margin-bottom: 20px !important; + } + + .member-card { + box-shadow: none !important; + border: 1px solid var(--color-border) !important; + } +} + +.modal-content.wide { + max-width: 1060px; + width: min(1040px, calc(100vw - 48px)); + max-height: calc(100vh - 28px); + padding: 20px 20px 16px; + display: flex; + flex-direction: column; + gap: 14px; + overflow: hidden; +} + +.modal-content.wide #modal-title { + margin-bottom: 0; + padding-bottom: 12px; + color: var(--color-text); +} + +.modal-content.wide #modal-fields { + min-height: 0; + overflow-y: auto; + padding-right: 4px; +} + +.modal-content.wide #modal-footer-area { + margin-top: 0 !important; + flex-shrink: 0; + position: relative; + z-index: 2; + background: var(--color-surface); +} + +.member-photo-field { + display: flex; + flex-direction: column; + gap: 8px; + height: 100%; +} + +.member-basic-top-row { + display: grid; + grid-template-columns: 220px minmax(0, 1fr); + gap: 12px; + align-items: stretch; +} + +.member-basic-editor { + display: flex; + flex-direction: column; + gap: 14px; +} + +.member-modal-tabs { + display: flex; + margin-bottom: 24px; + position: sticky; + top: 0; + z-index: 10; + background: var(--color-surface); + border-bottom: 1px solid var(--color-border); +} + +.member-modal-tab { + flex: 1 1 0; + padding: 12px 0; + font-size: 13px; + font-weight: 900; + color: var(--color-text-muted); + background: transparent; + border: 0; + border-bottom: 2px solid transparent; + transition: all 0.2s ease; +} + +.member-modal-tab.is-active { + color: var(--color-header); + border-bottom-color: var(--color-header); +} + +.member-modal-panel { + border: 1px solid var(--color-border); + border-radius: 18px; + background: linear-gradient(180deg, var(--color-surface) 0%, var(--color-surface-soft) 100%); + box-shadow: var(--shadow-soft); + padding: 16px; +} + +.member-modal-panel-title { + margin: 0 0 12px; + font-size: 11px; + font-weight: 900; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--color-header); +} + +.member-basic-split { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 14px; + align-items: stretch; +} + +.member-basic-left, +.member-basic-right, +.member-photo-panel, +.member-basic-fields { + min-width: 0; +} + +.member-basic-left, +.member-basic-right, +.member-photo-panel { + height: 100%; +} + +.member-basic-left { + display: grid; + grid-template-rows: auto 1fr; + gap: 12px; +} + +.member-basic-fields { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px 12px; + align-content: start; +} + +.member-basic-field { + display: flex; + flex-direction: column; + gap: 5px; + min-width: 0; +} + +.member-basic-field label, +.member-form-label { + font-size: 11px; + font-weight: 900; + color: var(--color-text-soft); +} + +.member-form-input, +.member-form-select, +.member-form-time { + width: 100%; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: 12px; + color: var(--color-text); + font-size: 13px; + font-weight: 800; + outline: none; +} + +.member-form-input, +.member-form-select { + padding: 12px 14px; +} + +.member-form-time { + padding: 10px 12px; +} + +.member-form-input:focus, +.member-form-select:focus, +.member-form-time:focus { + border-color: rgba(47, 153, 115, 0.42); + box-shadow: 0 0 0 4px rgba(47, 153, 115, 0.1); +} + +.member-form-manual { + margin-top: 8px; +} + +.member-form-new-option { + color: var(--color-header); + font-weight: 900; +} + +.member-seat-field { + min-height: 0; +} + +.member-seat-field-compact .seat-preview-card { + min-height: 100%; + height: 100%; +} + +.member-detail-top-row { + width: 100%; + display: grid; + grid-template-columns: 140px minmax(0, 1fr); + gap: 20px; + align-items: start; +} + +.member-detail-summary { + display: flex; + flex-direction: column; + gap: 12px; + min-width: 0; +} + +.member-name-field { + min-width: 0; +} + +.member-inline-info-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + margin-top: 10px; +} + +.member-inline-info-grid-edit { + margin-top: 10px; +} + +.member-inline-info-grid-stacked { + grid-template-columns: minmax(0, 1fr); + margin-top: 0; +} + +.member-name-field-compact { + display: flex; + flex-direction: column; + gap: 6px; +} + +.member-inline-info-card { + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; + padding: 10px; + border: 1px solid var(--color-border); + border-radius: 12px; + background: var(--color-surface-soft); +} + +.member-inline-info-card label { + display: block; + font-size: 10px; + font-weight: 900; + color: var(--color-text-muted); +} + +.member-inline-info-card strong { + display: block; + font-size: 13px; + font-weight: 900; + color: var(--color-text); + word-break: break-word; +} + +.member-inline-info-card-full { + grid-column: 1 / -1; +} + +.modal-form-grid { + align-items: start; +} + +.modal-form-grid>.col-span-1, +.modal-form-grid>.col-span-2 { + min-width: 0; +} + +.member-photo-upload-card { + display: flex; + gap: 16px; + align-items: center; + justify-content: center; + padding: 12px; + border: 1px solid var(--color-border); + border-radius: 16px; + background: var(--color-surface-soft); + box-sizing: border-box; +} + +.member-photo-upload-card-compact { + flex-direction: column; + align-items: center; + text-align: center; + justify-content: space-between; +} + +.member-photo-upload-card-inline { + min-height: 168px; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; +} + +.member-photo-panel, +.member-basic-right { + border: 1px solid var(--color-border); + border-radius: 18px; + background: linear-gradient(180deg, var(--color-surface) 0%, var(--color-surface-soft) 100%); + box-shadow: var(--shadow-soft); + padding: 16px; +} + +.member-basic-right { + padding: 0; + overflow: hidden; +} + +.member-photo-preview-wrap { + flex: 0 0 auto; +} + +.member-photo-preview { + width: 96px; + height: 96px; + border-radius: 9999px; + object-fit: cover; + border: 3px solid var(--color-surface-strong); + background: var(--color-surface); +} + +.member-photo-upload-controls { + min-width: 0; + display: flex; + flex-direction: column; + gap: 8px; + align-items: center; +} + +.member-photo-file-label { + display: inline-flex; + align-items: center; + justify-content: center; + width: fit-content; + padding: 10px 14px; + border-radius: 10px; + background: var(--color-header-soft); + color: #ffffff; + font-size: 12px; + font-weight: 800; + cursor: pointer; +} + +.member-photo-file-label:hover { + filter: brightness(1.05); +} + +.member-photo-file-label input { + display: none; +} + +.member-photo-file-name { + font-size: 11px; + color: var(--color-text-soft); + word-break: break-all; +} + +.seat-preview-card { + border: 1px solid var(--color-border); + border-radius: 18px; + background: linear-gradient(180deg, var(--color-surface) 0%, var(--color-surface-soft) 100%); + overflow: hidden; + min-height: 100%; +} + +.seat-preview-card.is-assigned { + border-color: rgba(47, 153, 115, 0.3); + box-shadow: var(--shadow-card); +} + +.seat-preview-head { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 12px 14px 8px; + align-items: flex-start; +} + +.seat-preview-head strong { + display: block; + font-size: 13px; + font-weight: 900; + color: var(--color-text); +} + +.seat-preview-head p { + margin: 4px 0 0; + font-size: 11px; + line-height: 1.5; + color: var(--color-text-muted); + font-weight: 700; +} + +.seat-preview-badge { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + padding: 6px 10px; + border-radius: 9999px; + background: linear-gradient(135deg, var(--color-header-soft) 0%, var(--color-header) 100%); + color: #ffffff; + font-size: 11px; + font-weight: 900; + box-shadow: var(--shadow-float); +} + +.seat-preview-badge-muted { + background: var(--color-surface-strong); + color: var(--color-text-muted); +} + +.seat-preview-canvas { + margin: 0 14px 14px; + min-height: 220px; + border-radius: 16px; + border: 1px dashed var(--color-border); + background: + linear-gradient(135deg, rgba(255, 250, 243, 0.92), rgba(244, 233, 215, 0.96)), + repeating-linear-gradient(0deg, + rgba(217, 197, 168, 0.18), + rgba(217, 197, 168, 0.18) 1px, + transparent 1px, + transparent 24px), + repeating-linear-gradient(90deg, + rgba(217, 197, 168, 0.18), + rgba(217, 197, 168, 0.18) 1px, + transparent 1px, + transparent 24px); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} + +.seat-preview-card.is-assigned .seat-preview-canvas { + border-color: rgba(47, 153, 115, 0.34); + box-shadow: inset 0 0 0 1px rgba(47, 153, 115, 0.1); +} + +.seat-preview-frame { + display: block; + width: 100%; + height: 300px; + border: 0; + background: var(--color-surface); + pointer-events: none; +} + +.seat-preview-card.is-assigned .seat-preview-frame { + box-shadow: inset 0 0 0 2px rgba(47, 153, 115, 0.14); +} + +.seat-preview-placeholder { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + color: var(--color-text-soft); + font-size: 12px; + font-weight: 900; +} + +.member-seat-field-compact .seat-preview-head { + padding: 14px 16px 10px; +} + +.member-seat-field-compact .seat-preview-head strong { + font-size: 17px; +} + +.member-seat-field-compact .seat-preview-head p { + font-size: 12px; + line-height: 1.35; +} + +.member-seat-field-compact .seat-preview-badge { + font-size: 11px; + padding: 7px 10px; +} + +.member-seat-field-compact .seat-preview-canvas { + min-height: 208px; +} + +.member-seat-field-compact .seat-preview-frame, +.member-seat-field-compact .seat-preview-placeholder { + min-height: 208px; +} + +.member-photo-upload-card-inline { + min-height: 168px; + padding: 16px 14px; + gap: 10px; + align-items: stretch; + justify-content: space-between; + text-align: left; +} + +.member-photo-card-title { + font-size: 11px; + font-weight: 900; + color: var(--color-text-soft); + line-height: 1.2; +} + +.member-photo-upload-card-inline .member-photo-preview-wrap { + margin: 0 auto; +} + +.member-photo-upload-card-inline .member-photo-preview { + width: 82px; + height: 82px; +} + +.member-photo-upload-card-inline .member-photo-upload-controls { + align-items: center; + text-align: center; +} + +.member-photo-upload-card-inline .member-photo-file-label { + padding: 9px 12px; +} + +.member-photo-upload-card-inline .member-photo-file-name { + font-size: 11px; +} + +.member-basic-field input { + min-width: 0; + padding: 12px 14px; +} + +.member-basic-field label { + line-height: 1.2; +} + + +.seat-preview-placeholder-icon { + width: 52px; + height: 52px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 9999px; + background: rgba(47, 153, 115, 0.1); + color: var(--color-header-soft); + font-size: 24px; +} + +@media (max-width: 720px) { + .member-basic-top-row { + grid-template-columns: 1fr; + } + + .member-basic-split { + grid-template-columns: 1fr; + } + + .member-basic-fields { + grid-template-columns: 1fr; + } + + .member-detail-top-row, + .member-inline-info-grid { + grid-template-columns: 1fr; + } +} + +.list-table { + width: 100%; + border-collapse: collapse; + font-size: 11px; +} + +.list-table th { + background: #f6eddd; + color: #7d8478; + font-weight: 800; + padding: 10px; + border: 1px solid #e0d0b4; + position: sticky; + top: 0; + z-index: 20; + text-align: center; +} + +.list-table td { + padding: 8px 10px; + border: 1px solid #e0d0b4; + text-align: center; + background: #fffaf0; + vertical-align: middle; +} + +.col-name { + width: 90px; +} + +.col-rank { + width: 80px; +} + +.col-pos { + width: 80px; +} + +.col-unit-sm { + width: 70px; +} + +.col-unit-lg { + width: 100px; +} + +.col-corp { + width: 110px; +} + +.col-action { + width: 90px; +} + +.list-header-row { + color: var(--color-text-soft); + font-weight: 800; + cursor: pointer; + user-select: none; + border-bottom: 1px solid var(--color-border-soft); +} + +.list-header-row td { + font-size: 13px; + text-align: left !important; + padding: 10px 15px !important; +} + +.list-header-row.lvl-0 td { + background: var(--color-header) !important; + color: white !important; + font-size: 13.5px; + font-weight: 900; +} + +.list-header-row.lvl-1 td { + background: var(--color-header-soft) !important; + color: white !important; +} + +.list-header-row.lvl-2 td { + background: #6f8f84 !important; + color: white !important; +} + +.list-header-row.lvl-3 td { + background: var(--color-accent) !important; + color: white !important; +} + +.list-header-row.lvl-4 td { + background: var(--color-surface-strong) !important; + color: var(--color-text-soft) !important; +} + +.list-header-row:hover { + filter: brightness(1.1); +} + +.collapse-icon { + margin-right: 8px; + transition: transform 0.2s; + display: inline-block; +} + +.collapsed .collapse-icon { + transform: rotate(-90deg); +} + +.hidden-row { + display: none !important; +} + +.list-table tr:hover td { + background: var(--color-surface-soft); +} + +.list-table tr.dragging { + opacity: 0.5; + background: var(--color-accent-soft); +} + +.list-search-target td { + background: var(--color-accent-soft) !important; + border-top: 2px solid var(--color-accent); + border-bottom: 2px solid var(--color-accent); +} + +.list-action-btn { + padding: 4px 8px; + border-radius: 4px; + font-size: 10px; + font-weight: 800; + cursor: pointer; + transition: all 0.2s; +} + +.btn-edit { + background: var(--color-accent-soft); + color: var(--color-header-soft); +} + +.btn-delete { + background: rgba(198, 71, 56, 0.12); + color: #a33427; +} + +.modal-footer-actions { + display: flex; + gap: 10px; + width: auto; + flex: 1 1 auto; + justify-content: flex-end; + align-items: center; +} + +.modal-btn { + border-radius: 12px; + font-size: 13px; + font-weight: 900; + padding: 14px 18px; + transition: all 0.2s ease; + border: 1px solid transparent; + white-space: nowrap; + writing-mode: horizontal-tb; +} + +.modal-btn-cancel { + flex: 0 1 140px; + background: var(--color-surface-strong); + color: var(--color-text-soft); + border-color: var(--color-border); +} + +.modal-btn-cancel:hover { + background: var(--color-surface-soft); +} + +.modal-btn-save { + flex: 0 1 140px; + background: var(--color-header); + color: #fff; + box-shadow: 0 10px 22px rgba(47, 153, 115, 0.2); +} + +.modal-btn-save:hover { + background: var(--color-header-soft); +} + +.modal-btn-delete { + flex: 0 0 132px; + min-width: 132px; + background: rgba(198, 71, 56, 0.12); + color: #a33427; + border-color: rgba(198, 71, 56, 0.2); +} + +.modal-btn-delete:hover { + background: rgba(198, 71, 56, 0.18); +} + +.modal-btn-close { + width: 100%; + background: var(--color-header); + color: #fff; + box-shadow: 0 10px 22px rgba(47, 153, 115, 0.2); +} + +.modal-btn-close:hover { + background: var(--color-header-soft); +} + +.list-toolbar { + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 16px; + padding: 4px; +} + +.list-toolbar-row { + display: flex; + gap: 10px; + align-items: center; + flex-wrap: wrap; +} + +.list-toolbar-group { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.list-toolbar-divider { + width: 1px; + align-self: stretch; + min-height: 36px; + background: var(--color-border); +} + +.list-mode-btn { + border: 1px solid #d9c49e; + background: #efe2ca; + color: var(--color-header-soft); + padding: 10px 14px; + border-radius: 12px; + font-size: 12px; + font-weight: 800; + cursor: pointer; +} + +.list-date-group { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.list-date-input { + border: 1px solid var(--color-border); + background: var(--color-surface); + color: var(--color-text); + padding: 9px 12px; + border-radius: 12px; + font-size: 12px; + font-weight: 700; +} + +.list-date-separator { + color: var(--color-text-muted); + font-size: 12px; + font-weight: 800; +} + +.list-view-status { + color: var(--color-text-muted); + font-size: 12px; + font-weight: 700; + padding: 2px 4px 0; +} + +.col-compare-status { + width: 82px; +} + +.col-compare-date { + width: 132px; +} + +.col-compare-category { + width: 120px; +} + +.list-empty-cell { + padding: 24px 16px !important; + color: var(--color-text-muted); + font-weight: 700; + text-align: center !important; +} + +.list-compare-cell { + text-align: left !important; + vertical-align: top !important; + line-height: 1.6; +} + +.list-compare-line + .list-compare-line { + margin-top: 3px; +} + +.list-compare-chip-group { + display: flex; + gap: 6px; + justify-content: center; + flex-wrap: wrap; +} + +.list-compare-chip { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 4px 8px; + border-radius: 999px; + background: var(--color-surface-strong); + color: var(--color-text); + font-size: 10px; + font-weight: 800; +} + +.list-compare-status { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 54px; + padding: 4px 8px; + border-radius: 999px; + font-size: 10px; + font-weight: 900; +} + +.list-compare-status-added { + background: rgba(47, 153, 115, 0.14); + color: #236847; +} + +.list-compare-status-updated { + background: var(--color-accent-soft); + color: var(--color-accent-strong); +} + +.list-compare-status-removed { + background: rgba(198, 71, 56, 0.14); + color: #a33427; +} + +.fab-container { + position: fixed; + bottom: 30px; + right: 30px; + display: flex; + flex-direction: column-reverse; + align-items: center; + gap: 15px; + z-index: 5000; +} + +.fab-main { + width: 60px; + height: 60px; + background: var(--color-header); + color: white; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 30px; + box-shadow: 0 10px 25px rgba(47, 153, 115, 0.28); + cursor: pointer; + transition: all 0.3s; + border: none; +} + +.fab-menu { + display: flex; + flex-direction: column-reverse; + align-items: center; + gap: 10px; + opacity: 0; + visibility: hidden; + transform: translateY(20px); + transition: all 0.3s; +} + +.fab-container.active .fab-menu { + opacity: 1; + visibility: visible; + transform: translateY(0); +} + +.fab-container.active .fab-main { + transform: rotate(45deg); + background: var(--color-header-soft); +} + +.fab-sub { + width: 50px; + height: 50px; + background: white; + color: var(--color-header); + border: 2px solid var(--color-header); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1); + cursor: pointer; + transition: all 0.2s; + position: relative; +} + +.fab-sub:hover { + background: var(--color-header); + color: white; + transform: scale(1.1); +} + +.fab-sub::after { + content: attr(data-label); + position: absolute; + right: 65px; + background: #1e293b; + color: white; + padding: 4px 10px; + border-radius: 6px; + font-size: 11px; + font-weight: 800; + white-space: nowrap; + opacity: 0; + transition: 0.2s; + pointer-events: none; +} + +.fab-sub:hover::after { + opacity: 1; + right: 75px; +} + +.clickable-title { + cursor: pointer; + transition: color 0.2s; + position: relative; +} + +.clickable-title:hover { + color: #818cf8 !important; + text-decoration: underline; +} + +.search-section { + position: fixed; + top: 14px; + left: 18px; + background: var(--color-surface-soft); + border-radius: var(--radius-md); + padding: 8px 12px; + box-shadow: var(--shadow-soft); + border: 1px solid var(--color-border-soft); + z-index: 1010; + display: flex; + align-items: center; + gap: 10px; + backdrop-filter: blur(8px); + transition: all 0.3s; +} + +.search-input { + border: none; + outline: none; + background: transparent; + font-size: 12px; + font-weight: 700; + color: var(--color-text); + width: 150px; +} + +.search-icon { + color: var(--color-text-muted); + display: flex; + align-items: center; + transform: scale(0.9); +} + +.stats-section { + position: fixed; + top: 14px; + right: 18px; + width: 332px; + background: var(--color-surface-soft); + border-radius: var(--radius-md); + padding: 10px 12px; + box-shadow: var(--shadow-soft); + border: 1px solid var(--color-border-soft); + z-index: 1010; + backdrop-filter: blur(8px); + transition: all 0.3s; +} + +.stats-title { + font-size: 11px; + line-height: 1.1; +} + +.stats-table { + width: 100%; + border-collapse: collapse; + font-size: 10px; + border-radius: 8px; + overflow: hidden; + border-style: hidden; + box-shadow: 0 0 0 1px var(--color-border); +} + +.stats-table th { + background: var(--color-surface-soft); + color: var(--color-text-muted); + font-weight: 800; + padding: 6px 4px; + border: 1px solid var(--color-border); + text-align: center; +} + +.stats-table td { + padding: 6px 4px; + border: 1px solid var(--color-border); + text-align: center; + font-weight: 700; + color: var(--color-text); +} + +.stats-table .row-label { + background: var(--color-surface-soft); + color: var(--color-text-soft); + font-weight: 800; + width: 92px; +} + +.stats-company-label { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.stats-company-dot { + width: 8px; + height: 8px; + border-radius: 999px; + display: inline-block; + background: var(--color-text-muted); + box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.7); +} + +.stats-company-dot.co-삼안 { + background: #d68a3a; +} + +.stats-company-dot.co-한맥 { + background: #c64738; +} + +.stats-company-dot.co-피티씨 { + background: #8b6fb3; +} + +.stats-company-dot.co-바론 { + background: #2f9973; +} + +.stats-table .total-cell { + background: var(--color-accent-soft); + color: var(--color-accent-strong); + font-weight: 900; +} + +.sum-row { + background: #f1f5f9; +} + +.sum-row td { + font-weight: 900 !important; + color: var(--color-text) !important; +} + +@keyframes target-pulse { + 0% { + box-shadow: 0 0 0 0 rgba(214, 138, 58, 0.38); + transform: scale(1); + } + + 50% { + box-shadow: 0 0 0 10px rgba(214, 138, 58, 0); + transform: scale(1.05); + } + + 100% { + box-shadow: 0 0 0 0 rgba(214, 138, 58, 0); + transform: scale(1); + } +} + +.search-target { + animation: target-pulse 1.5s ease-in-out 2; + position: relative; + z-index: 1000 !important; + border-color: var(--color-accent) !important; +} + +.admin-mode-btn { + position: fixed; + bottom: 37.5px; + right: 105px; + z-index: 5001; + width: 45px; + height: 45px; + background: var(--color-surface-soft); + backdrop-filter: blur(4px); + border: 1px solid var(--color-border-soft); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + box-shadow: var(--shadow-soft); + transition: all 0.3s; + cursor: pointer; +} + +.admin-mode-btn::after { + content: attr(data-label); + position: absolute; + bottom: 55px; + right: 0; + background: rgba(34, 46, 39, 0.88); + color: white; + padding: 4px 8px; + border-radius: 4px; + font-size: 10px; + white-space: nowrap; + opacity: 0; + visibility: hidden; + transition: all 0.2s; + pointer-events: none; +} + +.admin-mode-btn:hover::after { + opacity: 1; + visibility: visible; +} + +.admin-mode-btn.is-admin { + background: var(--color-header); + border-color: var(--color-header); + box-shadow: 0 4px 15px rgba(47, 153, 115, 0.24); +} + +.admin-mode-btn:hover { + transform: translateY(-2px); + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.1); +} + +.dept-tabs-container { + display: flex; + gap: 8px; + margin-top: 10px; + padding: 2px 0; + overflow-x: auto; + scrollbar-width: none; +} + +.dept-tabs-container::-webkit-scrollbar { + display: none; +} + +.dept-tab { + padding: 5px 11px; + background: white; + border: 1px solid var(--color-border); + border-radius: 20px; + font-size: 10px; + font-weight: 800; + color: var(--color-text-muted); + cursor: pointer; + white-space: nowrap; + transition: all 0.2s; +} + +.dept-tab:hover { + border-color: var(--color-border-soft); + background: var(--color-surface-soft); +} + +.dept-tab.active { + background: var(--color-accent); + color: white; + border-color: var(--color-accent); + box-shadow: 0 4px 10px rgba(214, 138, 58, 0.2); +} + +.member-inline-info-card { + border-color: var(--color-border); + background: var(--color-surface-soft); +} + +.member-inline-info-card label { + color: var(--color-text-muted); +} + +.member-inline-info-card strong { + color: var(--color-text); +} + +.member-photo-upload-card { + border-color: var(--color-border); + background: var(--color-surface-soft); +} + +.list-compare-chip { + background: var(--color-surface-strong); + color: var(--color-text); +} + +.list-compare-status-added { + background: rgba(47, 153, 115, 0.14); + color: #236847; +} + +.list-compare-status-updated { + background: var(--color-accent-soft); + color: var(--color-accent-strong); +} + +.list-compare-status-removed { + background: rgba(198, 71, 56, 0.14); + color: #a33427; +} + +.fab-main { + background: var(--color-header); + box-shadow: 0 10px 25px rgba(47, 153, 115, 0.28); +} + +.fab-container.active .fab-main { + background: var(--color-header-soft); +} + +.fab-sub { + color: var(--color-header); + border-color: var(--color-header); +} + +.fab-sub:hover { + background: var(--color-header); +} + +.fab-sub::after, +.admin-mode-btn::after { + background: rgba(34, 46, 39, 0.88); +} + +.stats-table { + box-shadow: 0 0 0 1px var(--color-border); +} + +.stats-table th, +.stats-table td { + border-color: var(--color-border); +} + +.stats-table th, +.stats-table .row-label { + background: var(--color-surface-soft); +} + +.stats-company-dot { + background: var(--color-text-muted); +} + +.stats-company-dot.co-삼안 { + background: #d68a3a; +} + +.stats-company-dot.co-한맥 { + background: #c64738; +} + +.stats-company-dot.co-피티씨 { + background: #8b6fb3; +} + +.stats-company-dot.co-바론 { + background: #2f9973; +} + +.stats-table .total-cell { + background: var(--color-accent-soft); + color: var(--color-accent-strong); +} + +.sum-row { + background: var(--color-surface-strong); +} + +@keyframes target-pulse { + 0% { + box-shadow: 0 0 0 0 rgba(214, 138, 58, 0.38); + transform: scale(1); + } + 50% { + box-shadow: 0 0 0 10px rgba(214, 138, 58, 0); + transform: scale(1.05); + } + 100% { + box-shadow: 0 0 0 0 rgba(214, 138, 58, 0); + transform: scale(1); + } +} + +.search-target { + border-color: var(--color-accent) !important; +} + +.admin-mode-btn.is-admin { + background: var(--color-header); + border-color: var(--color-header); + box-shadow: 0 4px 15px rgba(47, 153, 115, 0.24); +} + +.dept-tab { + border-color: var(--color-border); + color: var(--color-text-muted); +} + +.dept-tab:hover { + border-color: var(--color-border-soft); + background: var(--color-surface-soft); +} + +.dept-tab.active { + background: var(--color-accent); + border-color: var(--color-accent); + box-shadow: 0 4px 10px rgba(214, 138, 58, 0.2); +} diff --git a/frontend/apps/organization/assets/organization.js b/frontend/apps/organization/assets/organization.js new file mode 100644 index 0000000..179251b --- /dev/null +++ b/frontend/apps/organization/assets/organization.js @@ -0,0 +1,1979 @@ +let members = []; +let isAdmin = false; +let selectedDept = '전체'; +let editingMembers = []; +let collapsedUnits = new Set(); +let isListMode = false; +let isListDetailModal = false; +let emptyStateMessage = '서버에 조직 데이터가 없습니다. 상단의 업로드 버튼으로 초기 데이터를 넣어주세요.'; +let photoPreviewObjectUrl = null; +let seatMapLayoutCache = null; +let activeAsOfDate = ''; +let isHistoricalSnapshot = false; +const listViewState = { + mode: 'current', + snapshotDate: '', + compareFromDate: '', + compareToDate: '', + snapshotMembers: [], + compareItems: [], +}; +const seatMapOfficeKeys = ['technical-development-center', 'hanmac-building-6f', 'hanmac-building-7f']; + +const levelOrder = ['부서', '그룹', '디비전', '팀', '셀']; +const dropdownFields = ['소속회사', '직급', '직책', ...levelOrder]; + +function pad(value) { + return String(value).padStart(2, '0'); +} + +function updateTimestamp() { + const now = new Date(); + const dateStr = `${now.getFullYear()}.${pad(now.getMonth() + 1)}.${pad(now.getDate())}`; + const timeStr = `${pad(now.getHours())}:${pad(now.getMinutes())}`; + document.getElementById('last-updated').innerText = `WSL 서버 기준 동기화: ${dateStr} ${timeStr}`; +} + +function rebuildMemberPath(member) { + member._path = levelOrder + .map((level) => ({ level, name: member[level] || '' })) + .filter((item) => item.name !== ''); + return member; +} + +function cloneMembers(items) { + return JSON.parse(JSON.stringify(items)); +} + +function isRetiredLegacyMember(member) { + const workStatus = String(member?.['근무상태'] || '').trim(); + return workStatus === '퇴직'; +} + +function getVisibleLegacyMembers(items) { + return (items || []).filter((member) => !isRetiredLegacyMember(member)); +} + +function getPhotoPlaceholder(name = '') { + return `https://via.placeholder.com/160?text=${encodeURIComponent(name || 'Profile')}`; +} + +function escapeHtml(value) { + return String(value ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +function resetPhotoPreviewObjectUrl() { + if (photoPreviewObjectUrl) { + URL.revokeObjectURL(photoPreviewObjectUrl); + photoPreviewObjectUrl = null; + } +} + +function toLegacyMember(item) { + return rebuildMemberPath({ + _id: String(item.id), + id: item.id, + 이름: item.name || '', + 사번: item.employee_id || '', + 소속회사: item.company || '', + 직급: item.rank || '', + 직책: item.role || '', + 부서: item.department || '', + 그룹: item.grp || '', + 디비전: item.division || '', + 팀: item.team || '', + 셀: item.cell || '', + 근무상태: item.work_status || '', + 근무시간: item.work_time || '', + 전화번호: item.phone || '', + 이메일: item.email || '', + 자리위치: item.seat_label || '', + 사진: item.photo_url || '', + sort_order: item.sort_order ?? 0, + }); +} + +function toApiMember(member, sortOrder) { + return { + name: member['이름'] || '', + employee_id: member['사번'] || '', + company: member['소속회사'] || '', + rank: member['직급'] || '', + role: member['직책'] || '', + department: member['부서'] || '', + grp: member['그룹'] || '', + division: member['디비전'] || '', + team: member['팀'] || '', + cell: member['셀'] || '', + work_status: member['근무상태'] || '', + work_time: member['근무시간'] || '', + phone: member['전화번호'] || '', + email: member['이메일'] || '', + seat_label: member['자리위치'] || '', + photo_url: member['사진'] || '', + sort_order: sortOrder, + }; +} + +async function apiFetch(url, options = {}) { + const config = { ...options }; + config.headers = { ...(options.headers || {}) }; + if (config.body && !(config.body instanceof FormData) && !config.headers['Content-Type']) { + config.headers['Content-Type'] = 'application/json'; + } + + const response = await fetch(url, config); + const contentType = response.headers.get('content-type') || ''; + const payload = contentType.includes('application/json') ? await response.json() : null; + if (!response.ok) { + const detail = payload?.detail || '요청 처리에 실패했습니다.'; + throw new Error(detail); + } + return payload; +} + +function withAsOf(url) { + if (!activeAsOfDate) { + return url; + } + const separator = url.includes('?') ? '&' : '?'; + return `${url}${separator}as_of=${encodeURIComponent(activeAsOfDate)}`; +} + +function getDefaultHistoryDate() { + if (activeAsOfDate) { + return activeAsOfDate; + } + const now = new Date(); + return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`; +} + +async function uploadProfilePhoto(file, memberName) { + const formData = new FormData(); + formData.append('file', file); + formData.append('member_name', memberName || ''); + const payload = await apiFetch('/api/uploads/profile-photo', { + method: 'POST', + body: formData, + }); + return payload.url || ''; +} + +function setMembers(items) { + members = getVisibleLegacyMembers(items.map(toLegacyMember)); + if (selectedDept !== '전체' && !members.some((member) => member['부서'] === selectedDept)) { + selectedDept = '전체'; + } + updateTimestamp(); +} + +async function loadMembers(message) { + if (message) { + emptyStateMessage = message; + } + const payload = await apiFetch(withAsOf('/api/members')); + setMembers(payload.items || []); + if (!members.length) { + emptyStateMessage = '서버에 조직 데이터가 없습니다. 상단의 업로드 버튼으로 초기 데이터를 넣어주세요.'; + } + render(); +} + +async function loadSeatMapLayouts(force = false) { + if (seatMapLayoutCache && !force) { + return seatMapLayoutCache; + } + try { + const layouts = (await Promise.all(seatMapOfficeKeys.map(async (officeKey) => { + try { + const activePayload = await apiFetch(`/api/seat-maps/active?office_key=${encodeURIComponent(officeKey)}`); + const seatMap = activePayload?.item; + if (!seatMap?.id) { + return null; + } + return await apiFetch(withAsOf(`/api/seat-maps/${seatMap.id}/layout`)); + } catch { + return null; + } + }))).filter(Boolean); + seatMapLayoutCache = layouts; + return layouts; + } catch { + seatMapLayoutCache = null; + return []; + } +} + +function handleSeatMapLayoutUpdated() { + seatMapLayoutCache = null; + loadMembers().catch(() => { }); +} + +function getMemberSeatInfo(layouts, memberId) { + if (!Array.isArray(layouts) || !memberId) { + return null; + } + for (const layout of layouts) { + const placement = (layout.placements || []).find((item) => Number(item.member_id) === Number(memberId)); + if (!placement) { + continue; + } + const slot = (layout.slots || []).find((item) => Number(item.id) === Number(placement.seat_slot_id)); + return { + layout, + seatMapId: layout.seat_map?.id || null, + seatMapName: layout.seat_map?.name || '자리배치도', + seatLabel: placement.seat_label || slot?.label || '', + slotKey: slot?.slot_key || '', + assigned: true, + }; + } + return null; +} + +function buildSeatAssignments(layout) { + if (!layout || !Array.isArray(layout.placements) || !Array.isArray(layout.members) || !Array.isArray(layout.slots)) { + return []; + } + return layout.placements.map((placement) => { + const slot = layout.slots.find((item) => Number(item.id) === Number(placement.seat_slot_id)); + const memberItem = layout.members.find((item) => Number(item.id) === Number(placement.member_id)); + if (!slot || !memberItem) return null; + return { + key: String(slot.slot_key || ''), + member_id: Number(memberItem.id), + name: memberItem.name || '-', + rank: memberItem.rank || '-', + }; + }).filter(Boolean); +} + +function applySeatPreviewFrameState(frame, seatInfo, layout) { + if (!frame?.contentWindow || !seatInfo?.slotKey) { + return; + } + const postState = () => { + if (!frame.contentWindow) { + return; + } + frame.contentWindow.postMessage({ + type: 'seatmap-set-assignments', + items: buildSeatAssignments(layout), + }, window.location.origin); + frame.contentWindow.postMessage({ type: 'seatmap-set-mode', mode: 'compact' }, window.location.origin); + frame.contentWindow.postMessage({ type: 'seatmap-focus-chair', key: seatInfo.slotKey, padding: 2600 }, window.location.origin); + }; + postState(); + setTimeout(postState, 120); +} + +async function syncMembers(nextMembers) { + const payload = await apiFetch('/api/members/bulk-sync', { + method: 'PUT', + body: JSON.stringify({ + items: nextMembers.map((member, index) => toApiMember(member, index)), + }), + }); + setMembers(payload.items || []); + render(); +} + +function jsString(value) { + return String(value).replaceAll('\\', '\\\\').replaceAll("'", "\\'"); +} + +function updateDeptTabs(deptList) { + const tabsContainer = document.getElementById('dept-tabs'); + tabsContainer.innerHTML = deptList.map((dept) => ` +
${dept}
+ `).join(''); +} + +function selectDept(dept) { + selectedDept = dept; + render(); +} + +function calculateTotalCount(node) { + let count = node.members.length; + if (node.children) { + node.children.forEach((child) => { + count += calculateTotalCount(child); + }); + } + node.totalCount = count; + return count; +} + +function buildHierarchy(data, depth) { + if (!data || data.length === 0) { + return []; + } + + const orderedGroups = []; + const groupMap = {}; + + data.forEach((member) => { + const currentStep = member._path[depth]; + if (!currentStep) { + return; + } + + const currentName = currentStep.name; + if (!groupMap[currentName]) { + groupMap[currentName] = { + name: currentName, + level: currentStep.level, + members: [], + subData: [], + }; + orderedGroups.push(groupMap[currentName]); + } + + if (member._path.length === depth + 1) { + groupMap[currentName].members.push(member); + } else { + groupMap[currentName].subData.push(member); + } + }); + + return orderedGroups.map((group) => ({ + ...group, + children: buildHierarchy(group.subData, depth + 1), + })); +} + +function createMemberCard(member, isFullWidth = false) { + const card = document.createElement('div'); + card.id = `card-${member._id}`; + card.className = `member-card co-${member['소속회사'] || 'default'} transition-all duration-200 mb-1 last:mb-0`; + if (isFullWidth) { + card.classList.add('full-width'); + } + + card.onclick = (event) => { + event.stopPropagation(); + openModal(member._id); + }; + + if (isAdmin) { + card.setAttribute('draggable', 'true'); + card.ondragstart = (event) => handleDragStart(event, 'member', member._id); + card.ondragend = (event) => handleDragEnd(event); + card.ondragover = (event) => handleDragOverMember(event); + card.ondragleave = (event) => handleDragLeaveMember(event); + card.ondrop = (event) => handleDropMember(event, member._id); + } + + const isLeave = member['근무상태'] === '휴직'; + const roleDisplay = isLeave + ? '휴직' + : ((member['직책'] && member['직책'] !== '팀원') ? `${member['직책']}` : ''); + + card.innerHTML = `
${member['이름']}${roleDisplay}${member['직급'] || ''}
`; + return card; +} + +function getAllSubMembers(node) { + let memberList = [...node.members]; + node.children.forEach((child) => { + memberList = memberList.concat(getAllSubMembers(child)); + }); + return memberList; +} + +function collectTeamItems(teamNode) { + let items = [...teamNode.members]; + teamNode.children.forEach((cell) => { + items.push({ isCellHeader: true, name: cell.name }); + items = items.concat(getAllSubMembers(cell)); + }); + return items; +} + +function createNodeDOM(node, parentId) { + const nodeItem = document.createElement('div'); + nodeItem.className = `node-item${node.children.length || node.members.length ? ' has-children' : ''}`; + + const myId = `node-${encodeURIComponent(`${node.level}_${node.name}`)}`; + const box = document.createElement('div'); + box.className = 'box transition-all duration-200'; + box.id = myId; + box.setAttribute('data-level', node.level); + if (parentId) { + box.setAttribute('data-parent', parentId); + } + + if (isAdmin) { + box.ondragover = (event) => handleDragOver(event); + box.ondragleave = (event) => handleDragLeave(event); + box.ondrop = (event) => handleDrop(event, node.level, node.name); + } + + box.classList.add(`box-level-${node.level}`); + if (node.level === '팀') { + box.classList.add('box-team'); + } + + const displayTitle = `${node.name} (${node.totalCount || 0})`; + const nodeTitleClass = isAdmin ? 'clickable-title' : ''; + box.innerHTML = `
${displayTitle}
`; + + const memberGrid = document.createElement('div'); + const isHighLevel = node.level !== '팀' && node.level !== '셀'; + memberGrid.className = isHighLevel ? 'flex flex-col w-full' : 'member-grid'; + + if (node.level === '팀') { + const teamItems = collectTeamItems(node); + const leaderIdx = teamItems.findIndex((item) => item['직책'] === '팀장'); + const finalItems = []; + if (leaderIdx !== -1) { + finalItems.push(teamItems.splice(leaderIdx, 1)[0]); + } else if (teamItems.length > 0) { + finalItems.push(teamItems.shift()); + } + + while (teamItems.length > 0) { + const nextIndex = finalItems.length; + if (nextIndex > 0 && nextIndex % 10 === 0) { + finalItems.push({ isSpacer: true }); + continue; + } + if (nextIndex % 10 === 9 && teamItems[0].isCellHeader) { + finalItems.push({ isSpacer: true }); + continue; + } + finalItems.push(teamItems.shift()); + } + + finalItems.forEach((item) => { + if (item.isSpacer) { + const spacer = document.createElement('div'); + spacer.className = 'spacer-box'; + memberGrid.appendChild(spacer); + } else if (item.isCellHeader) { + const label = document.createElement('div'); + label.className = `cell-label${isAdmin ? ' clickable-title' : ''}`; + label.innerText = item.name; + if (isAdmin) { + label.onclick = () => openOrgEditModal('셀', item.name); + } + memberGrid.appendChild(label); + } else { + memberGrid.appendChild(createMemberCard(item)); + } + }); + } else { + const isFullWidth = node.level !== '팀' && node.level !== '셀'; + node.members.forEach((member) => memberGrid.appendChild(createMemberCard(member, isFullWidth))); + } + + box.appendChild(memberGrid); + nodeItem.appendChild(box); + + if (node.level !== '팀' && node.children && node.children.length > 0) { + const childrenWrapper = document.createElement('div'); + childrenWrapper.className = 'node-group'; + node.children.forEach((child) => childrenWrapper.appendChild(createNodeDOM(child, myId))); + nodeItem.appendChild(childrenWrapper); + } + + 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() { + const container = document.getElementById('tree-root'); + const svg = document.getElementById('svg-canvas'); + if (!svg || !container) { + return; + } + + const containerRect = container.getBoundingClientRect(); + let paths = ''; + + container.querySelectorAll('[data-parent]').forEach((box) => { + const parentId = box.getAttribute('data-parent'); + const parentBox = document.getElementById(parentId); + if (!parentBox) { + return; + } + + const parentLevel = parentBox.getAttribute('data-level'); + const childLevel = box.getAttribute('data-level'); + if (parentLevel === '부서' && childLevel === '그룹') { + return; + } + + const parentRect = parentBox.getBoundingClientRect(); + const childRect = box.getBoundingClientRect(); + const startX = parentRect.left + parentRect.width / 2 - containerRect.left; + const startY = parentRect.bottom - containerRect.top; + const endX = childRect.left + childRect.width / 2 - containerRect.left; + const endY = childRect.top - containerRect.top; + const curveY = endY - 20; + + paths += ``; + }); + + svg.innerHTML = paths; +} + +function updateStatsTable() { + if (!members.length) { + document.getElementById('stats-table-container').innerHTML = ''; + document.getElementById('total-count-badge').innerText = '0명'; + return; + } + + const companies = ['한맥', '삼안', '피티씨', '바론']; + const rankGroups = { + 경영진: ['사장', '부사장'], + 수석: ['수석'], + 책임: ['책임'], + 선임: ['선임'], + 연구: ['연구'], + }; + + const columns = Object.keys(rankGroups); + const stats = {}; + const companyLabelHtml = (company) => ` + + + ${company} + + `; + + companies.forEach((company) => { + stats[company] = {}; + columns.forEach((column) => { + stats[company][column] = 0; + }); + stats[company]._total = 0; + }); + + const targetMembers = selectedDept === '전체' + ? members + : members.filter((member) => member['부서'] === selectedDept); + + targetMembers.forEach((member) => { + const company = companies.find((item) => (member['소속회사'] || '').includes(item)); + if (!company) { + return; + } + + const rank = member['직급'] || ''; + for (const [groupName, keywords] of Object.entries(rankGroups)) { + if (keywords.some((keyword) => rank.includes(keyword))) { + stats[company][groupName] += 1; + break; + } + } + stats[company]._total += 1; + }); + + let html = `${columns.map((column) => ``).join('')}`; + const colSums = {}; + columns.forEach((column) => { + colSums[column] = 0; + }); + let grandTotal = 0; + + companies.forEach((company) => { + html += `${columns.map((column) => { + colSums[column] += stats[company][column]; + return ``; + }).join('')}`; + grandTotal += stats[company]._total; + }); + + html += `${columns.map((column) => ``).join('')}
구분${column}합계
${companyLabelHtml(company)}${stats[company][column] || '-'}${stats[company]._total}
전체 합계${colSums[column]}${grandTotal}
`; + + document.getElementById('stats-table-container').innerHTML = html; + document.getElementById('total-count-badge').innerText = `${grandTotal}명`; +} + +function render() { + const container = document.getElementById('tree-root'); + container.innerHTML = ''; + + if (!members.length) { + container.innerHTML += `
${emptyStateMessage}
`; + updateStatsTable(); + return; + } + + const allDepts = Array.from(new Set(members.map((member) => member['부서']).filter(Boolean))).sort(); + updateDeptTabs(['전체', ...allDepts]); + + const deptNames = selectedDept === '전체' ? allDepts : [selectedDept]; + deptNames.forEach((deptName) => { + const deptData = members.filter((member) => member['부서'] === deptName); + const hierarchy = buildHierarchy(deptData, 0); + const deptNode = hierarchy[0] || null; + if (deptNode) { + calculateTotalCount(deptNode); + } + + const deptSection = document.createElement('div'); + deptSection.className = 'dept-section'; + const deptId = `node-${encodeURIComponent(`부서_${deptName}`)}`; + const hasMembers = Boolean(deptNode && deptNode.members && deptNode.members.length > 0); + const totalCount = deptNode ? deptNode.totalCount : deptData.length; + + const deptBox = document.createElement('div'); + deptBox.id = deptId; + deptBox.className = 'dept-box'; + deptBox.setAttribute('data-level', '부서'); + if (isAdmin) { + deptBox.ondragover = (event) => handleDragOver(event); + deptBox.ondragleave = (event) => handleDragLeave(event); + deptBox.ondrop = (event) => handleDrop(event, '부서', deptName); + } + deptBox.innerHTML = `
${deptName} (${totalCount})
`; + + if (hasMembers) { + const memberGrid = document.createElement('div'); + memberGrid.className = 'flex flex-col w-full'; + memberGrid.style.padding = '0 15px 15px 15px'; + deptNode.members.forEach((member) => memberGrid.appendChild(createMemberCard(member, true))); + deptBox.appendChild(memberGrid); + } + + deptSection.appendChild(deptBox); + const groupContainer = document.createElement('div'); + groupContainer.className = 'node-group'; + if (deptNode && deptNode.children) { + deptNode.children.forEach((child) => groupContainer.appendChild(createNodeDOM(child, deptId))); + } + deptSection.appendChild(groupContainer); + container.appendChild(deptSection); + }); + + updateStatsTable(); + setTimeout(drawLines, 50); +} + +function toggleAdminMode(checked) { + if (checked && isHistoricalSnapshot) { + alert('월말 히스토리 조회 중에는 수정할 수 없습니다. 최신 월로 돌아간 뒤 수정해주세요.'); + return; + } + isAdmin = checked; + const button = document.getElementById('admin-mode-btn'); + if (isAdmin) { + button.classList.add('is-admin'); + button.innerText = '🔓'; + button.setAttribute('data-label', '관리자 모드: ON'); + } else { + button.classList.remove('is-admin'); + button.innerText = '🔐'; + button.setAttribute('data-label', '관리자 모드: OFF'); + } + updateFabMenu(); + render(); +} + +function toggleFab(event) { + if (event) { + event.stopPropagation(); + } + document.getElementById('fab-container').classList.toggle('active'); +} + +function updateFabMenu() { + const menu = document.getElementById('fab-menu'); + let html = ''; + html += ''; + html += ''; + if (isAdmin && !isHistoricalSnapshot) { + html += ''; + html += ''; + html += ''; + } + menu.innerHTML = html; +} + +async function openHistoryCompareModal(fromDate, toDate) { + openListViewModal(); + const fromInput = document.getElementById('list-compare-from'); + const toInput = document.getElementById('list-compare-to'); + if (fromInput) { + fromInput.value = fromDate || ''; + } + if (toInput) { + toInput.value = toDate || ''; + } + await loadCompareListView(); +} + +function openSeatMapView(event) { + event.stopPropagation(); + document.getElementById('fab-container').classList.remove('active'); + if (window.parent && window.parent !== window) { + window.parent.postMessage({ type: 'open-seatmap', readOnly: !isAdmin }, '*'); + } +} + +window.addEventListener('message', (event) => { + const data = event.data; + if (!data || typeof data !== 'object') { + return; + } + if (data.type === 'date-range') { + activeAsOfDate = String(data.endDate || '').slice(0, 10); + return; + } + if (data.type === 'organization-history-view') { + activeAsOfDate = String(data.asOfDate || '').slice(0, 10); + isHistoricalSnapshot = Boolean(data.historical); + if (isHistoricalSnapshot && isAdmin) { + toggleAdminMode(false); + } else { + updateFabMenu(); + render(); + } + seatMapLayoutCache = null; + loadMembers().catch(() => { }); + return; + } + if (data.type === 'open-history-compare') { + openHistoryCompareModal(String(data.fromDate || ''), String(data.toDate || '')).catch((error) => { + alert(error.message || '변경 비교를 불러오지 못했습니다.'); + }); + return; + } + if (data.type === 'seatmap-layout-updated') { + handleSeatMapLayoutUpdated(); + } +}); + +function triggerUpload(event) { + if (event) { + event.stopPropagation(); + } + document.getElementById('upload-excel').click(); +} + +function printA3() { + window.print(); +} + +function toggleStats() { + const container = document.getElementById('stats-table-container'); + const icon = document.getElementById('stats-toggle-icon'); + const area = document.getElementById('stats-area'); + if (container.style.display === 'none') { + container.style.display = 'block'; + icon.style.transform = 'rotate(0deg)'; + area.style.padding = '15px'; + } else { + container.style.display = 'none'; + icon.style.transform = 'rotate(-90deg)'; + area.style.padding = '10px 15px'; + } +} + +function handleSearch(value) { + const query = value.trim().toLowerCase(); + if (!query) { + return; + } + + document.querySelectorAll('.search-target').forEach((element) => element.classList.remove('search-target')); + let targetEl = null; + + const memberMatch = members.find((member) => (member['이름'] || '').toLowerCase().includes(query)); + if (memberMatch) { + targetEl = document.getElementById(`card-${memberMatch._id}`); + } + + if (!targetEl) { + for (const level of levelOrder) { + const orgName = Array.from(new Set(members.map((member) => member[level]).filter(Boolean))) + .find((name) => name.toLowerCase().includes(query)); + if (orgName) { + targetEl = document.getElementById(`node-${encodeURIComponent(`${level}_${orgName}`)}`); + } + if (targetEl) { + break; + } + } + } + + if (!targetEl) { + alert('검색 결과를 찾을 수 없습니다.'); + return; + } + + targetEl.scrollIntoView({ behavior: 'smooth', block: 'center' }); + targetEl.classList.add('search-target'); +} + +async function importMemberFile(file) { + const formData = new FormData(); + formData.append('file', file); + const payload = await apiFetch('/api/members/import', { + method: 'POST', + body: formData, + }); + emptyStateMessage = '서버에 조직 데이터가 없습니다. 상단의 업로드 버튼으로 초기 데이터를 넣어주세요.'; + setMembers(payload.items || []); + render(); +} + +function openAddModal(event) { + if (event) { + event.stopPropagation(); + } + 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 = ` +
+
+
+ +
+ +
+ + +
+ +
+ + ~ + + +
+
+
+ + +
+
+
+
+ `; + modal.style.display = 'flex'; +} + +function returnToListViewModal() { + if (!isListMode) { + return; + } + isListDetailModal = false; + renderListViewShell(listViewState.snapshotDate || getDefaultHistoryDate()); + renderListViewModalContent(); +} + +function updateParentList() { + const type = document.getElementById('new-unit-type').value; + const parentSelect = document.getElementById('new-unit-parent'); + const typeIdx = levelOrder.indexOf(type); + const parentType = levelOrder[typeIdx - 1]; + const parents = Array.from(new Set(members.map((member) => member[parentType]).filter(Boolean))).sort(); + parentSelect.innerHTML = '' + parents.map((parent) => ``).join(''); +} + +function openUnitAddModal(event) { + if (event) { + event.stopPropagation(); + } + + if (!members.length) { + alert('먼저 조직 데이터를 업로드해주세요.'); + return; + } + + const modal = document.getElementById('modal'); + modal.querySelector('.modal-content').classList.remove('wide'); + document.getElementById('modal-title').innerText = '신규 조직 단위 추가'; + const fieldsArea = document.getElementById('modal-fields'); + fieldsArea.className = 'grid grid-cols-2 gap-x-8 gap-y-5'; + fieldsArea.style.maxHeight = 'none'; + fieldsArea.innerHTML = ` +
+ + +
+
+ + +
+
+ + +
+ `; + updateParentList(); + document.getElementById('modal-footer-area').innerHTML = ` + + `; + modal.style.display = 'flex'; +} + +async function saveNewUnit() { + const type = document.getElementById('new-unit-type').value; + const parentName = document.getElementById('new-unit-parent').value; + const name = document.getElementById('new-unit-name').value.trim(); + + if (!name) { + alert('이름을 입력해주세요.'); + return; + } + + const typeIdx = levelOrder.indexOf(type); + const parentType = levelOrder[typeIdx - 1]; + const template = (parentName && parentType) + ? (members.find((member) => member[parentType] === parentName) || members[0]) + : members[0]; + + const newMember = { + ...cloneMembers([template])[0], + _id: `virtual-${Date.now()}`, + 이름: '공석(신규)', + 직급: '', + 직책: '', + 소속회사: template?.소속회사 || '', + 전화번호: '', + 이메일: '', + 자리위치: '', + 사진: '', + 근무상태: '근무', + 근무시간: '09~18', + }; + + if (!parentName) { + for (let index = 1; index < typeIdx; index += 1) { + newMember[levelOrder[index]] = ''; + } + } else { + newMember[parentType] = parentName; + } + + newMember[type] = name; + for (let index = typeIdx + 1; index < levelOrder.length; index += 1) { + newMember[levelOrder[index]] = ''; + } + + rebuildMemberPath(newMember); + await syncMembers([...members, newMember]); + closeModal(); +} + +function openOrgEditModal(level, oldName) { + const modal = document.getElementById('modal'); + modal.querySelector('.modal-content').classList.remove('wide'); + document.getElementById('modal-title').innerText = `${level} 이름 수정`; + const fieldsArea = document.getElementById('modal-fields'); + fieldsArea.className = 'grid grid-cols-2 gap-x-8 gap-y-5'; + fieldsArea.style.maxHeight = 'none'; + fieldsArea.innerHTML = ` +
+ + +
+ `; + document.getElementById('modal-footer-area').innerHTML = ` + + + `; + modal.style.display = 'flex'; +} + +async function deleteOrg(level, name) { + if (!confirm(`'${name}' ${level}과 소속된 모든 인원 정보가 삭제됩니다. 정말 삭제하시겠습니까?`)) { + return; + } + const nextMembers = members.filter((member) => member[level] !== name); + await syncMembers(nextMembers); + closeModal(); +} + +async function saveOrgName(level, oldName) { + const newName = document.getElementById('new-org-name').value.trim(); + if (!newName) { + alert('이름을 입력해주세요.'); + return; + } + + if (newName === oldName) { + closeModal(); + return; + } + + const nextMembers = cloneMembers(members); + nextMembers.forEach((member) => { + if (member[level] === oldName) { + member[level] = newName; + rebuildMemberPath(member); + } + }); + await syncMembers(nextMembers); + closeModal(); +} + +function toggleManualInput(field) { + document.getElementById(`manual-${field}`).classList.toggle('hidden', document.getElementById(`sel-${field}`).value !== '__NEW__'); +} + +function toggleFlexibleTime(value) { + document.getElementById('flexible-time-area').classList.toggle('hidden', value !== '유연근무제'); +} + +function updatePhotoPreview(src, fallbackName) { + const preview = document.getElementById('m-photo-preview'); + if (!preview) { + return; + } + preview.src = src || getPhotoPlaceholder(fallbackName); +} + +function syncPhotoPreviewFromUrl() { + const name = document.getElementById('m-name')?.value?.trim() || ''; + const url = document.getElementById('m-photo-hidden')?.value?.trim() || ''; + updatePhotoPreview(url, name); +} + +function handlePhotoFileChange(event) { + const file = event.target.files?.[0]; + const fileName = document.getElementById('m-photo-file-name'); + const name = document.getElementById('m-name')?.value?.trim() || ''; + resetPhotoPreviewObjectUrl(); + + if (!file) { + if (fileName) { + fileName.textContent = '선택된 파일 없음'; + } + syncPhotoPreviewFromUrl(); + return; + } + + if (fileName) { + fileName.textContent = file.name; + } + photoPreviewObjectUrl = URL.createObjectURL(file); + updatePhotoPreview(photoPreviewObjectUrl, name); +} + +function renderSeatPreviewCard(seatInfo) { + const assigned = Boolean(seatInfo?.assigned); + const seatMapLabel = String(seatInfo?.seatMapName || '자리배치도').replace(/\s*자리배치도\s*$/u, '').trim() || '사무실'; + const safeSeatMapName = escapeHtml(seatInfo?.seatMapName || '자리배치도'); + const safeOfficeLabel = escapeHtml(seatMapLabel); + const badge = assigned + ? `${safeOfficeLabel}` + : '미배치'; + const body = assigned + ? ` + + ` + : ` +
+ + 현재 공석 또는 미배치 상태입니다. +
+ `; + + return ` +
+
+
+ 재석위치 +

${assigned ? '현재 배치된 사무실과 좌석 위치를 강조해서 표시합니다.' : '현재 자리배치도에서 배치된 좌석이 없습니다.'}

+
+ ${badge} +
+
+ ${body} +
+
+ `; +} + +async function hydrateMemberSeatPreview(member) { + const target = document.getElementById('member-seat-preview'); + if (!target) { + return; + } + target.innerHTML = renderSeatPreviewCard({ + assigned: false, + seatMapName: '자리배치도', + seatLabel: member['자리위치'] || '', + slotKey: '', + }); + const layouts = await loadSeatMapLayouts(true); + if (!document.getElementById('member-seat-preview')) { + return; + } + const seatInfo = getMemberSeatInfo(layouts, member.id) || { + layout: null, + seatMapName: '자리배치도', + seatLabel: member['자리위치'] || '', + slotKey: '', + assigned: false, + }; + target.innerHTML = renderSeatPreviewCard(seatInfo); + if (!seatInfo.assigned || !seatInfo.seatMapId || !seatInfo.slotKey) { + return; + } + const frame = document.getElementById('member-seat-preview-frame'); + if (!frame) { + return; + } + frame.addEventListener('load', () => { + applySeatPreviewFrameState(frame, seatInfo, seatInfo.layout); + }, { once: true }); +} + +function switchModalTab(tab) { + const isBasic = tab === 'basic'; + document.getElementById('modal-sec-basic').classList.toggle('hidden', !isBasic); + document.getElementById('modal-sec-org').classList.toggle('hidden', isBasic); + document.getElementById('modal-tab-basic').className = isBasic ? 'member-modal-tab is-active' : 'member-modal-tab'; + document.getElementById('modal-tab-org').className = !isBasic ? 'member-modal-tab is-active' : 'member-modal-tab'; +} + +function openModal(id) { + const sourceList = isListMode ? editingMembers : members; + const modal = document.getElementById('modal'); + modal.querySelector('.modal-content').classList.remove('wide'); + const fieldsArea = document.getElementById('modal-fields'); + const footer = document.getElementById('modal-footer-area'); + const member = id ? (sourceList.find((item) => item._id === id) || {}) : {}; + + if (!isAdmin && id) { + document.getElementById('modal-title').innerText = '구성원 상세 프로필'; + fieldsArea.className = 'flex flex-col items-center gap-6 py-4'; + fieldsArea.style.maxHeight = 'none'; + fieldsArea.innerHTML = ` +
+
+ +
+
+
+

${member['이름'] || ''}

+

${member['직급'] || '-'} / ${member['직책'] || '팀원'}

+

${(member._path || []).map((path) => path.name).join(' > ')}

+
+
+
+ + ${member['전화번호'] || '정보 없음'} +
+
+ + ${member['이메일'] || '정보 없음'} +
+
+
+
+
+
${renderSeatPreviewCard({ assigned: false, seatLabel: member['자리위치'] || '', seatMapName: '자리배치도', slotKey: '' })}
+
+ `; + footer.innerHTML = ''; + modal.style.display = 'flex'; + hydrateMemberSeatPreview(member); + return; + } + + document.getElementById('modal-title').innerText = id ? '구성원 정보 수정' : '신규 구성원 추가'; + modal.querySelector('.modal-content').classList.add('wide'); + fieldsArea.className = 'flex flex-col w-full'; + fieldsArea.style.maxHeight = 'none'; + fieldsArea.style.overflowY = 'auto'; + isListDetailModal = isListMode; + + const sourceValues = isListMode ? editingMembers : members; + let orgFields = '`; + + fieldsArea.innerHTML = ` +
+ + +
+ +
${orgFields}
+ `; + + resetPhotoPreviewObjectUrl(); + + const deleteBtn = id ? `` : ''; + footer.innerHTML = ` + ${deleteBtn} + + `; + modal.style.display = 'flex'; + if (id) { + hydrateMemberSeatPreview(member); + } +} + +function closeModal() { + if (isListMode && isListDetailModal) { + returnToListViewModal(); + return; + } + resetPhotoPreviewObjectUrl(); + 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').style.maxHeight = 'none'; + document.getElementById('modal-fields').style.overflowY = 'visible'; + document.querySelector('.modal-content').classList.remove('wide'); + isListDetailModal = false; + isListMode = false; +} + +async function saveMember() { + const id = document.getElementById('m-id')?.value || ''; + const name = document.getElementById('m-name').value.trim(); + if (!name) { + alert('이름을 입력해주세요.'); + return; + } + + const targetList = isListMode ? editingMembers : members; + let member = id ? targetList.find((item) => item._id === id) : { _id: `virtual-${Date.now()}` }; + if (!member) { + member = { _id: `virtual-${Date.now()}` }; + } + + member['이름'] = name; + member['사번'] = document.getElementById('m-employee-id').value.trim(); + dropdownFields.forEach((field) => { + const selectValue = document.getElementById(`sel-${field}`).value; + if (selectValue === '__NEW__') { + member[field] = document.getElementById(`input-${field}`).value.trim(); + } else if (selectValue === '__NONE__') { + member[field] = ''; + } else { + member[field] = selectValue; + } + }); + + member['근무상태'] = document.getElementById('m-status').value; + member['근무시간'] = document.getElementById('m-worktime').value; + member['전화번호'] = document.getElementById('m-phone').value.trim(); + member['이메일'] = document.getElementById('m-email').value.trim(); + member['자리위치'] = document.getElementById('m-seat-hidden').value.trim(); + member['사진'] = document.getElementById('m-photo-hidden').value.trim(); + const photoFile = document.getElementById('m-photo-file')?.files?.[0]; + if (photoFile) { + member['사진'] = await uploadProfilePhoto(photoFile, member['이름']); + } + if (member['근무시간'] === '유연근무제') { + member['유연근무_시작'] = document.getElementById('m-work-start').value; + member['유연근무_종료'] = document.getElementById('m-work-end').value; + } else { + member['유연근무_시작'] = ''; + member['유연근무_종료'] = ''; + } + rebuildMemberPath(member); + + if (isListMode) { + if (!id) { + targetList.push(member); + } + returnToListViewModal(); + return; + } + + if (id && member.id) { + await apiFetch(`/api/members/${member.id}`, { + method: 'PUT', + body: JSON.stringify({ + ...toApiMember(member, member.sort_order || 0), + id: member.id, + }), + }); + } else { + await apiFetch('/api/members', { + method: 'POST', + body: JSON.stringify(toApiMember(member, members.length)), + }); + } + + await loadMembers(); + closeModal(); +} + +async function deleteMember(id) { + if (!confirm('해당 구성원을 삭제하시겠습니까?')) { + return; + } + + if (isListMode) { + const shouldReturnToList = isListDetailModal; + const idx = editingMembers.findIndex((member) => member._id === id); + if (idx !== -1) { + editingMembers.splice(idx, 1); + } + if (shouldReturnToList) { + returnToListViewModal(); + } else { + renderListViewTable(); + } + return; + } + + const member = members.find((item) => item._id === id); + if (!member?.id) { + return; + } + await apiFetch(`/api/members/${member.id}`, { method: 'DELETE' }); + await loadMembers(); + closeModal(); +} + +function openListViewModal(event) { + if (event) { + event.stopPropagation(); + } + + const defaultDate = getDefaultHistoryDate(); + listViewState.mode = 'current'; + listViewState.snapshotDate = defaultDate; + listViewState.compareFromDate = defaultDate; + listViewState.compareToDate = defaultDate; + listViewState.snapshotMembers = []; + listViewState.compareItems = []; + isListMode = true; + isListDetailModal = false; + editingMembers = cloneMembers(members); + renderListViewShell(defaultDate); + renderListViewModalContent(); +} + +async function applyListViewChanges() { + if (listViewState.mode !== 'current') { + closeModal(); + return; + } + if (!confirm('리스트의 변경 사항을 메인 화면에 반영하시겠습니까?')) { + return; + } + await syncMembers(editingMembers); + isListMode = false; + closeModal(); +} + +function renderListViewFooter() { + const footer = document.getElementById('modal-footer-area'); + if (!footer) { + return; + } + if (listViewState.mode === 'current' && isAdmin) { + footer.innerHTML = ` +
+
+ + +
+
+

항목을 드래그하여 순서를 바꿀 수 있습니다.

+ + +
+
+ `; + return; + } + footer.innerHTML = '
'; +} + +function getRenderableListMembers() { + if (listViewState.mode === 'snapshot') { + return listViewState.snapshotMembers; + } + return editingMembers; +} + +function getListSearchEntries() { + if (listViewState.mode === 'compare') { + return (listViewState.compareItems || []).map((item) => ({ + rowId: `list-compare-row-${item.member_id}`, + name: String(item.name || ''), + values: [String(item.name || ''), ...(item.before_lines || []), ...(item.after_lines || [])], + })); + } + return getRenderableListMembers().map((member) => ({ + rowId: `list-row-${member._id}`, + name: String(member['이름'] || ''), + values: [ + String(member['이름'] || ''), + ...levelOrder.map((level) => String(member[level] || '')), + ], + })); +} + +function formatCompareChangedAt(value) { + const raw = String(value || '').trim(); + if (!raw) { + return '-'; + } + const date = new Date(raw); + if (Number.isNaN(date.getTime())) { + return raw; + } + const year = date.getFullYear(); + const month = pad(date.getMonth() + 1); + const day = pad(date.getDate()); + const hours = pad(date.getHours()); + const minutes = pad(date.getMinutes()); + return `${year}-${month}-${day} ${hours}:${minutes}`; +} + +function renderListViewCompareTable() { + const container = document.getElementById('list-table-container'); + if (!container) { + return; + } + + const rows = listViewState.compareItems || []; + let html = ` + + + + + + + + + + + + + `; + + if (!rows.length) { + html += ''; + } else { + rows.forEach((item) => { + const categories = (item.categories || []).map((category) => `${escapeHtml(category)}`).join(''); + const beforeLines = (item.before_lines || []).map((line) => `
${escapeHtml(line)}
`).join('') || '-'; + const afterLines = (item.after_lines || []).map((line) => `
${escapeHtml(line)}
`).join('') || '-'; + html += ` + + + + + + + + + `; + }); + } + + html += '
이름상태변경일시변경유형이전현재
선택한 기간 사이의 구성원 변경 내역이 없습니다.
${escapeHtml(item.name || '-')}${escapeHtml(item.status_label || '-')}${escapeHtml(formatCompareChangedAt(item.changed_at))}
${categories || '-'}
${beforeLines}${afterLines}
'; + container.innerHTML = html; +} + +function renderListViewModalContent() { + const status = document.getElementById('list-view-status'); + if (status) { + if (listViewState.mode === 'snapshot') { + status.textContent = listViewState.snapshotDate + ? `${listViewState.snapshotDate} 기준 인원 명단입니다.` + : '기준일을 선택한 뒤 조회하세요.'; + } else if (listViewState.mode === 'compare') { + status.textContent = (listViewState.compareFromDate && listViewState.compareToDate) + ? `${listViewState.compareFromDate} ~ ${listViewState.compareToDate} 변경 내역입니다.` + : '비교 시작일과 종료일을 선택하세요.'; + } else { + status.textContent = '현재 조직 인원 명단입니다.'; + } + } + + if (listViewState.mode === 'compare') { + renderListViewCompareTable(); + } else { + renderListViewTable(); + } + renderListViewFooter(); +} + +function showCurrentListView() { + listViewState.mode = 'current'; + renderListViewModalContent(); +} + +async function loadSnapshotListView() { + const snapshotDate = document.getElementById('list-snapshot-date')?.value || ''; + if (!snapshotDate) { + alert('기준일을 선택해주세요.'); + return; + } + const payload = await apiFetch(`/api/members?as_of=${encodeURIComponent(snapshotDate)}`); + listViewState.snapshotDate = snapshotDate; + listViewState.snapshotMembers = getVisibleLegacyMembers((payload.items || []).map(toLegacyMember)); + listViewState.mode = 'snapshot'; + renderListViewModalContent(); +} + +async function loadCompareListView() { + const fromDate = document.getElementById('list-compare-from')?.value || ''; + const toDate = document.getElementById('list-compare-to')?.value || ''; + if (!fromDate || !toDate) { + alert('비교 시작일과 종료일을 선택해주세요.'); + return; + } + const payload = await apiFetch(`/api/history/members/compare?from_date=${encodeURIComponent(fromDate)}&to_date=${encodeURIComponent(toDate)}`); + listViewState.compareFromDate = fromDate; + listViewState.compareToDate = toDate; + listViewState.compareItems = Array.isArray(payload.items) ? payload.items : []; + listViewState.mode = 'compare'; + renderListViewModalContent(); +} + +function renderListViewTable() { + const container = document.getElementById('list-table-container'); + if (!container) { + return; + } + + const sourceMembers = getRenderableListMembers(); + const editable = isAdmin && listViewState.mode === 'current'; + const inspectable = !editable && listViewState.mode === 'current'; + const groupColumnCount = editable ? 11 : 10; + let html = `${editable ? '' : ''}`; + const lastValues = {}; + levelOrder.forEach((level) => { + lastValues[level] = ''; + }); + + sourceMembers.forEach((member, index) => { + let isAnyParentCollapsed = false; + levelOrder.forEach((level, depth) => { + const value = (member[level] || '').trim(); + if (!value) { + return; + } + const key = `${level}_${value}`; + const parentLevels = levelOrder.slice(0, depth); + if (parentLevels.some((parentLevel) => member[parentLevel] && collapsedUnits.has(`${parentLevel}_${member[parentLevel].trim()}`))) { + isAnyParentCollapsed = true; + } + if (value !== lastValues[level]) { + const isCollapsed = collapsedUnits.has(key); + const dragAttr = editable ? `draggable="true" ondragstart="handleListGroupDragStart(event, '${jsString(level)}', '${jsString(value)}')" ondragover="event.preventDefault()" ondrop="handleListGroupDrop(event, '${jsString(level)}', '${jsString(value)}')"` : ''; + html += ``; + lastValues[level] = value; + levelOrder.slice(depth + 1).forEach((childLevel) => { + lastValues[childLevel] = ''; + }); + } + }); + + const hidden = levelOrder.some((level) => member[level] && collapsedUnits.has(`${level}_${member[level].trim()}`)) || isAnyParentCollapsed; + const rowDragAttr = editable ? `draggable="true" ondragstart="handleListDragStart(event, ${index})" ondragover="event.preventDefault()" ondrop="handleListDrop(event, ${index})"` : ''; + const actionCell = editable + ? `
수정삭제
` + : inspectable + ? `조회` + : '-'; + html += ` + + ${editable ? '' : ''} + + + + + + + + + + + + `; + }); + + html += '
순서이름직급직책디비전그룹부서소속${editable ? '관리' : '조회'}
${escapeHtml(value)}
${escapeHtml(member['이름'] || '-')}${escapeHtml(member['직급'] || '-')}${member['근무상태'] === '휴직' ? '휴직' : escapeHtml(member['직책'] || '-')}${escapeHtml(member['셀'] || '-')}${escapeHtml(member['팀'] || '-')}${escapeHtml(member['디비전'] || '-')}${escapeHtml(member['그룹'] || '-')}${escapeHtml(member['부서'] || '-')}${escapeHtml(member['소속회사'] || '-')}${actionCell}
'; + container.innerHTML = html; +} + +function toggleUnitCollapse(level, name) { + const key = `${level}_${name}`; + if (collapsedUnits.has(key)) { + collapsedUnits.delete(key); + } else { + collapsedUnits.add(key); + } + renderListViewTable(); +} + +let draggedGroup = null; +function handleListGroupDragStart(event, level, name) { + draggedIdx = null; + draggedGroup = { level, name }; + event.dataTransfer.effectAllowed = 'move'; +} + +function handleListGroupDrop(event, targetLevel, targetName) { + 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)) { + return; + } + const movingMembers = editingMembers.filter((member) => member[draggedGroup.level] === draggedGroup.name); + if (!movingMembers.length) { + return; + } + editingMembers = editingMembers.filter((member) => member[draggedGroup.level] !== draggedGroup.name); + let targetIdx = editingMembers.findIndex((member) => member[targetLevel] === targetName); + if (targetIdx === -1) { + targetIdx = editingMembers.length; + } + editingMembers.splice(targetIdx, 0, ...movingMembers); + draggedGroup = null; + renderListViewTable(); +} + +function handleListSearch(value) { + const query = value.trim().toLowerCase(); + if (!query) { + return; + } + document.querySelectorAll('.list-search-target').forEach((element) => element.classList.remove('list-search-target')); + const targetEntry = getListSearchEntries().find((entry) => ( + entry.values.some((candidate) => String(candidate || '').toLowerCase().includes(query)) + )); + if (!targetEntry) { + alert('검색 결과가 없습니다.'); + return; + } + const row = document.getElementById(targetEntry.rowId); + if (row) { + row.scrollIntoView({ behavior: 'smooth', block: 'center' }); + row.classList.add('list-search-target'); + setTimeout(() => row.classList.remove('list-search-target'), 2000); + } +} + +let draggedIdx = null; +function handleListDragStart(event, index) { + draggedGroup = null; + draggedIdx = index; + event.dataTransfer.effectAllowed = 'move'; + event.target.classList.add('dragging'); +} + +function handleListDrop(event, targetIdx) { + event.preventDefault(); + if (draggedIdx === null || draggedIdx === targetIdx) { + return; + } + const targetMember = editingMembers[targetIdx] || null; + 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); + draggedIdx = null; + renderListViewTable(); +} + +function handleDragStart(event, type, id) { + event.stopPropagation(); + if (type !== 'member') { + return; + } + event.dataTransfer.setData('text/plain', JSON.stringify({ type, id })); + event.dataTransfer.effectAllowed = 'move'; + setTimeout(() => event.target.classList.add('opacity-40', 'scale-95'), 0); +} + +function handleDragEnd(event) { + event.stopPropagation(); + event.target.classList.remove('opacity-40', 'scale-95'); +} + +function handleDragOver(event) { + event.preventDefault(); + event.stopPropagation(); + event.dataTransfer.dropEffect = 'move'; + event.currentTarget.classList.add('ring-4', 'ring-indigo-400', 'bg-indigo-50'); +} + +function handleDragLeave(event) { + event.stopPropagation(); + event.currentTarget.classList.remove('ring-4', 'ring-indigo-400', 'bg-indigo-50'); +} + +async function handleDrop(event, targetLevel, targetName) { + event.preventDefault(); + event.stopPropagation(); + event.currentTarget.classList.remove('ring-4', 'ring-indigo-400', 'bg-indigo-50'); + + try { + const data = JSON.parse(event.dataTransfer.getData('text/plain')); + if (data.type !== 'member') { + return; + } + const targetMember = members.find((member) => member[targetLevel] === targetName); + const targetLevelIndex = levelOrder.indexOf(targetLevel); + const memberIndex = members.findIndex((item) => item._id === data.id); + if (memberIndex === -1) { + return; + } + const nextMembers = cloneMembers(members); + const moved = nextMembers[memberIndex]; + if (targetLevelIndex === -1) { + return; + } + applyMemberPlacementFromTarget(moved, targetLevel, targetName, targetMember); + nextMembers.splice(memberIndex, 1); + nextMembers.push(moved); + await syncMembers(nextMembers); + } catch (error) { + console.error(error); + } +} + +function handleDragOverMember(event) { + event.preventDefault(); + event.stopPropagation(); + event.dataTransfer.dropEffect = 'move'; + const rect = event.currentTarget.getBoundingClientRect(); + const relX = event.clientX - rect.left; + if (relX < rect.width / 2) { + event.currentTarget.classList.add('drop-left'); + event.currentTarget.classList.remove('drop-right'); + } else { + event.currentTarget.classList.add('drop-right'); + event.currentTarget.classList.remove('drop-left'); + } +} + +function handleDragLeaveMember(event) { + event.stopPropagation(); + event.currentTarget.classList.remove('drop-left', 'drop-right'); +} + +async function handleDropMember(event, targetId) { + event.preventDefault(); + event.stopPropagation(); + const rect = event.currentTarget.getBoundingClientRect(); + const insertAfter = event.clientX - rect.left >= rect.width / 2; + event.currentTarget.classList.remove('drop-left', 'drop-right'); + + try { + const data = JSON.parse(event.dataTransfer.getData('text/plain')); + if (data.type !== 'member' || data.id === targetId) { + return; + } + const nextMembers = cloneMembers(members); + let movingIdx = nextMembers.findIndex((member) => member._id === data.id); + let targetIdx = nextMembers.findIndex((member) => member._id === targetId); + if (movingIdx === -1 || targetIdx === -1) { + return; + } + const moved = nextMembers[movingIdx]; + const target = nextMembers[targetIdx]; + applyMemberPlacementFromTarget(moved, levelOrder[levelOrder.length - 1], target[levelOrder[levelOrder.length - 1]] || '', target); + nextMembers.splice(movingIdx, 1); + targetIdx = nextMembers.findIndex((member) => member._id === targetId); + nextMembers.splice(insertAfter ? targetIdx + 1 : targetIdx, 0, moved); + await syncMembers(nextMembers); + } catch (error) { + console.error(error); + } +} + +window.addEventListener('resize', () => { + requestAnimationFrame(drawLines); +}); + +window.addEventListener('click', () => { + document.getElementById('fab-container').classList.remove('active'); +}); + +document.addEventListener('DOMContentLoaded', async () => { + document.getElementById('upload-excel').addEventListener('change', async (event) => { + const file = event.target.files?.[0]; + if (!file) { + return; + } + try { + await importMemberFile(file); + event.target.value = ''; + } catch (error) { + alert(error.message || '업로드에 실패했습니다.'); + } + }); + document.getElementById('admin-mode-btn').addEventListener('click', () => toggleAdminMode(!isAdmin)); + document.getElementById('fab-main').addEventListener('click', (event) => toggleFab(event)); + document.getElementById('search-input').addEventListener('keydown', (event) => { + if (event.key === 'Enter') { + handleSearch(event.target.value); + } + }); + document.getElementById('stats-header').addEventListener('click', toggleStats); + document.getElementById('modal-cancel-btn').addEventListener('click', closeModal); + + updateFabMenu(); + try { + await loadMembers('서버에서 조직 데이터를 불러오는 중입니다.'); + } catch (error) { + console.error(error); + emptyStateMessage = `WSL 서버 연결에 실패했습니다. ${error.message || ''}`; + render(); + } +}); diff --git a/frontend/apps/organization/index.html b/frontend/apps/organization/index.html new file mode 100644 index 0000000..792800c --- /dev/null +++ b/frontend/apps/organization/index.html @@ -0,0 +1,65 @@ + + + + + + MH 조직현황 관리 + + + + + + + + + + +
+
+
+ + + + +
+
+
+
+ +
+
+

인원 현황 통계 0명

+ +
+ +
+ +
+
서버에서 조직 데이터를 불러오는 중입니다.
+
+ + + +
+ +
+ +
+
+ + + + + + diff --git a/incoming-files/README.md b/incoming-files/README.md index e37f84c..cc21610 100644 --- a/incoming-files/README.md +++ b/incoming-files/README.md @@ -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/` 하위로 재배치 여부를 검토한다. diff --git a/incoming-files/mh.html b/incoming-files/mh.html deleted file mode 100644 index 4518e67..0000000 --- a/incoming-files/mh.html +++ /dev/null @@ -1,3472 +0,0 @@ - - - - - - - - - - - 팀/개인별 분석 - - - - - - - - - - - - - - - - - - - -
-
-

-
-
-

팀/개인별 분석

-
-
-
- - - -
-
- -
- -
-
-
-
-
- -
-
- - - - - -
- -
- -
- -
- -
- -
- -
-
- -
- -
-

- - 팀별 진행 프로젝트 - -

- - -
- -
- - - -
- -
- -
파일을 업로드하면 프로젝트 현황이 표시됩니다.
- -
- -
- -
-
- -
-
- -
- -
- - - -
- -
- -

분석 데이터를 기다리는 중..

- - -
- -
- -
- -

※ 인정시간: 평일(8시간+연장 3시간) 및 주말(5시간)

-
-
-
- -
-
- - - -
-
- -
- - - - - diff --git a/incoming-files/payment.html b/incoming-files/payment.html deleted file mode 100644 index 24ca0c2..0000000 --- a/incoming-files/payment.html +++ /dev/null @@ -1,1622 +0,0 @@ - - - - - - 프로젝트 대시보드 - - - - - - - - -
- - - - - - - - - - - - - - - - diff --git a/incoming-files/reference/README.md b/incoming-files/reference/README.md index ddf5ad6..e3c440a 100644 --- a/incoming-files/reference/README.md +++ b/incoming-files/reference/README.md @@ -8,8 +8,7 @@ - `ledger/` - 사업관리대장 원본 wrapper/html/css/xlsx - - 이전 override 복사본 - - 중첩 백업 디렉터리 + - 이전 override 참고 파일 규칙: diff --git a/incoming-files/reference/ledger/README.md b/incoming-files/reference/ledger/README.md index 4e65014..caf5d6b 100644 --- a/incoming-files/reference/ledger/README.md +++ b/incoming-files/reference/ledger/README.md @@ -14,6 +14,7 @@ - 원본 HTML/CSS - 원본 XLSX - 과거 override 참고 파일 +- 단일 canonical reference set만 유지 주의: diff --git a/incoming-files/reference/ledger/사업관리대장/MH 통합 대시보드_260320.css b/incoming-files/reference/ledger/사업관리대장/MH 통합 대시보드_260320.css deleted file mode 100644 index 8b948d4..0000000 --- a/incoming-files/reference/ledger/사업관리대장/MH 통합 대시보드_260320.css +++ /dev/null @@ -1,1377 +0,0 @@ -:root { - --bg: #f1eadf; - --panel: #fffaf3; - --panel-soft: #f4e9d7; - --ink: #10251d; - --muted: #66756d; - --line: #d9c5a8; - --brand: #0f3a2f; - --brand-deep: #0a2a22; - --brand-soft: #1a5645; - --accent: #d68a3a; - --accent-soft: #f2c484; - --mint: #2f9973; - --blue: #4b87b3; - --shadow: 0 22px 54px rgba(15, 58, 47, 0.12); - } - - * { box-sizing: border-box; } - body { - margin: 0; - font-family: "Pretendard", "Malgun Gothic", sans-serif; - color: var(--ink); - background: - radial-gradient(circle at top left, rgba(214, 138, 58, 0.18), transparent 24%), - radial-gradient(circle at top right, rgba(47, 153, 115, 0.12), transparent 22%), - linear-gradient(180deg, #f6efe6 0%, #f1eadf 100%); - } - - .page { - max-width: 1720px; - margin: 0 auto; - padding: 26px 22px 40px; - } - - .hero { - position: relative; - overflow: hidden; - background: - radial-gradient(circle at 12% 18%, rgba(242, 196, 132, 0.18), transparent 24%), - radial-gradient(circle at 88% 12%, rgba(255, 255, 255, 0.10), transparent 18%), - linear-gradient(145deg, var(--brand-deep) 0%, var(--brand) 52%, var(--brand-soft) 100%); - color: #f7f0e4; - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 30px; - padding: 34px 32px; - box-shadow: 0 28px 70px rgba(15, 58, 47, 0.22); - } - - .hero::before { - content: ""; - position: absolute; - inset: auto -6% -46% auto; - width: 320px; - height: 320px; - border-radius: 50%; - background: radial-gradient(circle, rgba(242, 196, 132, 0.22), transparent 68%); - pointer-events: none; - } - - .hero::after { - content: ""; - position: absolute; - top: -90px; - right: 80px; - width: 220px; - height: 220px; - border-radius: 50%; - border: 1px solid rgba(242, 196, 132, 0.16); - pointer-events: none; - } - - h1 { - margin: 0; - font-size: 52px; - line-height: 1.12; - letter-spacing: -0.03em; - position: relative; - z-index: 1; - } - - .summary { - margin-top: 10px; - display: grid; - grid-template-columns: repeat(5, minmax(0, 1fr)); - gap: 14px; - } - - .summary-card { - padding: 14px 16px; - border-radius: 22px; - background: linear-gradient(180deg, rgba(255, 255, 255, 0.12) 0%, rgba(255, 255, 255, 0.07) 100%); - border: 1px solid rgba(255, 255, 255, 0.14); - backdrop-filter: blur(8px); - position: relative; - z-index: 1; - } - - .summary-label { - font-size: 11px; - letter-spacing: 0.16em; - text-transform: uppercase; - color: rgba(255, 244, 230, 0.68); - font-weight: 900; - } - - .summary-value { - margin-top: 8px; - font-size: 24px; - font-weight: 900; - } - - .summary-sub { - margin-top: 4px; - font-size: 12px; - color: rgba(255, 244, 230, 0.82); - font-weight: 700; - } - - .tabs { - margin-top: 18px; - display: flex; - gap: 10px; - flex-wrap: wrap; - } - - .hero-actions { - margin-top: 0; - display: flex; - gap: 10px; - flex-wrap: wrap; - position: absolute; - top: 0; - right: 0; - justify-content: flex-end; - } - - .hero-action { - padding: 12px 16px; - border-radius: 999px; - border: 1px solid rgba(242, 196, 132, 0.34); - background: rgba(255, 255, 255, 0.08); - color: #f4efe6; - font-size: 14px; - font-weight: 900; - cursor: pointer; - } - - .hero-action:hover { - background: rgba(242, 196, 132, 0.16); - border-color: rgba(242, 196, 132, 0.52); - } - - .tab { - padding: 12px 16px; - border-radius: 999px; - border: 1px solid #d6c1a3; - background: linear-gradient(180deg, #fffdf8 0%, #f5ebdd 100%); - color: #244638; - font-size: 14px; - font-weight: 900; - cursor: pointer; - transition: all 0.16s ease; - } - - .tab.active { - background: linear-gradient(145deg, var(--brand-deep) 0%, var(--brand) 100%); - color: #f4efe6; - border-color: var(--brand); - box-shadow: 0 12px 28px rgba(15, 58, 47, 0.2); - } - - .layout { - margin-top: 18px; - display: grid; - grid-template-columns: 320px minmax(0, 1fr); - gap: 18px; - } - - .panel { - background: var(--panel); - border: 1px solid var(--line); - border-radius: 26px; - box-shadow: var(--shadow); - overflow: hidden; - } - - .panel-head { - padding: 18px 20px 0; - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - } - - .panel-title { - margin: 0; - font-size: 18px; - font-weight: 900; - } - - .panel-note { - color: var(--muted); - font-size: 12px; - font-weight: 800; - } - - .panel-body { - padding: 18px 20px 20px; - } - - .company-card { - display: grid; - gap: 10px; - } - - .metric { - padding: 14px 16px; - border-radius: 18px; - background: linear-gradient(180deg, #fffdf8 0%, #f2e7d6 100%); - border: 1px solid var(--line); - } - - .metric-label { - font-size: 11px; - font-weight: 900; - letter-spacing: 0.16em; - color: #8a6b3d; - text-transform: uppercase; - } - - .metric-value { - margin-top: 8px; - font-size: 20px; - font-weight: 900; - line-height: 1.2; - } - - .metric-sub { - margin-top: 5px; - font-size: 12px; - line-height: 1.45; - color: #425148; - font-weight: 700; - } - - .metric-sub-list { - display: grid; - gap: 4px; - margin-top: 6px; - max-height: 132px; - overflow: auto; - padding-right: 4px; - } - -.metric-sub-item { - font-size: 12px; - line-height: 1.4; - color: #425148; - font-weight: 700; - } - - /* MH embedded business dashboard theme */ - body.mh-business-theme { - color: var(--ink); - background: - radial-gradient(circle at top left, rgba(214, 138, 58, 0.18), transparent 24%), - radial-gradient(circle at top right, rgba(47, 153, 115, 0.12), transparent 22%), - linear-gradient(180deg, #f6efe6 0%, #f1eadf 100%) !important; - font-family: "Pretendard", "Malgun Gothic", sans-serif; - } - - body.mh-business-theme .wrap { - width: calc(100vw - 60px); - max-width: calc(100vw - 60px); - margin: 0 auto; - padding: 18px 18px 16px; - } - - body.mh-business-theme .top { - position: relative; - overflow: hidden; - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(320px, 520px); - gap: 18px 24px; - align-items: start; - background: - radial-gradient(circle at 12% 18%, rgba(242, 196, 132, 0.18), transparent 24%), - radial-gradient(circle at 88% 12%, rgba(255, 255, 255, 0.10), transparent 18%), - linear-gradient(145deg, var(--brand-deep) 0%, var(--brand) 52%, var(--brand-soft) 100%); - color: #f7f0e4; - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 30px; - padding: 30px 30px 26px; - box-shadow: 0 28px 70px rgba(15, 58, 47, 0.22); - margin-bottom: 14px; - } - - body.mh-business-theme .top::before { - content: ""; - position: absolute; - inset: auto -6% -46% auto; - width: 320px; - height: 320px; - border-radius: 50%; - background: radial-gradient(circle, rgba(242, 196, 132, 0.22), transparent 68%); - pointer-events: none; - } - - body.mh-business-theme .top::after { - content: ""; - position: absolute; - top: -90px; - right: 80px; - width: 220px; - height: 220px; - border-radius: 50%; - border: 1px solid rgba(242, 196, 132, 0.16); - pointer-events: none; - } - - body.mh-business-theme .top > div:first-child { - min-width: 0; - } - - body.mh-business-theme .sub, - body.mh-business-theme .title, - body.mh-business-theme .today-date-label, - body.mh-business-theme #btnUpload { - display: none !important; - } - - body.mh-business-theme .brand-head { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 10px; - min-width: 0; - position: relative; - z-index: 1; - } - - body.mh-business-theme .brand-ci { - display: flex; - align-items: center; - gap: 10px; - min-width: 0; - } - - body.mh-business-theme .brand-logo-wrap { - display: inline-flex; - align-items: center; - justify-content: center; - flex: 0 0 auto; - } - - body.mh-business-theme .brand-logo { - width: 50px; - height: 28px; - flex: 0 0 auto; - display: block; - } - - body.mh-business-theme .brand-company { - color: #8db4ff; - font-size: 20px; - font-weight: 900; - letter-spacing: -0.03em; - white-space: nowrap; - line-height: 1; - } - - body.mh-business-theme .brand-copy { - min-width: 0; - } - - body.mh-business-theme .brand-title { - min-width: 0; - color: #f4efe6; - font-size: clamp(28px, 2.5vw, 46px); - font-weight: 900; - letter-spacing: -0.03em; - line-height: 1.1; - word-break: keep-all; - } - - body.mh-business-theme .brand-subtitle { - display: inline; - color: rgba(244, 239, 230, 0.92); - font-size: 0.5em; - font-weight: 700; - letter-spacing: 0.01em; - margin-left: 6px; - white-space: nowrap; - } - - body.mh-business-theme .controls { - display: flex; - flex-direction: column; - align-items: stretch; - justify-self: end; - gap: 12px; - min-width: min(100%, 520px); - position: relative; - z-index: 1; - } - - body.mh-business-theme .controls-top-row, - body.mh-business-theme .controls-top-actions { - display: flex; - align-items: center; - justify-content: flex-end; - gap: 10px; - flex-wrap: wrap; - } - - body.mh-business-theme .search { - width: min(520px, 100%); - align-self: flex-end; - min-height: 52px; - border-radius: 18px; - border: 1px solid rgba(255, 255, 255, 0.18); - background: rgba(255, 255, 255, 0.14); - color: #fff7eb; - padding: 14px 18px; - font-size: 15px; - font-weight: 800; - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06); - } - - body.mh-business-theme .search::placeholder { - color: rgba(247, 240, 228, 0.66); - } - - body.mh-business-theme .status { - display: none !important; - } - - body.mh-business-theme .cards { - grid-column: 1 / -1; - display: grid; - grid-template-columns: repeat(6, minmax(0, 1fr)); - gap: 14px; - margin-top: 6px; - margin-bottom: 0; - position: relative; - z-index: 1; - } - - body.mh-business-theme .business-shell { - position: relative; - margin-top: 12px; - padding: 18px 18px 20px; - border-radius: 30px; - background: - radial-gradient(circle at 12% 12%, rgba(242, 196, 132, 0.10), transparent 26%), - radial-gradient(circle at 88% 10%, rgba(255, 255, 255, 0.08), transparent 18%), - linear-gradient(145deg, rgba(15, 58, 47, 0.96) 0%, rgba(26, 86, 69, 0.94) 100%); - border: 1px solid rgba(255, 255, 255, 0.10); - box-shadow: 0 26px 70px rgba(15, 58, 47, 0.16); - overflow: hidden; - } - - body.mh-business-theme .business-shell::before { - content: ""; - position: absolute; - inset: auto -10% -32% auto; - width: 360px; - height: 360px; - border-radius: 50%; - background: radial-gradient(circle, rgba(242, 196, 132, 0.16), transparent 70%); - pointer-events: none; - } - - body.mh-business-theme .business-shell::after { - content: ""; - position: absolute; - top: -110px; - right: 90px; - width: 260px; - height: 260px; - border-radius: 50%; - border: 1px solid rgba(242, 196, 132, 0.12); - pointer-events: none; - } - - body.mh-business-theme .cards-toolbar { - grid-column: 1 / -1; - display: flex; - flex-direction: column; - gap: 10px; - margin-bottom: 6px; - } - - body.mh-business-theme .cards-toolbar-row { - display: flex; - align-items: center; - gap: 10px; - flex-wrap: wrap; - } - - body.mh-business-theme .summary-filter-chip, - body.mh-business-theme .summary-year-chip { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 8px; - min-width: 58px; - padding: 9px 14px; - border-radius: 999px; - border: 1px solid rgba(255, 255, 255, 0.14); - background: rgba(255, 255, 255, 0.08); - color: #f4efe6; - font-size: 12px; - font-weight: 900; - cursor: pointer; - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); - } - - body.mh-business-theme .summary-filter-chip { - flex-direction: column; - align-items: center; - justify-content: center; - min-height: 96px; - gap: 8px; - text-align: center; - } - - body.mh-business-theme .summary-filter-chip .label { - color: rgba(244, 239, 230, 0.92); - letter-spacing: 0.01em; - } - - body.mh-business-theme .summary-filter-chip .count { - color: #ffd08a; - font-size: 30px; - line-height: 1; - letter-spacing: -0.03em; - } - - body.mh-business-theme .summary-filter-chip .meta { - color: rgba(255, 230, 190, 0.92); - font-size: 11px; - font-weight: 800; - } - - body.mh-business-theme .summary-filter-chip.active, - body.mh-business-theme .summary-year-chip.active { - background: linear-gradient(180deg, #fff8ee 0%, #f2dec0 100%); - color: #0a2a22; - border-color: rgba(242, 196, 132, 0.58); - box-shadow: 0 12px 28px rgba(10, 42, 34, 0.18); - } - - body.mh-business-theme .summary-filter-chip.active .count { - color: #b86b1f; - } - - body.mh-business-theme .summary-filter-chip.active .label { - color: rgba(10, 42, 34, 0.78); - } - - body.mh-business-theme .summary-filter-chip.active .meta { - color: #7c5a20; - } - - body.mh-business-theme .card { - padding: 16px 16px 14px; - min-height: 108px; - border-radius: 22px; - background: linear-gradient(180deg, rgba(255, 250, 243, 0.98) 0%, rgba(247, 238, 225, 0.94) 100%); - border: 1px solid rgba(214, 193, 163, 0.78); - color: #173328; - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.75), - 0 14px 30px rgba(15, 58, 47, 0.08); - backdrop-filter: blur(10px); - display: flex; - flex-direction: column; - justify-content: center; - } - - body.mh-business-theme .card.management { - background: linear-gradient(180deg, rgba(255, 246, 232, 0.98) 0%, rgba(250, 236, 208, 0.96) 100%); - border-color: rgba(214, 138, 58, 0.42); - } - - body.mh-business-theme .card .k { - color: rgba(23, 51, 40, 0.68); - font-size: 11px; - font-weight: 900; - letter-spacing: 0.04em; - } - - body.mh-business-theme .card .v { - margin-top: 7px; - color: #173328; - font-size: clamp(19px, 1.55vw, 27px); - font-weight: 900; - letter-spacing: -0.03em; - white-space: nowrap; - } - - body.mh-business-theme .card .n { - margin-top: 5px; - color: rgba(63, 84, 74, 0.88); - font-size: 12px; - font-weight: 700; - line-height: 1.4; - } - - body.mh-business-theme .panel { - background: var(--panel); - border: 1px solid var(--line); - border-radius: 26px; - box-shadow: var(--shadow); - overflow: hidden; - position: relative; - z-index: 1; - } - - body.mh-business-theme .table-wrap { - overflow: auto; - } - - body.mh-business-theme table { - width: 100%; - min-width: 1250px; - border-collapse: collapse; - } - - body.mh-business-theme thead th { - background: #122b23; - color: rgba(247, 240, 228, 0.92); - font-size: 11px; - letter-spacing: 0.08em; - padding: 12px 10px; - text-align: left; - white-space: nowrap; - vertical-align: middle; - } - - body.mh-business-theme .th-trigger, - body.mh-business-theme .th-trigger:hover, - body.mh-business-theme .th-trigger.active, - body.mh-business-theme .th-trigger.open { - color: rgba(247, 240, 228, 0.92); - } - - body.mh-business-theme .th-mark, - body.mh-business-theme .th-caret, - body.mh-business-theme .th-meta { - color: #f2c484; - } - - body.mh-business-theme tbody td { - padding: 12px 10px; - border-bottom: 1px solid #ece1cf; - font-size: 13px; - background: #fffaf3; - } - - body.mh-business-theme th:nth-child(1), - body.mh-business-theme td:nth-child(1) { width: 5%; } - body.mh-business-theme th:nth-child(2), - body.mh-business-theme td:nth-child(2) { width: 6%; } - body.mh-business-theme th:nth-child(3), - body.mh-business-theme td:nth-child(3) { width: 31%; } - body.mh-business-theme th:nth-child(4), - body.mh-business-theme td:nth-child(4) { width: 12%; } - body.mh-business-theme th:nth-child(5), - body.mh-business-theme td:nth-child(5) { width: 7%; } - body.mh-business-theme th:nth-child(6), - body.mh-business-theme td:nth-child(6) { width: 6%; } - body.mh-business-theme th:nth-child(7), - body.mh-business-theme td:nth-child(7) { width: 9%; } - body.mh-business-theme th:nth-child(8), - body.mh-business-theme td:nth-child(8) { width: 8%; } - body.mh-business-theme th:nth-child(9), - body.mh-business-theme td:nth-child(9) { width: 8%; } - body.mh-business-theme th:nth-child(10), - body.mh-business-theme td:nth-child(10) { width: 8%; } - body.mh-business-theme th:nth-child(11), - body.mh-business-theme td:nth-child(11) { width: 5%; } - - body.mh-business-theme tbody tr:hover td { - background: #fff5e8; - } - - body.mh-business-theme .name, - body.mh-business-theme td strong { - color: #10251d; - } - - body.mh-business-theme .subline { - color: #7c8b82; - } - - body.mh-business-theme .client-main { - display: block; - font-weight: 800; - color: #10251d; - } - - body.mh-business-theme .badge.badge-baron { - border-color: #f0bb75; - background: #fff2df; - color: #b96820; - } - - body.mh-business-theme .badge.badge-family { - border-color: #a5cbb6; - background: #eef7f1; - color: #2d6a4f; - } - - body.mh-business-theme .group-row td { - padding: 12px 14px 10px; - background: linear-gradient(180deg, #fff9ef 0%, #f5ebdd 100%); - border-top: 1px solid #ead8bc; - border-bottom: 1px solid #ead8bc; - } - - body.mh-business-theme .group-chip { - display: inline-flex; - align-items: center; - gap: 8px; - padding: 7px 13px; - border-radius: 999px; - background: #fffdfa; - border: 1px solid #d6c1a3; - color: #244638; - font-size: 12px; - font-weight: 900; - box-shadow: 0 10px 24px rgba(15, 58, 47, 0.10); - cursor: pointer; - } - - body.mh-business-theme .group-chip .group-toggle { - margin-left: 4px; - width: 22px; - height: 22px; - display: inline-flex; - align-items: center; - justify-content: center; - border-radius: 999px; - background: rgba(242, 196, 132, 0.16); - color: #8a5a1f; - font-size: 14px; - font-weight: 900; - line-height: 1; - } - - body.mh-business-theme .detail-row td { - background: linear-gradient(180deg, #fff7ec 0%, #fffdf8 100%); - border-top: 1px solid #ecd8ba; - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65); - } - - body.mh-business-theme .detail-row .inline-panel { - background: transparent; - border-left: 3px solid var(--accent); - } - - body.mh-business-theme .detail-row .inline-card, - body.mh-business-theme .detail-row .ledger-block { - box-shadow: 0 10px 24px rgba(15, 58, 47, 0.08); - } - - body.mh-business-theme .table-top-note { - display: flex; - justify-content: flex-end; - margin: 0 2px 10px; - } - - body.mh-business-theme .table-vat-note { - display: inline-flex; - align-items: center; - padding: 6px 10px; - border-radius: 999px; - background: rgba(255, 248, 238, 0.96); - border: 1px solid rgba(242, 196, 132, 0.46); - color: #6f5528; - font-size: 11px; - font-weight: 900; - letter-spacing: -0.01em; - } - - @media (max-width: 1280px) { - body.mh-business-theme .top { - grid-template-columns: 1fr; - } - - body.mh-business-theme .controls { - min-width: 0; - justify-self: stretch; - } - - body.mh-business-theme .controls-top-row, - body.mh-business-theme .controls-top-actions { - justify-content: flex-start; - } - - body.mh-business-theme .search { - width: 100%; - align-self: stretch; - } - - body.mh-business-theme .cards { - grid-template-columns: repeat(2, minmax(140px, 1fr)); - } - } - - @media (max-width: 720px) { - body.mh-business-theme .wrap { - width: calc(100vw - 30px); - max-width: calc(100vw - 30px); - padding: 12px 10px; - } - - body.mh-business-theme .top { - padding: 18px 14px 16px; - gap: 14px; - } - - body.mh-business-theme .brand-head { - gap: 8px; - align-items: flex-start; - } - - body.mh-business-theme .brand-company { - font-size: 16px; - } - - body.mh-business-theme .brand-title { - font-size: 30px; - } - - body.mh-business-theme .brand-subtitle { - display: inline; - margin-left: 4px; - margin-top: 0; - white-space: normal; - } - } - - .filter-row { - display: grid; - grid-template-columns: 1fr 160px 180px 180px; - gap: 12px; - margin-bottom: 14px; - } - - .year-summary { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); - gap: 10px; - margin-bottom: 14px; - } - - .year-card { - padding: 14px 16px; - border-radius: 18px; - background: linear-gradient(180deg, #fffdf8 0%, #f3e7d6 100%); - border: 1px solid var(--line); - cursor: pointer; - transition: transform 0.16s ease, box-shadow 0.16s ease, border-color 0.16s ease, background 0.16s ease; - } - - .year-card:hover { - transform: translateY(-2px); - box-shadow: 0 10px 22px rgba(18, 48, 30, 0.10); - } - - .year-card.active { - background: linear-gradient(145deg, var(--brand-deep) 0%, var(--brand) 62%, var(--brand-soft) 100%); - border-color: rgba(214, 138, 58, 0.42); - box-shadow: 0 14px 30px rgba(15, 58, 47, 0.2); - } - - .year-card.active .year-card-label, - .year-card.active .year-card-value, - .year-card.active .year-card-sub { - color: #f4efe6; - } - - .year-card-label { - font-size: 11px; - font-weight: 900; - letter-spacing: 0.14em; - color: #7a684d; - } - - .year-card-value { - margin-top: 8px; - font-size: clamp(15px, 1.45vw, 18px); - font-weight: 900; - color: var(--brand); - line-height: 1.2; - white-space: nowrap; - letter-spacing: -0.04em; - } - - .year-card-sub { - margin-top: 4px; - font-size: 12px; - font-weight: 700; - color: var(--muted); - } - - .input, .select { - width: 100%; - border: 1px solid #d7c4a7; - background: #fffdf8; - border-radius: 14px; - padding: 12px 14px; - font-size: 14px; - font-weight: 700; - color: var(--ink); - outline: none; - } - - .input:focus, .select:focus { - border-color: var(--brand-soft); - box-shadow: 0 0 0 3px rgba(47, 153, 115, 0.12); - } - - .chip-row { - display: flex; - gap: 8px; - flex-wrap: wrap; - margin-bottom: 12px; - } - - .chip { - padding: 8px 12px; - border-radius: 999px; - background: #efe3cf; - color: #315243; - font-size: 12px; - font-weight: 900; - } - - .table-wrap { - overflow: auto; - border-top: 1px solid var(--line); - } - - .table-summary { - display: flex; - justify-content: flex-end; - gap: 18px; - padding: 14px 20px 18px; - border-top: 1px solid var(--line); - background: #fbf4ea; - font-size: 13px; - font-weight: 800; - color: #244233; - } - - .table-summary strong { - color: #12301e; - font-size: 15px; - } - - table { - width: 100%; - min-width: 1180px; - border-collapse: separate; - border-spacing: 0; - } - - th, td { - border-right: 1px solid #ecdfcc; - border-bottom: 1px solid #ecdfcc; - padding: 11px 12px; - vertical-align: middle; - text-align: left; - font-size: 13px; - line-height: 1.55; - white-space: nowrap; - } - - th:last-child, td:last-child { border-right: none; } - - thead th { - position: sticky; - top: 0; - z-index: 1; - background: linear-gradient(180deg, var(--brand-deep) 0%, var(--brand) 100%); - color: #f4efe6; - font-size: 12px; - font-weight: 900; - text-align: center; - border-bottom: 1px solid rgba(242, 196, 132, 0.3); - } - - tbody tr:nth-child(even) td { - background: #fbf4ea; - } - - .money { - white-space: nowrap; - font-weight: 900; - color: var(--brand-soft); - } - - .muted { - color: var(--muted); - font-size: 12px; - } - - .cell-filter { - padding: 0; - border: none; - background: transparent; - color: #12301e; - font: inherit; - font-weight: 800; - cursor: pointer; - white-space: nowrap; - text-decoration: underline; - text-decoration-thickness: 1px; - text-underline-offset: 3px; - } - - .cell-filter:hover { - color: var(--mint); - } - - .chip-action { - border: 1px solid #d9c8af; - background: #fffaf2; - cursor: pointer; - } - - .chip-action:hover { - border-color: var(--mint); - color: var(--mint); - } - - .empty { - padding: 24px; - text-align: center; - color: var(--muted); - font-weight: 800; - } - - .page { - position: relative; - } - - .page::before { - content: ""; - position: fixed; - inset: 0; - background: - linear-gradient(rgba(15, 58, 47, 0.03) 1px, transparent 1px), - linear-gradient(90deg, rgba(15, 58, 47, 0.03) 1px, transparent 1px); - background-size: 32px 32px; - mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.34), transparent 82%); - pointer-events: none; - z-index: 0; - } - - .hero { - padding: 28px 34px 24px; - border-radius: 32px; - box-shadow: 0 30px 70px rgba(15, 58, 47, 0.22); - } - - .hero-grid { - position: relative; - z-index: 1; - display: grid; - grid-template-columns: minmax(0, 1fr); - gap: 24px; - align-items: end; - } - - .hero-grid > div:first-child { - position: relative; - min-height: 108px; - padding-right: 260px; - } - - .brand-kicker { - display: inline-flex; - align-items: center; - gap: 10px; - padding: 9px 14px; - border-radius: 999px; - background: rgba(255, 255, 255, 0.08); - border: 1px solid rgba(242, 196, 132, 0.24); - color: rgba(255, 244, 230, 0.86); - font-size: 11px; - font-weight: 900; - letter-spacing: 0.22em; - text-transform: uppercase; - } - - .brand-kicker::before { - content: ""; - width: 8px; - height: 8px; - border-radius: 50%; - background: var(--accent-soft); - box-shadow: 0 0 0 6px rgba(242, 196, 132, 0.12); - } - - .brand-line { - display: grid; - grid-template-columns: 76px minmax(0, 1fr); - gap: 20px; - align-items: center; - margin-top: 0; - } - - .hero-logo { - position: relative; - width: 76px; - height: 76px; - display: grid; - place-items: center; - border-radius: 24px; - background: - radial-gradient(circle at 30% 26%, rgba(255, 255, 255, 0.24), transparent 20%), - radial-gradient(circle at 68% 72%, rgba(242, 196, 132, 0.34), transparent 18%), - linear-gradient(145deg, rgba(242, 196, 132, 0.18) 0%, rgba(255, 255, 255, 0.04) 100%); - border: 1px solid rgba(242, 196, 132, 0.24); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12), 0 18px 36px rgba(10, 42, 34, 0.24); - } - - .hero-logo::before { - content: ""; - position: absolute; - inset: 10px; - border-radius: 18px; - border: 1px solid rgba(242, 196, 132, 0.16); - } - - .hero-logo::after { - content: ""; - position: absolute; - left: 50%; - top: 50%; - width: 28px; - height: 28px; - transform: translate(-50%, -50%); - border-radius: 50%; - background: radial-gradient(circle, rgba(242, 196, 132, 0.92), rgba(214, 138, 58, 0.18)); - box-shadow: - 0 0 0 8px rgba(242, 196, 132, 0.06), - 0 0 24px rgba(242, 196, 132, 0.24); - } - - .hero-logo-core { - display: none; - } - - .hero-copy { - display: none; - } - - .hero-side { - display: grid; - gap: 12px; - } - - .hero-mini-card { - position: relative; - padding: 16px 18px 16px 58px; - border-radius: 22px; - background: linear-gradient(180deg, rgba(255, 255, 255, 0.11) 0%, rgba(255, 255, 255, 0.06) 100%); - border: 1px solid rgba(255, 255, 255, 0.12); - min-height: 88px; - } - - .hero-mini-card::before { - content: attr(data-mark); - position: absolute; - left: 16px; - top: 16px; - width: 30px; - height: 30px; - border-radius: 12px; - display: grid; - place-items: center; - background: rgba(242, 196, 132, 0.18); - color: #ffe6bf; - font-size: 11px; - font-weight: 1000; - letter-spacing: 0.08em; - } - - .hero-mini-label { - color: rgba(255, 244, 230, 0.62); - font-size: 11px; - font-weight: 900; - letter-spacing: 0.16em; - text-transform: uppercase; - } - - .hero-mini-value { - margin-top: 8px; - color: #fff7eb; - font-size: 24px; - font-weight: 1000; - letter-spacing: -0.03em; - } - - .hero-mini-sub { - margin-top: 6px; - color: rgba(255, 244, 230, 0.76); - font-size: 13px; - line-height: 1.5; - font-weight: 700; - } - - .tabs-shell { - margin-top: 18px; - padding: 12px; - border-radius: 24px; - background: linear-gradient(180deg, rgba(255, 255, 255, 0.68) 0%, rgba(255, 250, 243, 0.92) 100%); - border: 1px solid rgba(217, 197, 168, 0.8); - box-shadow: 0 18px 36px rgba(15, 58, 47, 0.08); - backdrop-filter: blur(10px); - } - - .tabs-shell .tabs { - margin-top: 0; - } - - .page > .hero:not(.hero-reimagined), - .page > .tabs:not(.tabs-reimagined) { - display: none; - } - - .summary-card { - min-height: 122px; - overflow: hidden; - transition: transform 0.18s ease, box-shadow 0.18s ease; - } - - .summary-card:hover { - transform: translateY(-3px); - box-shadow: 0 18px 30px rgba(10, 42, 34, 0.16); - } - - .summary-card::after { - content: ""; - position: absolute; - inset: auto -14px -28px auto; - width: 96px; - height: 96px; - border-radius: 50%; - background: radial-gradient(circle, rgba(242, 196, 132, 0.14), transparent 70%); - } - - .summary-top { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - display: none; - } - - .summary-icon { - width: 42px; - height: 42px; - border-radius: 14px; - display: grid; - place-items: center; - background: rgba(255, 255, 255, 0.14); - border: 1px solid rgba(255, 255, 255, 0.12); - color: #fff3df; - font-size: 11px; - font-weight: 1000; - letter-spacing: 0.08em; - } - - .summary-tail { - margin-top: auto; - padding-top: 8px; - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - color: rgba(255, 244, 230, 0.74); - font-size: 11px; - font-weight: 800; - } - - .summary-card .summary-value { - margin-top: 0; - } - - .summary-line { - flex: 1; - height: 4px; - border-radius: 999px; - background: rgba(255, 255, 255, 0.12); - overflow: hidden; - } - - .summary-line span { - display: block; - height: 100%; - width: 64%; - border-radius: inherit; - background: linear-gradient(90deg, rgba(242, 196, 132, 0.96), rgba(47, 153, 115, 0.88)); - } - - .panel { - position: relative; - } - - .panel::before { - content: ""; - position: absolute; - inset: 0 0 auto 0; - height: 4px; - background: linear-gradient(90deg, var(--accent), #edd6af 38%, var(--mint) 100%); - opacity: 0.9; - } - - tbody tr:hover td { - background: #f5ecdf; - } - - @media (max-width: 1180px) { - .hero-grid { - grid-template-columns: 1fr; - } - - .hero-grid > div:first-child { - min-height: auto; - padding-right: 0; - } - - .brand-line { - grid-template-columns: 92px minmax(0, 1fr); - gap: 16px; - } - - .hero-logo { - width: 68px; - height: 68px; - } - - .hero-actions { - position: static; - margin-top: 18px; - justify-content: flex-start; - } - - .layout { - grid-template-columns: 1fr; - } - - .filter-row { - grid-template-columns: 1fr; - } - - .summary { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - } - - @media (max-width: 720px) { - h1 { - font-size: 34px; - } - - .summary { - grid-template-columns: 1fr; - } - } diff --git a/incoming-files/reference/ledger/사업관리대장/MH 통합 대시보드_260320.html b/incoming-files/reference/ledger/사업관리대장/MH 통합 대시보드_260320.html deleted file mode 100644 index 5c9abdc..0000000 --- a/incoming-files/reference/ledger/사업관리대장/MH 통합 대시보드_260320.html +++ /dev/null @@ -1,2598 +0,0 @@ - - - - - - total - - - - - - - - - -
-
-
- - - -
-
- - - - -
-
-
- -
- -
-
- -
-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/incoming-files/reference/ledger/사업관리대장/사업관리대장-1.xlsx b/incoming-files/reference/ledger/사업관리대장/사업관리대장-1.xlsx deleted file mode 100644 index a6d20ac..0000000 Binary files a/incoming-files/reference/ledger/사업관리대장/사업관리대장-1.xlsx and /dev/null differ diff --git a/incoming-files/served/ledger/README.md b/incoming-files/served/ledger/README.md index f88ae40..4f3b972 100644 --- a/incoming-files/served/ledger/README.md +++ b/incoming-files/served/ledger/README.md @@ -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 시 생성 diff --git a/scripts/publish_organization_app.sh b/scripts/publish_organization_app.sh new file mode 100755 index 0000000..e8cd8ea --- /dev/null +++ b/scripts/publish_organization_app.sh @@ -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" diff --git a/scripts/publish_payment_app.sh b/scripts/publish_payment_app.sh index 81e2543..a3d0a4e 100755 --- a/scripts/publish_payment_app.sh +++ b/scripts/publish_payment_app.sh @@ -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}" diff --git a/scripts/publish_team_app.sh b/scripts/publish_team_app.sh index f6cf187..7ace12b 100755 --- a/scripts/publish_team_app.sh +++ b/scripts/publish_team_app.sh @@ -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}"