Compare commits
2 Commits
8125193378
...
c0564ee326
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0564ee326 | ||
|
|
f8ea345882 |
55
CONTRIBUTING.md
Normal file
55
CONTRIBUTING.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# Contributing
|
||||||
|
|
||||||
|
## 기본 규칙
|
||||||
|
|
||||||
|
- `main`은 팀 기준 브랜치로 사용합니다.
|
||||||
|
- 기능 개발과 버그 수정은 각자 작업 브랜치에서 진행합니다.
|
||||||
|
- 직접 `8080` 기준 파일을 수정하지 않습니다.
|
||||||
|
- 검증은 먼저 `8081` 개발 환경에서 수행합니다.
|
||||||
|
- 커밋은 한 기능 또는 한 버그 단위로 작게 나눕니다.
|
||||||
|
- 작업 시작 전 [docs/TEAM_GUIDE.md](docs/TEAM_GUIDE.md)를 먼저 읽습니다.
|
||||||
|
|
||||||
|
## 권장 브랜치 이름
|
||||||
|
|
||||||
|
- `feature/<name>-<topic>`
|
||||||
|
- `fix/<name>-<topic>`
|
||||||
|
- `chore/<name>-<topic>`
|
||||||
|
|
||||||
|
예:
|
||||||
|
|
||||||
|
- `fix/alex-organization-save`
|
||||||
|
- `feature/minsu-ledger-filter`
|
||||||
|
|
||||||
|
## 작업 순서
|
||||||
|
|
||||||
|
1. `main` 최신 상태를 받습니다.
|
||||||
|
2. 작업 브랜치를 만듭니다.
|
||||||
|
3. 필요한 경우 `./scripts/prepare_dev_worktree.sh`로 격리된 개발 워크스페이스를 준비합니다.
|
||||||
|
4. `8081`에서 수정과 검증을 진행합니다.
|
||||||
|
5. 관련 publish 스크립트가 있는 화면은 publish 후 실제 런타임 파일까지 확인합니다.
|
||||||
|
6. `docs/REGRESSION_CHECKLIST.md` 기준으로 필요한 시나리오를 점검합니다.
|
||||||
|
7. 커밋 후 PR을 생성합니다.
|
||||||
|
|
||||||
|
## PR 규칙
|
||||||
|
|
||||||
|
- PR 하나에는 한 주제만 담습니다.
|
||||||
|
- PR 본문에 아래 내용을 포함합니다.
|
||||||
|
- 작업 목적
|
||||||
|
- 변경 범위
|
||||||
|
- 검증 방법
|
||||||
|
- DB 영향 여부
|
||||||
|
- 공용 구조 파일 수정 시 영향 화면을 명시합니다.
|
||||||
|
|
||||||
|
## 파일 수정 기준
|
||||||
|
|
||||||
|
- 탭 화면 수정은 먼저 `frontend/apps/*`를 봅니다.
|
||||||
|
- 조직현황은 `frontend/apps/organization`와 `legacy/static/*` 구조를 함께 확인합니다.
|
||||||
|
- integration 화면 런타임은 `incoming-files/served/*`지만, 직접 수정 원본은 `frontend/apps/*`입니다.
|
||||||
|
- 원본 참고 파일은 `incoming-files/reference/*`에만 둡니다.
|
||||||
|
|
||||||
|
## 문서 기준
|
||||||
|
|
||||||
|
- 저장소 진입: [README.md](README.md)
|
||||||
|
- 작업 시작 기준: [docs/TEAM_GUIDE.md](docs/TEAM_GUIDE.md)
|
||||||
|
- 개발/운영 DB 원칙: [docs/DEV_PROD_DB_PROTOCOL.md](docs/DEV_PROD_DB_PROTOCOL.md)
|
||||||
|
- 실제 서빙 책임 맵: [docs/architecture/8081_SERVING_MAP.md](docs/architecture/8081_SERVING_MAP.md)
|
||||||
59
README.md
Normal file
59
README.md
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# MH Dashboard Organization
|
||||||
|
|
||||||
|
조직현황, 자리배치도, 프로젝트별 분석, 팀/개인별 분석을 하나의 대시보드로 제공하는 사내 웹 애플리케이션입니다.
|
||||||
|
|
||||||
|
## 구성
|
||||||
|
|
||||||
|
- `frontend/`
|
||||||
|
- 허브 화면과 공통 스타일
|
||||||
|
- `frontend/apps/`
|
||||||
|
- 화면별 source-of-truth 앱 소스
|
||||||
|
- `legacy/static/`
|
||||||
|
- 조직현황 레거시 런타임 자산
|
||||||
|
- `incoming-files/served/`
|
||||||
|
- integration 화면의 실제 런타임 서빙 자산
|
||||||
|
- `incoming-files/reference/`
|
||||||
|
- 원본 참고 자산
|
||||||
|
- `backend/app/`
|
||||||
|
- FastAPI 백엔드
|
||||||
|
- `scripts/`
|
||||||
|
- 실행, publish, 검증, 동기화 스크립트
|
||||||
|
|
||||||
|
## 핵심 원칙
|
||||||
|
|
||||||
|
- `frontend/apps/*`가 탭별 수정 원본입니다.
|
||||||
|
- `incoming-files/served/*`와 `legacy/static/*`는 런타임 자산입니다.
|
||||||
|
- 조직현황/멤버/자리배치 관련 검증은 `8081` 개발 환경에서 먼저 수행합니다.
|
||||||
|
- `8080`은 기준 데이터와 공개 환경, `8081`은 검증 환경으로 다룹니다.
|
||||||
|
|
||||||
|
## 시작 문서
|
||||||
|
|
||||||
|
- 첫 문서: [docs/TEAM_GUIDE.md](docs/TEAM_GUIDE.md)
|
||||||
|
- 협업 규칙: [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||||
|
- 개발/운영 DB 원칙: [docs/DEV_PROD_DB_PROTOCOL.md](docs/DEV_PROD_DB_PROTOCOL.md)
|
||||||
|
- 실제 서빙 책임 맵: [docs/architecture/8081_SERVING_MAP.md](docs/architecture/8081_SERVING_MAP.md)
|
||||||
|
- 디자인 기준: [docs/architecture/DESIGN_SSOT.md](docs/architecture/DESIGN_SSOT.md)
|
||||||
|
|
||||||
|
## 빠른 실행
|
||||||
|
|
||||||
|
기본 공개 환경:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
격리된 `8081` 개발 환경:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/prepare_dev_worktree.sh
|
||||||
|
cd .dev-worktree-8081
|
||||||
|
docker compose -p mh-dashboard-organization-dev --env-file .env -f docker-compose.8081.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
## publish 스크립트
|
||||||
|
|
||||||
|
- 조직현황: `./scripts/publish_organization_app.sh`
|
||||||
|
- 프로젝트별 분석: `./scripts/publish_payment_app.sh`
|
||||||
|
- 팀/개인별 분석: `./scripts/publish_team_app.sh`
|
||||||
|
- 사업관리대장: `./scripts/publish_ledger_app.sh`
|
||||||
|
- DB 상태: `./scripts/publish_db_status_app.sh`
|
||||||
@@ -24,20 +24,36 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from openpyxl import load_workbook
|
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 .config import BASE_DIR, LEGACY_DIR, MOCK_LOGIN_ENABLED, UPLOAD_DIR
|
||||||
from .db import get_conn, init_db
|
from .db import get_conn, init_db
|
||||||
from .integration_routes import register_integration_routes
|
|
||||||
from .ledger_runtime import (
|
from .ledger_runtime import (
|
||||||
build_business_ledger_default_response,
|
build_business_ledger_default_response,
|
||||||
build_ledger_index_response,
|
build_ledger_index_response,
|
||||||
sync_default_business_ledger_source,
|
sync_default_business_ledger_source,
|
||||||
)
|
)
|
||||||
from .member_routes import register_member_routes
|
from .routes import (
|
||||||
from .seatmap_routes import register_seatmap_routes
|
register_auth_routes,
|
||||||
from .system_routes import register_system_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")
|
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 = {
|
LEGACY_HEADER_MAP = {
|
||||||
"이름": "name",
|
"이름": "name",
|
||||||
"name": "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:
|
def _encode_auth_bytes(value: bytes) -> str:
|
||||||
return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=")
|
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)
|
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:
|
def parse_as_of(as_of: str | None) -> datetime | None:
|
||||||
raw = str(as_of or "").strip()
|
raw = str(as_of or "").strip()
|
||||||
if not raw:
|
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
|
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]]:
|
def build_member_compare_items(from_items: list[dict[str, object]], to_items: list[dict[str, object]]) -> list[dict[str, object]]:
|
||||||
tracked_fields = (
|
tracked_fields = (
|
||||||
("company", "소속회사", "기본"),
|
("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))
|
affected_member_ids = sorted(set(previous_member_ids + member_ids))
|
||||||
if affected_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}")
|
revision_no = create_history_revision(
|
||||||
sync_seat_assignment_versions(cur, affected_member_ids, f"seat-layout:{seat_map_id}", revision_no)
|
cur,
|
||||||
sync_member_versions(cur, affected_member_ids, f"seat-layout:{seat_map_id}", revision_no)
|
"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()
|
conn.commit()
|
||||||
|
|
||||||
return fetch_seat_layout(seat_map_id)["placements"]
|
return fetch_seat_layout(seat_map_id)["placements"]
|
||||||
@@ -2312,126 +2011,6 @@ def get_member_count() -> int:
|
|||||||
return int(cur.fetchone()["count"])
|
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 rows_to_member_payloads(rows: list[list[object]]) -> list[MemberPayload]:
|
||||||
def normalize_header(value: object) -> str:
|
def normalize_header(value: object) -> str:
|
||||||
return str(value or "").strip().lower()
|
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]]]]:
|
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 = {
|
by_employee_id = {
|
||||||
clean_text(member.get("employee_id")): member
|
clean_text(member.get("employee_id")): member
|
||||||
for member in members
|
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)
|
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 []
|
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()
|
members_by_employee_id, members_by_name = fetch_member_lookup()
|
||||||
|
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
@@ -4073,15 +3657,26 @@ register_member_routes(
|
|||||||
member_payload_cls=MemberPayload,
|
member_payload_cls=MemberPayload,
|
||||||
member_bulk_payload_cls=MemberBulkPayload,
|
member_bulk_payload_cls=MemberBulkPayload,
|
||||||
parse_as_of=parse_as_of,
|
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,
|
fetch_members_as_of=fetch_members_as_of,
|
||||||
build_member_compare_items=build_member_compare_items,
|
build_member_compare_items=build_member_compare_items,
|
||||||
serialize_member_payload=serialize_member_payload,
|
serialize_member_payload=serialize_member_payload,
|
||||||
sync_auth_users_from_members=sync_auth_users_from_members,
|
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_member_versions=sync_member_versions,
|
||||||
sync_seat_assignment_versions=sync_seat_assignment_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,
|
parse_import_rows=parse_import_rows,
|
||||||
)
|
)
|
||||||
register_integration_routes(
|
register_integration_routes(
|
||||||
|
|||||||
19
backend/app/repositories/__init__.py
Normal file
19
backend/app/repositories/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
from .organization import (
|
||||||
|
fetch_current_member_state,
|
||||||
|
fetch_current_seat_assignments,
|
||||||
|
fetch_history_revision,
|
||||||
|
fetch_history_revision_created_at,
|
||||||
|
fetch_history_revisions,
|
||||||
|
fetch_members,
|
||||||
|
fetch_members_as_of,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"fetch_current_member_state",
|
||||||
|
"fetch_current_seat_assignments",
|
||||||
|
"fetch_history_revision",
|
||||||
|
"fetch_history_revision_created_at",
|
||||||
|
"fetch_history_revisions",
|
||||||
|
"fetch_members",
|
||||||
|
"fetch_members_as_of",
|
||||||
|
]
|
||||||
145
backend/app/repositories/organization.py
Normal file
145
backend/app/repositories/organization.py
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, datetime, time, timedelta
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_members(get_conn) -> list[dict[str, object]]:
|
||||||
|
with get_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, name, employee_id, company, rank, role, department, grp, division, team, cell,
|
||||||
|
work_status, work_time, phone, email, seat_label, photo_url,
|
||||||
|
sort_order, created_at, updated_at
|
||||||
|
FROM members
|
||||||
|
ORDER BY sort_order ASC, id ASC
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
return cur.fetchall()
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_history_revision(cur, revision_id: int) -> dict[str, object] | None:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, revision_label, created_at, note
|
||||||
|
FROM history_revisions
|
||||||
|
WHERE scope = 'organization'
|
||||||
|
AND id = %s
|
||||||
|
""",
|
||||||
|
(revision_id,),
|
||||||
|
)
|
||||||
|
return cur.fetchone()
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_history_revisions(
|
||||||
|
cur,
|
||||||
|
*,
|
||||||
|
app_timezone,
|
||||||
|
day: date | None = None,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> list[dict[str, object]]:
|
||||||
|
safe_limit = max(1, min(int(limit), 500))
|
||||||
|
if day is None:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, revision_label, created_at, note
|
||||||
|
FROM history_revisions
|
||||||
|
WHERE scope = 'organization'
|
||||||
|
ORDER BY created_at DESC, id DESC
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(safe_limit,),
|
||||||
|
)
|
||||||
|
return cur.fetchall()
|
||||||
|
|
||||||
|
day_start = datetime.combine(day, time.min, tzinfo=app_timezone)
|
||||||
|
day_end = day_start + timedelta(days=1)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, revision_label, created_at, note
|
||||||
|
FROM history_revisions
|
||||||
|
WHERE scope = 'organization'
|
||||||
|
AND created_at >= %s
|
||||||
|
AND created_at < %s
|
||||||
|
ORDER BY created_at ASC, id ASC
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(day_start, day_end, safe_limit),
|
||||||
|
)
|
||||||
|
return cur.fetchall()
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_history_revision_created_at(cur, revision_id: int) -> datetime:
|
||||||
|
revision = fetch_history_revision(cur, revision_id)
|
||||||
|
if revision is None:
|
||||||
|
raise HTTPException(status_code=404, detail="History revision not found.")
|
||||||
|
return revision["created_at"]
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_current_member_state(cur) -> dict[int, dict[str, object]]:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, name, employee_id, company, rank, role, department, grp, division, team, cell,
|
||||||
|
work_status, work_time, phone, email, seat_label, photo_url, sort_order,
|
||||||
|
created_at, updated_at
|
||||||
|
FROM members
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
return {int(row["id"]): row for row in cur.fetchall()}
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_current_seat_assignments(cur) -> dict[int, dict[str, object]]:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT member_id, seat_map_id, seat_slot_id, seat_label, updated_at
|
||||||
|
FROM seat_positions
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
return {int(row["member_id"]): row for row in cur.fetchall()}
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_members_as_of(cur, as_of: datetime) -> list[dict[str, object]]:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT mv.member_id AS id,
|
||||||
|
mv.name,
|
||||||
|
COALESCE(m.employee_id, '') AS employee_id,
|
||||||
|
mv.company,
|
||||||
|
mv.rank,
|
||||||
|
mv.role,
|
||||||
|
mv.department,
|
||||||
|
mv.grp,
|
||||||
|
mv.division,
|
||||||
|
mv.team,
|
||||||
|
mv.cell,
|
||||||
|
mv.work_status,
|
||||||
|
mv.work_time,
|
||||||
|
mv.phone,
|
||||||
|
mv.email,
|
||||||
|
COALESCE(sav.seat_label, '') AS seat_label,
|
||||||
|
mv.photo_url,
|
||||||
|
COALESCE(m.sort_order, 2147483647) AS sort_order,
|
||||||
|
mv.created_at,
|
||||||
|
mv.valid_from AS updated_at,
|
||||||
|
mv.valid_to AS history_valid_to,
|
||||||
|
mv.revision_no,
|
||||||
|
hr.created_at AS revision_created_at
|
||||||
|
FROM member_versions mv
|
||||||
|
LEFT JOIN members m
|
||||||
|
ON m.id = mv.member_id
|
||||||
|
LEFT JOIN history_revisions hr
|
||||||
|
ON hr.id = mv.revision_no
|
||||||
|
LEFT JOIN seat_assignment_versions sav
|
||||||
|
ON sav.member_id = mv.member_id
|
||||||
|
AND sav.valid_from <= %s
|
||||||
|
AND (sav.valid_to IS NULL OR sav.valid_to > %s)
|
||||||
|
WHERE mv.valid_from <= %s
|
||||||
|
AND (mv.valid_to IS NULL OR mv.valid_to > %s)
|
||||||
|
ORDER BY COALESCE(m.sort_order, 2147483647) ASC, mv.member_id ASC
|
||||||
|
""",
|
||||||
|
(as_of, as_of, as_of, as_of),
|
||||||
|
)
|
||||||
|
return cur.fetchall()
|
||||||
13
backend/app/routes/__init__.py
Normal file
13
backend/app/routes/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
from .auth import register_auth_routes
|
||||||
|
from .integration import register_integration_routes
|
||||||
|
from .organization import register_member_routes
|
||||||
|
from .seatmap import register_seatmap_routes
|
||||||
|
from .system import register_system_routes
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"register_auth_routes",
|
||||||
|
"register_integration_routes",
|
||||||
|
"register_member_routes",
|
||||||
|
"register_seatmap_routes",
|
||||||
|
"register_system_routes",
|
||||||
|
]
|
||||||
3
backend/app/routes/auth.py
Normal file
3
backend/app/routes/auth.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from ..auth_routes import register_auth_routes
|
||||||
|
|
||||||
|
__all__ = ["register_auth_routes"]
|
||||||
3
backend/app/routes/integration.py
Normal file
3
backend/app/routes/integration.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from ..integration_routes import register_integration_routes
|
||||||
|
|
||||||
|
__all__ = ["register_integration_routes"]
|
||||||
3
backend/app/routes/organization.py
Normal file
3
backend/app/routes/organization.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from ..member_routes import register_member_routes
|
||||||
|
|
||||||
|
__all__ = ["register_member_routes"]
|
||||||
3
backend/app/routes/seatmap.py
Normal file
3
backend/app/routes/seatmap.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from ..seatmap_routes import register_seatmap_routes
|
||||||
|
|
||||||
|
__all__ = ["register_seatmap_routes"]
|
||||||
3
backend/app/routes/system.py
Normal file
3
backend/app/routes/system.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from ..system_routes import register_system_routes
|
||||||
|
|
||||||
|
__all__ = ["register_system_routes"]
|
||||||
15
backend/app/schemas/__init__.py
Normal file
15
backend/app/schemas/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
from .organization import (
|
||||||
|
MemberBulkPayload,
|
||||||
|
MemberPayload,
|
||||||
|
SeatLayoutPayload,
|
||||||
|
SeatLayoutPlacementPayload,
|
||||||
|
SeatMapPayload,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"MemberBulkPayload",
|
||||||
|
"MemberPayload",
|
||||||
|
"SeatLayoutPayload",
|
||||||
|
"SeatLayoutPlacementPayload",
|
||||||
|
"SeatMapPayload",
|
||||||
|
]
|
||||||
58
backend/app/schemas/organization.py
Normal file
58
backend/app/schemas/organization.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class MemberPayload(BaseModel):
|
||||||
|
id: int | None = None
|
||||||
|
name: str = Field(min_length=1)
|
||||||
|
employee_id: str = ""
|
||||||
|
company: str = ""
|
||||||
|
rank: str = ""
|
||||||
|
role: str = ""
|
||||||
|
department: str = ""
|
||||||
|
grp: str = ""
|
||||||
|
division: str = ""
|
||||||
|
team: str = ""
|
||||||
|
cell: str = ""
|
||||||
|
work_status: str = ""
|
||||||
|
work_time: str = ""
|
||||||
|
phone: str = ""
|
||||||
|
email: str = ""
|
||||||
|
seat_label: str = ""
|
||||||
|
photo_url: str = ""
|
||||||
|
sort_order: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class MemberBulkPayload(BaseModel):
|
||||||
|
items: list[MemberPayload]
|
||||||
|
|
||||||
|
|
||||||
|
class SeatMapPayload(BaseModel):
|
||||||
|
name: str = Field(min_length=1)
|
||||||
|
image_url: str = ""
|
||||||
|
source_type: str = "image"
|
||||||
|
source_url: str = ""
|
||||||
|
preview_svg: str = ""
|
||||||
|
view_box_min_x: float | None = None
|
||||||
|
view_box_min_y: float | None = None
|
||||||
|
view_box_width: float | None = None
|
||||||
|
view_box_height: float | None = None
|
||||||
|
image_width: int | None = None
|
||||||
|
image_height: int | None = None
|
||||||
|
grid_rows: int = Field(default=1, ge=1, le=200)
|
||||||
|
grid_cols: int = Field(default=1, ge=1, le=200)
|
||||||
|
cell_gap: int = Field(default=0, ge=0, le=24)
|
||||||
|
is_active: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class SeatLayoutPlacementPayload(BaseModel):
|
||||||
|
member_id: int
|
||||||
|
seat_slot_id: int | None = None
|
||||||
|
row_index: int = Field(default=0, ge=0)
|
||||||
|
col_index: int = Field(default=0, ge=0)
|
||||||
|
seat_label: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class SeatLayoutPayload(BaseModel):
|
||||||
|
placements: list[SeatLayoutPlacementPayload]
|
||||||
21
backend/app/services/__init__.py
Normal file
21
backend/app/services/__init__.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
from .organization import (
|
||||||
|
create_history_revision,
|
||||||
|
merge_import_member,
|
||||||
|
normalize_phone,
|
||||||
|
pick_existing_member,
|
||||||
|
replace_members,
|
||||||
|
serialize_member_payload,
|
||||||
|
sync_member_versions,
|
||||||
|
sync_seat_assignment_versions,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"create_history_revision",
|
||||||
|
"merge_import_member",
|
||||||
|
"normalize_phone",
|
||||||
|
"pick_existing_member",
|
||||||
|
"replace_members",
|
||||||
|
"serialize_member_payload",
|
||||||
|
"sync_member_versions",
|
||||||
|
"sync_seat_assignment_versions",
|
||||||
|
]
|
||||||
350
backend/app/services/organization.py
Normal file
350
backend/app/services/organization.py
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
from ..repositories import (
|
||||||
|
fetch_current_member_state,
|
||||||
|
fetch_current_seat_assignments,
|
||||||
|
fetch_history_revision_created_at,
|
||||||
|
fetch_members,
|
||||||
|
)
|
||||||
|
from ..schemas import MemberPayload
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_phone(value: object) -> str:
|
||||||
|
raw = str(value or "").strip()
|
||||||
|
digits = "".join(ch for ch in raw if ch.isdigit())
|
||||||
|
if not digits:
|
||||||
|
return ""
|
||||||
|
if len(digits) == 10 and not digits.startswith("0"):
|
||||||
|
digits = f"0{digits}"
|
||||||
|
if len(digits) == 11 and digits.startswith("0"):
|
||||||
|
return f"{digits[:3]}-{digits[3:7]}-{digits[7:]}"
|
||||||
|
if len(digits) == 10 and digits.startswith("0"):
|
||||||
|
return f"{digits[:3]}-{digits[3:6]}-{digits[6:]}"
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_member_payload(item, sort_order: int) -> tuple[object, ...]:
|
||||||
|
return (
|
||||||
|
item.name.strip(),
|
||||||
|
item.employee_id.strip(),
|
||||||
|
item.company.strip(),
|
||||||
|
item.rank.strip(),
|
||||||
|
item.role.strip(),
|
||||||
|
item.department.strip(),
|
||||||
|
item.grp.strip(),
|
||||||
|
item.division.strip(),
|
||||||
|
item.team.strip(),
|
||||||
|
item.cell.strip(),
|
||||||
|
item.work_status.strip(),
|
||||||
|
item.work_time.strip(),
|
||||||
|
normalize_phone(item.phone),
|
||||||
|
item.email.strip(),
|
||||||
|
item.seat_label.strip(),
|
||||||
|
item.photo_url.strip(),
|
||||||
|
sort_order,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_history_revision(cur, label_prefix: str, note: str, *, app_timezone) -> int:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO history_revisions (scope, revision_label, note)
|
||||||
|
VALUES ('organization', %s, %s)
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
(f"{label_prefix}-{datetime.now(app_timezone).strftime('%Y%m%d-%H%M%S-%f')}", note),
|
||||||
|
)
|
||||||
|
return int(cur.fetchone()["id"])
|
||||||
|
|
||||||
|
|
||||||
|
def sync_member_versions(
|
||||||
|
cur,
|
||||||
|
member_ids: list[int],
|
||||||
|
change_reason: str,
|
||||||
|
revision_no: int,
|
||||||
|
effective_at: datetime | None = None,
|
||||||
|
) -> None:
|
||||||
|
if not member_ids:
|
||||||
|
return
|
||||||
|
event_at = effective_at or datetime.now(timezone.utc)
|
||||||
|
unique_ids = sorted(set(int(member_id) for member_id in member_ids))
|
||||||
|
current_members = fetch_current_member_state(cur)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, member_id, name, company, rank, role, department, grp, division, team, cell,
|
||||||
|
work_status, work_time, phone, email, photo_url, valid_from, valid_to
|
||||||
|
FROM member_versions
|
||||||
|
WHERE member_id = ANY(%s)
|
||||||
|
AND valid_to IS NULL
|
||||||
|
""",
|
||||||
|
(unique_ids,),
|
||||||
|
)
|
||||||
|
active_versions = {int(row["member_id"]): row for row in cur.fetchall()}
|
||||||
|
|
||||||
|
for member_id in unique_ids:
|
||||||
|
current = current_members.get(member_id)
|
||||||
|
active = active_versions.get(member_id)
|
||||||
|
if current is None:
|
||||||
|
if active is not None:
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE member_versions SET valid_to = %s WHERE id = %s AND valid_to IS NULL",
|
||||||
|
(event_at, int(active["id"])),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
current_tuple = (
|
||||||
|
str(current.get("name") or ""),
|
||||||
|
str(current.get("company") or ""),
|
||||||
|
str(current.get("rank") or ""),
|
||||||
|
str(current.get("role") or ""),
|
||||||
|
str(current.get("department") or ""),
|
||||||
|
str(current.get("grp") or ""),
|
||||||
|
str(current.get("division") or ""),
|
||||||
|
str(current.get("team") or ""),
|
||||||
|
str(current.get("cell") or ""),
|
||||||
|
str(current.get("work_status") or ""),
|
||||||
|
str(current.get("work_time") or ""),
|
||||||
|
str(current.get("phone") or ""),
|
||||||
|
str(current.get("email") or ""),
|
||||||
|
str(current.get("photo_url") or ""),
|
||||||
|
)
|
||||||
|
active_tuple = None
|
||||||
|
if active is not None:
|
||||||
|
active_tuple = (
|
||||||
|
str(active.get("name") or ""),
|
||||||
|
str(active.get("company") or ""),
|
||||||
|
str(active.get("rank") or ""),
|
||||||
|
str(active.get("role") or ""),
|
||||||
|
str(active.get("department") or ""),
|
||||||
|
str(active.get("grp") or ""),
|
||||||
|
str(active.get("division") or ""),
|
||||||
|
str(active.get("team") or ""),
|
||||||
|
str(active.get("cell") or ""),
|
||||||
|
str(active.get("work_status") or ""),
|
||||||
|
str(active.get("work_time") or ""),
|
||||||
|
str(active.get("phone") or ""),
|
||||||
|
str(active.get("email") or ""),
|
||||||
|
str(active.get("photo_url") or ""),
|
||||||
|
)
|
||||||
|
if active_tuple == current_tuple:
|
||||||
|
continue
|
||||||
|
if active is not None:
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE member_versions SET valid_to = %s WHERE id = %s AND valid_to IS NULL",
|
||||||
|
(event_at, int(active["id"])),
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO member_versions (
|
||||||
|
member_id, name, company, rank, role, department, grp, division, team, cell,
|
||||||
|
work_status, work_time, phone, email, photo_url,
|
||||||
|
valid_from, valid_to, revision_no, changed_by_user_id, change_reason
|
||||||
|
)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NULL, %s, NULL, %s)
|
||||||
|
""",
|
||||||
|
(member_id, *current_tuple, event_at, revision_no, change_reason),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def sync_seat_assignment_versions(
|
||||||
|
cur,
|
||||||
|
member_ids: list[int],
|
||||||
|
change_reason: str,
|
||||||
|
revision_no: int,
|
||||||
|
effective_at: datetime | None = None,
|
||||||
|
) -> None:
|
||||||
|
if not member_ids:
|
||||||
|
return
|
||||||
|
event_at = effective_at or datetime.now(timezone.utc)
|
||||||
|
unique_ids = sorted(set(int(member_id) for member_id in member_ids))
|
||||||
|
current_assignments = fetch_current_seat_assignments(cur)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, member_id, seat_map_id, seat_slot_id, seat_label
|
||||||
|
FROM seat_assignment_versions
|
||||||
|
WHERE member_id = ANY(%s)
|
||||||
|
AND valid_to IS NULL
|
||||||
|
""",
|
||||||
|
(unique_ids,),
|
||||||
|
)
|
||||||
|
active_versions = {int(row["member_id"]): row for row in cur.fetchall()}
|
||||||
|
|
||||||
|
for member_id in unique_ids:
|
||||||
|
current = current_assignments.get(member_id)
|
||||||
|
active = active_versions.get(member_id)
|
||||||
|
current_tuple = None
|
||||||
|
if current is not None:
|
||||||
|
current_tuple = (
|
||||||
|
current.get("seat_map_id"),
|
||||||
|
current.get("seat_slot_id"),
|
||||||
|
str(current.get("seat_label") or ""),
|
||||||
|
)
|
||||||
|
active_tuple = None
|
||||||
|
if active is not None:
|
||||||
|
active_tuple = (
|
||||||
|
active.get("seat_map_id"),
|
||||||
|
active.get("seat_slot_id"),
|
||||||
|
str(active.get("seat_label") or ""),
|
||||||
|
)
|
||||||
|
if active_tuple == current_tuple:
|
||||||
|
continue
|
||||||
|
if active is not None:
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE seat_assignment_versions SET valid_to = %s WHERE id = %s AND valid_to IS NULL",
|
||||||
|
(event_at, int(active["id"])),
|
||||||
|
)
|
||||||
|
if current is None:
|
||||||
|
continue
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO seat_assignment_versions (
|
||||||
|
member_id, seat_map_id, seat_slot_id, seat_label,
|
||||||
|
valid_from, valid_to, revision_no, changed_by_user_id, change_reason
|
||||||
|
)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, NULL, %s, NULL, %s)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
member_id,
|
||||||
|
current.get("seat_map_id"),
|
||||||
|
current.get("seat_slot_id"),
|
||||||
|
str(current.get("seat_label") or ""),
|
||||||
|
event_at,
|
||||||
|
revision_no,
|
||||||
|
change_reason,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def merge_import_member(item: MemberPayload, existing: dict[str, object] | None) -> MemberPayload:
|
||||||
|
if existing is None:
|
||||||
|
return item
|
||||||
|
|
||||||
|
payload = item.model_copy(deep=True)
|
||||||
|
if not payload.photo_url.strip():
|
||||||
|
payload.photo_url = str(existing.get("photo_url") or "")
|
||||||
|
if not payload.seat_label.strip():
|
||||||
|
payload.seat_label = str(existing.get("seat_label") or "")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def pick_existing_member(
|
||||||
|
item: MemberPayload,
|
||||||
|
existing_by_employee_id: dict[str, list[dict[str, object]]],
|
||||||
|
existing_by_name: dict[str, list[dict[str, object]]],
|
||||||
|
matched_ids: set[int],
|
||||||
|
) -> dict[str, object] | None:
|
||||||
|
employee_id = item.employee_id.strip()
|
||||||
|
if employee_id:
|
||||||
|
for candidate in existing_by_employee_id.get(employee_id, []):
|
||||||
|
candidate_id = int(candidate["id"])
|
||||||
|
if candidate_id not in matched_ids:
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
name = item.name.strip()
|
||||||
|
if name:
|
||||||
|
available = [
|
||||||
|
candidate
|
||||||
|
for candidate in existing_by_name.get(name, [])
|
||||||
|
if int(candidate["id"]) not in matched_ids
|
||||||
|
]
|
||||||
|
if len(available) == 1:
|
||||||
|
return available[0]
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def replace_members(
|
||||||
|
items: list[MemberPayload],
|
||||||
|
*,
|
||||||
|
get_conn,
|
||||||
|
sync_auth_users_from_members: Callable[[object], None],
|
||||||
|
app_timezone,
|
||||||
|
) -> list[dict[str, object]]:
|
||||||
|
with get_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, name, employee_id, company, rank, role, department, grp, division, team, cell,
|
||||||
|
work_status, work_time, phone, email, seat_label, photo_url,
|
||||||
|
sort_order, created_at, updated_at
|
||||||
|
FROM members
|
||||||
|
ORDER BY id ASC
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
existing_members = cur.fetchall()
|
||||||
|
|
||||||
|
existing_by_employee_id: dict[str, list[dict[str, object]]] = {}
|
||||||
|
existing_by_name: dict[str, list[dict[str, object]]] = {}
|
||||||
|
for member in existing_members:
|
||||||
|
employee_id = str(member.get("employee_id") or "").strip()
|
||||||
|
name = str(member.get("name") or "").strip()
|
||||||
|
if employee_id:
|
||||||
|
existing_by_employee_id.setdefault(employee_id, []).append(member)
|
||||||
|
if name:
|
||||||
|
existing_by_name.setdefault(name, []).append(member)
|
||||||
|
|
||||||
|
matched_ids: set[int] = set()
|
||||||
|
for index, item in enumerate(items):
|
||||||
|
existing = pick_existing_member(item, existing_by_employee_id, existing_by_name, matched_ids)
|
||||||
|
merged_item = merge_import_member(item, existing)
|
||||||
|
if existing is None:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO members (
|
||||||
|
name, employee_id, company, rank, role, department, grp, division, team, cell,
|
||||||
|
work_status, work_time, phone, email, seat_label, photo_url, sort_order
|
||||||
|
)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
|
""",
|
||||||
|
serialize_member_payload(merged_item, index),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
matched_ids.add(int(existing["id"]))
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE members
|
||||||
|
SET name = %s,
|
||||||
|
employee_id = %s,
|
||||||
|
company = %s,
|
||||||
|
rank = %s,
|
||||||
|
role = %s,
|
||||||
|
department = %s,
|
||||||
|
grp = %s,
|
||||||
|
division = %s,
|
||||||
|
team = %s,
|
||||||
|
cell = %s,
|
||||||
|
work_status = %s,
|
||||||
|
work_time = %s,
|
||||||
|
phone = %s,
|
||||||
|
email = %s,
|
||||||
|
seat_label = %s,
|
||||||
|
photo_url = %s,
|
||||||
|
sort_order = %s,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(*serialize_member_payload(merged_item, index), int(existing["id"])),
|
||||||
|
)
|
||||||
|
stale_ids = [int(member["id"]) for member in existing_members if int(member["id"]) not in matched_ids]
|
||||||
|
if stale_ids:
|
||||||
|
cur.execute("DELETE FROM members WHERE id = ANY(%s)", (stale_ids,))
|
||||||
|
sync_auth_users_from_members(cur)
|
||||||
|
cur.execute("SELECT id FROM members")
|
||||||
|
current_ids = [int(row["id"]) for row in cur.fetchall()]
|
||||||
|
affected_member_ids = sorted(set(current_ids + [int(member["id"]) for member in existing_members]))
|
||||||
|
if affected_member_ids:
|
||||||
|
revision_no = create_history_revision(
|
||||||
|
cur,
|
||||||
|
"members-bulk-sync",
|
||||||
|
"Bulk member sync applied",
|
||||||
|
app_timezone=app_timezone,
|
||||||
|
)
|
||||||
|
revision_created_at = fetch_history_revision_created_at(cur, revision_no)
|
||||||
|
sync_member_versions(cur, affected_member_ids, "members-bulk-sync", revision_no, revision_created_at)
|
||||||
|
sync_seat_assignment_versions(cur, affected_member_ids, "members-bulk-sync", revision_no, revision_created_at)
|
||||||
|
conn.commit()
|
||||||
|
return fetch_members(get_conn)
|
||||||
@@ -286,9 +286,9 @@ API 보호 예시:
|
|||||||
기존 프론트엔드의 mock 로그인 제거.
|
기존 프론트엔드의 mock 로그인 제거.
|
||||||
|
|
||||||
변경 대상:
|
변경 대상:
|
||||||
- [frontend/public/app.js](/home/hyunho/projects/mh-dashboard-organization/frontend/public/app.js)
|
- [frontend/public/app.js](../frontend/public/app.js)
|
||||||
- [backend/app/main.py](/home/hyunho/projects/mh-dashboard-organization/backend/app/main.py)
|
- [backend/app/main.py](../backend/app/main.py)
|
||||||
- [backend/app/config.py](/home/hyunho/projects/mh-dashboard-organization/backend/app/config.py)
|
- [backend/app/config.py](../backend/app/config.py)
|
||||||
|
|
||||||
### Phase 4
|
### Phase 4
|
||||||
|
|
||||||
|
|||||||
@@ -23,19 +23,10 @@
|
|||||||
- 이 저장소를 서버로 복사합니다.
|
- 이 저장소를 서버로 복사합니다.
|
||||||
- `.env.example`을 기준으로 `.env` 파일을 만들고 실제 DB 비밀번호를 넣습니다.
|
- `.env.example`을 기준으로 `.env` 파일을 만들고 실제 DB 비밀번호를 넣습니다.
|
||||||
|
|
||||||
## 4-1. 현재 로컬 PC 기준 WSL 작업 표준
|
## 4-1. 로컬 개발 환경 원칙
|
||||||
- 현재 로컬 개발 서버는 `WSL2 + Ubuntu-24.04` 기준으로 구성했습니다.
|
- 로컬 개발은 Linux 계열 개발 환경을 권장합니다.
|
||||||
- 기본 작업 사용자는 `hyunho` 입니다.
|
- Docker, Python, 파일 경로가 실제 배포 환경과 최대한 비슷한 환경에서 작업하는 것이 안전합니다.
|
||||||
- 앞으로의 기준 작업 경로는 아래입니다.
|
- Windows 호스트를 사용하는 경우에도, 실제 실행 경로와 편집 경로가 어긋나지 않도록 주의합니다.
|
||||||
- `/home/hyunho/projects/mh-dashboard-organization`
|
|
||||||
- Windows 폴더는 원본 참고용으로 남아 있을 수 있지만, 실제 실행과 개발 기준은 WSL 내부 경로로 맞추는 것을 권장합니다.
|
|
||||||
|
|
||||||
## 4-2. VS Code는 어떤 경로를 열어야 하나
|
|
||||||
- VS Code 좌측 아래에 `WSL: Ubuntu-24.04` 가 보이는 상태로 여는 것이 가장 안전합니다.
|
|
||||||
- VS Code에서 `Remote-WSL: Reopen Folder in WSL` 기능으로 다시 열 수 있습니다.
|
|
||||||
- 다시 열어야 할 권장 경로는 아래입니다.
|
|
||||||
- `/home/hyunho/projects/mh-dashboard-organization`
|
|
||||||
- 이렇게 열면 Docker, Python, Linux 경로, 실행 환경이 실제 서버와 가장 비슷하게 맞춰집니다.
|
|
||||||
|
|
||||||
## 5. Docker 설치 관련 메모
|
## 5. Docker 설치 관련 메모
|
||||||
- 메신저에 공유된 명령어는 Ubuntu 서버에 Docker를 설치하기 위한 절차입니다.
|
- 메신저에 공유된 명령어는 Ubuntu 서버에 Docker를 설치하기 위한 절차입니다.
|
||||||
@@ -73,8 +64,6 @@
|
|||||||
## 10. 현재 로컬 테스트 접속 정보
|
## 10. 현재 로컬 테스트 접속 정보
|
||||||
- 접속 주소: `http://localhost:8080`
|
- 접속 주소: `http://localhost:8080`
|
||||||
- 상태 확인 API: `http://localhost:8080/api/health`
|
- 상태 확인 API: `http://localhost:8080/api/health`
|
||||||
- WSL 내부 실행 경로:
|
|
||||||
- `/home/hyunho/projects/mh-dashboard-organization`
|
|
||||||
|
|
||||||
## 11. 운영 검증 체크포인트
|
## 11. 운영 검증 체크포인트
|
||||||
- 엑셀 또는 CSV 업로드 후 `GET /api/members` 에서 데이터가 조회되는지 확인합니다.
|
- 엑셀 또는 CSV 업로드 후 `GET /api/members` 에서 데이터가 조회되는지 확인합니다.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
이 문서는 `total` 브랜치에서 진행한 통합 작업을 기능 단위로 정리한 개발 히스토리다.
|
이 문서는 이 저장소에서 진행한 통합 작업을 기능 단위로 정리한 개발 히스토리다.
|
||||||
목표는 다음 두 가지다.
|
목표는 다음 두 가지다.
|
||||||
|
|
||||||
- 지금까지 어떤 기능을 어떤 방식으로 붙였는지 빠르게 파악
|
- 지금까지 어떤 기능을 어떤 방식으로 붙였는지 빠르게 파악
|
||||||
@@ -161,12 +161,12 @@
|
|||||||
|
|
||||||
### 작업 내용
|
### 작업 내용
|
||||||
|
|
||||||
- WSL 내부 `0.0.0.0:8080` 바인딩 확인
|
- 개발 환경의 외부 접속 경로를 정리
|
||||||
- Windows host에서 `portproxy`와 방화벽 규칙으로 다른 PC 접속 가능하게 정리
|
- 호스트 방화벽과 포트 포워딩 규칙으로 다른 PC 접속 가능하게 구성
|
||||||
|
|
||||||
### 유의사항
|
### 유의사항
|
||||||
|
|
||||||
- Windows LAN IP 또는 WSL IP가 바뀌면 `portproxy`의 `connectaddress`는 다시 맞춰야 한다
|
- 호스트 IP나 포워딩 대상 IP가 바뀌면 포트 포워딩 설정을 다시 맞춰야 한다
|
||||||
- 운영 안정성을 위해 향후 자동화 스크립트화가 필요함
|
- 운영 안정성을 위해 향후 자동화 스크립트화가 필요함
|
||||||
|
|
||||||
## 10. 인증 기본 구조 추가
|
## 10. 인증 기본 구조 추가
|
||||||
@@ -226,7 +226,7 @@
|
|||||||
|
|
||||||
### 설계 문서
|
### 설계 문서
|
||||||
|
|
||||||
- [HISTORY_ASOF_DB_PLAN.md](/home/hyunho/projects/mh-dashboard-organization/docs/HISTORY_ASOF_DB_PLAN.md)
|
- [HISTORY_ASOF_DB_PLAN.md](HISTORY_ASOF_DB_PLAN.md)
|
||||||
|
|
||||||
## Next Focus
|
## Next Focus
|
||||||
|
|
||||||
|
|||||||
@@ -10,15 +10,15 @@
|
|||||||
|
|
||||||
### 코드 경로
|
### 코드 경로
|
||||||
|
|
||||||
- 공개용 `8080`: `/home/hyunho/projects/mh-dashboard-organization`
|
- 공개용 `8080`: 메인 workspace
|
||||||
- 작업용 `8081`: `/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081`
|
- 작업용 `8081`: 메인 workspace 아래의 격리 worktree
|
||||||
|
|
||||||
### 작업용 Compose 기준
|
### 작업용 Compose 기준
|
||||||
|
|
||||||
- 공개용 `8080` stack: `docker-compose.yml`
|
- 공개용 `8080` stack: `docker-compose.yml`
|
||||||
- 작업용 `8081` stack: `docker-compose.8081.yml`
|
- 작업용 `8081` stack: `docker-compose.8081.yml`
|
||||||
- 작업용 project name 기본값: `mh-dashboard-organization-dev`
|
- 작업용 project name 기본값: `mh-dashboard-organization-dev`
|
||||||
- 작업용 `8081`는 반드시 `/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081`에서 띄운다
|
- 작업용 `8081`는 반드시 격리된 worktree에서 띄운다
|
||||||
|
|
||||||
### DB 볼륨
|
### DB 볼륨
|
||||||
|
|
||||||
@@ -102,7 +102,7 @@
|
|||||||
1. `8080`과 `8081` 모두 기동 상태 확인
|
1. `8080`과 `8081` 모두 기동 상태 확인
|
||||||
2. 이번 작업이 `코드 변경`인지 `데이터 변경`인지 먼저 구분
|
2. 이번 작업이 `코드 변경`인지 `데이터 변경`인지 먼저 구분
|
||||||
3. 공개용 기준 데이터가 필요한 화면이면 `8081` DB를 먼저 `8080` 기준으로 맞춤
|
3. 공개용 기준 데이터가 필요한 화면이면 `8081` DB를 먼저 `8080` 기준으로 맞춤
|
||||||
4. 작업 전후 검증은 [REGRESSION_CHECKLIST.md](/home/hyunho/projects/mh-dashboard-organization/docs/REGRESSION_CHECKLIST.md) 기준으로 수행
|
4. 작업 전후 검증은 [REGRESSION_CHECKLIST.md](REGRESSION_CHECKLIST.md) 기준으로 수행
|
||||||
|
|
||||||
### 2. 기능 개발 중
|
### 2. 기능 개발 중
|
||||||
|
|
||||||
@@ -154,7 +154,7 @@
|
|||||||
2. 공개용 기준 데이터가 필요한지 판단
|
2. 공개용 기준 데이터가 필요한지 판단
|
||||||
3. 필요하면 `8081` DB를 `8080` 기준으로 먼저 동기화
|
3. 필요하면 `8081` DB를 `8080` 기준으로 먼저 동기화
|
||||||
4. 그 뒤 기능 개발과 검증 수행
|
4. 그 뒤 기능 개발과 검증 수행
|
||||||
5. 검증은 [REGRESSION_CHECKLIST.md](/home/hyunho/projects/mh-dashboard-organization/docs/REGRESSION_CHECKLIST.md) 기준으로 수행
|
5. 검증은 [REGRESSION_CHECKLIST.md](REGRESSION_CHECKLIST.md) 기준으로 수행
|
||||||
6. 검증 완료 후 공개용에 코드 승격
|
6. 검증 완료 후 공개용에 코드 승격
|
||||||
|
|
||||||
## 다음 액션
|
## 다음 액션
|
||||||
@@ -167,14 +167,14 @@
|
|||||||
|
|
||||||
반복 가능한 동기화 스크립트:
|
반복 가능한 동기화 스크립트:
|
||||||
|
|
||||||
- [sync_prod_db_to_dev.sh](/home/hyunho/projects/mh-dashboard-organization/scripts/sync_prod_db_to_dev.sh)
|
- [sync_prod_db_to_dev.sh](../scripts/sync_prod_db_to_dev.sh)
|
||||||
- [docker-compose.8081.yml](/home/hyunho/projects/mh-dashboard-organization/docker-compose.8081.yml)
|
- [docker-compose.8081.yml](../docker-compose.8081.yml)
|
||||||
|
|
||||||
사용 방법:
|
사용 방법:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./scripts/prepare_dev_worktree.sh
|
./scripts/prepare_dev_worktree.sh
|
||||||
cd /home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081
|
cd <repo>/.dev-worktree-8081
|
||||||
docker compose -p mh-dashboard-organization-dev --env-file .env -f docker-compose.8081.yml up -d --build
|
docker compose -p mh-dashboard-organization-dev --env-file .env -f docker-compose.8081.yml up -d --build
|
||||||
./scripts/sync_prod_db_to_dev.sh minimal
|
./scripts/sync_prod_db_to_dev.sh minimal
|
||||||
./scripts/sync_prod_db_to_dev.sh full
|
./scripts/sync_prod_db_to_dev.sh full
|
||||||
@@ -194,8 +194,8 @@ docker compose -p mh-dashboard-organization-dev --env-file .env -f docker-compos
|
|||||||
중요:
|
중요:
|
||||||
|
|
||||||
- `8081`은 현재 메인 workspace를 직접 마운트하면 안 된다
|
- `8081`은 현재 메인 workspace를 직접 마운트하면 안 된다
|
||||||
- 컨테이너가 `/home/hyunho/projects/mh-dashboard-organization/...`를 물고 있으면 분리 상태가 깨진 것이다
|
- 컨테이너가 메인 workspace를 직접 물고 있으면 분리 상태가 깨진 것이다
|
||||||
- 정상 상태는 `docker inspect mh-dashboard-organization-dev-backend-1` 기준 마운트 소스가 `/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/...`로 나와야 한다
|
- 정상 상태는 `docker inspect mh-dashboard-organization-dev-backend-1` 기준 마운트 소스가 격리 worktree 경로로 나와야 한다
|
||||||
|
|
||||||
규칙:
|
규칙:
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
- 2026-03-27 기준 `curl http://localhost:8080/api/health` 정상
|
- 2026-03-27 기준 `curl http://localhost:8080/api/health` 정상
|
||||||
- 2026-03-27 기준 `curl http://localhost:8080/api/members` 에서 `items` 비어 있지 않음
|
- 2026-03-27 기준 `curl http://localhost:8080/api/members` 에서 `items` 비어 있지 않음
|
||||||
- 다른 PC 접속도 현재 확인됨
|
- 다른 PC 접속도 현재 확인됨
|
||||||
- 개발/운영 DB 분리 운영 원칙은 [DEV_PROD_DB_PROTOCOL.md](/home/hyunho/projects/mh-dashboard-organization/docs/DEV_PROD_DB_PROTOCOL.md) 기준으로 관리
|
- 개발/운영 DB 분리 운영 원칙은 [DEV_PROD_DB_PROTOCOL.md](DEV_PROD_DB_PROTOCOL.md) 기준으로 관리
|
||||||
|
|
||||||
## 1. 컨테이너 기동
|
## 1. 컨테이너 기동
|
||||||
- `docker compose build`
|
- `docker compose build`
|
||||||
|
|||||||
@@ -1,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` 까지 확인
|
|
||||||
@@ -15,8 +15,8 @@
|
|||||||
|
|
||||||
관련 문서:
|
관련 문서:
|
||||||
|
|
||||||
- [DEV_PROD_DB_PROTOCOL.md](/home/hyunho/projects/mh-dashboard-organization/docs/DEV_PROD_DB_PROTOCOL.md)
|
- [DEV_PROD_DB_PROTOCOL.md](DEV_PROD_DB_PROTOCOL.md)
|
||||||
- [INFRA_VALIDATION_CHECKLIST.md](/home/hyunho/projects/mh-dashboard-organization/docs/INFRA_VALIDATION_CHECKLIST.md)
|
- [INFRA_VALIDATION_CHECKLIST.md](INFRA_VALIDATION_CHECKLIST.md)
|
||||||
|
|
||||||
## 작업 시작 전
|
## 작업 시작 전
|
||||||
|
|
||||||
|
|||||||
159
docs/TEAM_GUIDE.md
Normal file
159
docs/TEAM_GUIDE.md
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
# Team Guide
|
||||||
|
|
||||||
|
이 문서는 이 저장소에서 작업할 때 가장 먼저 읽는 기준 문서다.
|
||||||
|
|
||||||
|
목표는 세 가지다.
|
||||||
|
|
||||||
|
- 어디를 수정해야 하는지 바로 알 수 있게 하기
|
||||||
|
- `8080`과 `8081`을 헷갈리지 않게 하기
|
||||||
|
- 작업 순서와 검증 기준을 하나의 문서로 시작하게 하기
|
||||||
|
|
||||||
|
## 1. 먼저 이해할 구조
|
||||||
|
|
||||||
|
- `frontend/apps/*`
|
||||||
|
- 화면별 source-of-truth
|
||||||
|
- `incoming-files/served/*`
|
||||||
|
- integration 화면의 실제 런타임 파일
|
||||||
|
- `legacy/static/*`
|
||||||
|
- 조직현황 레거시 런타임 파일
|
||||||
|
- `incoming-files/reference/*`
|
||||||
|
- 원본 참고 자산
|
||||||
|
- `backend/app/routes/*`
|
||||||
|
- API 엔드포인트 등록
|
||||||
|
- `backend/app/services/*`
|
||||||
|
- 비즈니스 로직
|
||||||
|
- `backend/app/repositories/*`
|
||||||
|
- DB 읽기 쿼리
|
||||||
|
|
||||||
|
원칙:
|
||||||
|
|
||||||
|
- source를 먼저 수정하고 runtime은 publish로 반영한다.
|
||||||
|
- reference 파일은 비교/복구용이지 직접 수정 기준이 아니다.
|
||||||
|
|
||||||
|
## 2. 환경 원칙
|
||||||
|
|
||||||
|
- `8080`
|
||||||
|
- 공개 기준 환경
|
||||||
|
- 기준 데이터가 있는 쪽
|
||||||
|
- `8081`
|
||||||
|
- 개발/검증 환경
|
||||||
|
- 먼저 수정하고 검증하는 쪽
|
||||||
|
|
||||||
|
중요:
|
||||||
|
|
||||||
|
- 새 작업은 항상 `.dev-worktree-8081` 기준으로 시작한다.
|
||||||
|
- `8081`에서 검증되지 않은 변경을 바로 `8080`에 올리지 않는다.
|
||||||
|
- `8081` DB는 독립 정본이 아니라 검증용 복제본처럼 다룬다.
|
||||||
|
|
||||||
|
자세한 DB 운영 원칙은 [DEV_PROD_DB_PROTOCOL.md](DEV_PROD_DB_PROTOCOL.md)를 따른다.
|
||||||
|
|
||||||
|
## 3. 작업 시작 순서
|
||||||
|
|
||||||
|
1. 현재 브랜치와 변경 파일을 확인한다.
|
||||||
|
2. 연결된 이슈 또는 작업 목적을 확인한다.
|
||||||
|
3. 이번 작업의 source 파일과 runtime 파일을 구분한다.
|
||||||
|
4. 필요한 경우 `8081` 개발 환경을 띄운다.
|
||||||
|
5. 필요한 DB 동기화 범위를 결정한다.
|
||||||
|
6. 수정 후 관련 시나리오를 검증한다.
|
||||||
|
|
||||||
|
핵심 질문:
|
||||||
|
|
||||||
|
- 지금 고치는 파일이 실제 source-of-truth가 맞는가?
|
||||||
|
- 이 작업은 `8081`에서 먼저 검증해야 하는가?
|
||||||
|
- DB 차이 때문에 생긴 문제는 아닌가?
|
||||||
|
|
||||||
|
## 4. 수정 원칙
|
||||||
|
|
||||||
|
- 한 작업은 한 기능 또는 한 버그 단위로 작게 나눈다.
|
||||||
|
- 완료 기능은 관련 이슈 없이 함부로 건드리지 않는다.
|
||||||
|
- 임시 우회 로직은 이유와 제거 계획이 있어야 한다.
|
||||||
|
- 구조를 정리하더라도 기존 동작을 바꾸면 안 된다.
|
||||||
|
|
||||||
|
고위험 영역:
|
||||||
|
|
||||||
|
- `members`
|
||||||
|
- `seat_maps`
|
||||||
|
- `seat_slots`
|
||||||
|
- `seat_positions`
|
||||||
|
- `auth.*`
|
||||||
|
- 동기화 스크립트
|
||||||
|
- 스키마 변경
|
||||||
|
|
||||||
|
이 영역은 변경 이유, 영향 범위, 검증 결과를 반드시 남긴다.
|
||||||
|
|
||||||
|
## 5. 화면별 수정 기준
|
||||||
|
|
||||||
|
### 조직현황
|
||||||
|
|
||||||
|
- source: `frontend/apps/organization`
|
||||||
|
- runtime: `DashBoard-organization.html`, `legacy/static/*`
|
||||||
|
- publish: `./scripts/publish_organization_app.sh`
|
||||||
|
|
||||||
|
### 프로젝트별 분석
|
||||||
|
|
||||||
|
- source: `frontend/apps/payment/index.html`
|
||||||
|
- runtime: `incoming-files/served/payment.html`
|
||||||
|
- publish: `./scripts/publish_payment_app.sh`
|
||||||
|
|
||||||
|
### 팀/개인별 분석
|
||||||
|
|
||||||
|
- source: `frontend/apps/team/index.html`
|
||||||
|
- runtime: `incoming-files/served/mh.html`
|
||||||
|
- publish: `./scripts/publish_team_app.sh`
|
||||||
|
|
||||||
|
### 사업관리대장
|
||||||
|
|
||||||
|
- source: `frontend/apps/ledger/*`
|
||||||
|
- runtime: `incoming-files/served/ledger/*`
|
||||||
|
- publish: `./scripts/publish_ledger_app.sh`
|
||||||
|
|
||||||
|
### DB 상태
|
||||||
|
|
||||||
|
- source: `frontend/apps/db-status/index.html`
|
||||||
|
- runtime: `incoming-files/served/db-status/index.html`
|
||||||
|
- publish: `./scripts/publish_db_status_app.sh`
|
||||||
|
|
||||||
|
실제 서빙 책임은 [architecture/8081_SERVING_MAP.md](architecture/8081_SERVING_MAP.md)에서 확인한다.
|
||||||
|
|
||||||
|
## 6. 디자인 수정 원칙
|
||||||
|
|
||||||
|
- 먼저 `frontend/public/design-tokens.css`
|
||||||
|
- 다음 `frontend/public/design-patterns.css`
|
||||||
|
- 그 다음 [architecture/DESIGN_SSOT.md](architecture/DESIGN_SSOT.md)
|
||||||
|
- 마지막으로 화면별 파일
|
||||||
|
|
||||||
|
금지:
|
||||||
|
|
||||||
|
- reference 파일부터 수정하기
|
||||||
|
- 토큰/패턴으로 해결 가능한 것을 화면별 하드코딩으로 처리하기
|
||||||
|
- 예전 색 체계를 새 기본값으로 다시 넣기
|
||||||
|
|
||||||
|
## 7. 검증 원칙
|
||||||
|
|
||||||
|
- 완료 기준은 “코드를 썼다”가 아니라 “실제 동작을 검증했다”이다.
|
||||||
|
- 구조 정리나 라우트 분리 후에는 `./scripts/check_8081_smoke.sh`를 먼저 본다.
|
||||||
|
- 기능 수정 후에는 관련 화면만 보지 말고 주변 연동까지 확인한다.
|
||||||
|
|
||||||
|
검증 세부 항목은 [REGRESSION_CHECKLIST.md](REGRESSION_CHECKLIST.md)를 따른다.
|
||||||
|
|
||||||
|
자주 쓰는 DB 동기화:
|
||||||
|
|
||||||
|
- 조직현황/멤버/자리배치: `./scripts/sync_prod_db_to_dev.sh minimal`
|
||||||
|
- 분석 화면: `./scripts/sync_prod_db_to_dev.sh analysis`
|
||||||
|
- 전체 재검증: `./scripts/sync_prod_db_to_dev.sh full`
|
||||||
|
|
||||||
|
## 8. 커밋과 PR
|
||||||
|
|
||||||
|
- 커밋은 한 주제만 담는다.
|
||||||
|
- PR 본문에는 작업 목적, 변경 범위, 검증 방법, DB 영향 여부를 적는다.
|
||||||
|
- 공용 구조 파일을 수정했으면 영향 화면을 명시한다.
|
||||||
|
|
||||||
|
자세한 팀 작업 규칙은 [../CONTRIBUTING.md](../CONTRIBUTING.md)를 따른다.
|
||||||
|
|
||||||
|
## 9. 이 문서 다음에 읽을 것
|
||||||
|
|
||||||
|
- 협업 방식: [../CONTRIBUTING.md](../CONTRIBUTING.md)
|
||||||
|
- DB 운영 원칙: [DEV_PROD_DB_PROTOCOL.md](DEV_PROD_DB_PROTOCOL.md)
|
||||||
|
- 회귀 검증: [REGRESSION_CHECKLIST.md](REGRESSION_CHECKLIST.md)
|
||||||
|
- 실제 서빙 책임: [architecture/8081_SERVING_MAP.md](architecture/8081_SERVING_MAP.md)
|
||||||
|
- 디자인 기준: [architecture/DESIGN_SSOT.md](architecture/DESIGN_SSOT.md)
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
# Today Work Prep - 2026-03-30
|
|
||||||
|
|
||||||
## Current Local State
|
|
||||||
|
|
||||||
- working branch: `total`
|
|
||||||
- HEAD: `24852d4` (`Fix seatmap slot matching and update member modal layout`)
|
|
||||||
- remote tracking: `origin/total`
|
|
||||||
- status: local branch is `ahead 2`
|
|
||||||
- open PRs: none
|
|
||||||
|
|
||||||
untracked files:
|
|
||||||
|
|
||||||
- `docs/HISTORY_ASOF_DB_PLAN.md`
|
|
||||||
- `incoming-files/6f.html`
|
|
||||||
- `incoming-files/7f.html`
|
|
||||||
- `incoming-files/center.html`
|
|
||||||
|
|
||||||
주의:
|
|
||||||
|
|
||||||
- `docs/NEXT_SESSION_CHECKPOINT.md` 의 최신 checked commit 은 아직 `1d15cf9` 로 남아 있다.
|
|
||||||
- 실제 최신 작업 판단은 아래 최근 2개 로컬 커밋 기준으로 보는 것이 맞다.
|
|
||||||
|
|
||||||
## What Was Added After `origin/total`
|
|
||||||
|
|
||||||
### Commit `d666141`
|
|
||||||
|
|
||||||
- 3개 고정 오피스 자리배치도 반영
|
|
||||||
- `technical-development-center`
|
|
||||||
- `hanmac-building-6f`
|
|
||||||
- `hanmac-building-7f`
|
|
||||||
- 백엔드 `office_key` 기반 active viewer/layout 조회 지원
|
|
||||||
- 프런트 자리배치도 탭에서 3개 오피스 선택 지원
|
|
||||||
- `scripts/sync_prod_db_to_dev.sh` 추가
|
|
||||||
- `docs/DEV_PROD_DB_PROTOCOL.md` 추가
|
|
||||||
|
|
||||||
### Commit `24852d4`
|
|
||||||
|
|
||||||
- slot 기반 자리 저장 시 slot matching 보정
|
|
||||||
- 멤버 상세 모달 / 조직도 seat preview 레이아웃 조정
|
|
||||||
- 회귀 점검용 `docs/REGRESSION_CHECKLIST.md` 추가
|
|
||||||
- dev/prod sync script 후속 보정
|
|
||||||
|
|
||||||
## Remote Branch / Issue Snapshot
|
|
||||||
|
|
||||||
remote branches:
|
|
||||||
|
|
||||||
- `total` -> `1d15cf9`
|
|
||||||
- `hyunho` -> `8efb5da`
|
|
||||||
- `main` -> `7a0bd54`
|
|
||||||
|
|
||||||
open issues:
|
|
||||||
|
|
||||||
- `#11` `[P0] [버그] 자리배치도 회귀 오류`
|
|
||||||
- `#12` `[P1] [DB] 공개용/작업용 seat_positions 스키마 불일치 정리`
|
|
||||||
- `#13` `[P1] [인프라] 작업용 DB 동기화 절차 안정화 및 자동화`
|
|
||||||
- `#14` `[P2] [리팩터링] 누적된 임시 로직 정리 및 중복 코드 제거`
|
|
||||||
- `#10` `[P1] [분석] 1~2월 원본 정합성 보정 및 팀/개인별 검색 범위 개선 작업 정리`
|
|
||||||
- `#9` `[P1] [이력관리] as-of date / 버전 누적 저장`
|
|
||||||
- `#8` `[P2] [자리배치도] 좌석 클릭 시 개인 상위 조직 트리 표시`
|
|
||||||
- `#7` `[P2] [자리배치도] 팀별 색상 오버레이 표시`
|
|
||||||
- `#5` `[P2] [인증] 권한 제어 마무리 및 mock login 정리`
|
|
||||||
- `#3` `[P1] [기능] 사무실 좌석 배치도 조회 및 관리자 편집 기능 고도화`
|
|
||||||
- `#2` `[P0] [인프라] 백엔드 영속 저장 구조 운영 마무리`
|
|
||||||
|
|
||||||
현재 관계 해석:
|
|
||||||
|
|
||||||
- `#11` 은 최근 2개 커밋이 직접 겨냥한 회귀 묶음이다.
|
|
||||||
- `#12`, `#13` 은 `#11` 재발 방지용 운영 과제에 가깝다.
|
|
||||||
- `#3` 은 다중 오피스 도면 반영으로 많이 진척됐지만, 공개용 기준 회귀 검증 전에는 완료 처리하면 안 된다.
|
|
||||||
- `#2` 는 단순 구현보다 dev/prod 데이터 운영 기준 정리가 핵심으로 바뀌었다.
|
|
||||||
- `#5` 는 로그인 구현보다 권한 경계와 `/api/mock-login` 정리가 남은 상태다.
|
|
||||||
|
|
||||||
## Best Starting Point Today
|
|
||||||
|
|
||||||
오늘 첫 작업은 새 기능 추가보다, 최근 자리배치도/DB 동기화 작업을 검증 가능한 상태로 굳히는 쪽이 우선이다.
|
|
||||||
|
|
||||||
우선순위:
|
|
||||||
|
|
||||||
1. `#13` 프로토콜대로 작업용 DB를 `minimal` 범위로 동기화
|
|
||||||
2. `docs/REGRESSION_CHECKLIST.md` 기준으로 자리배치도 회귀 확인
|
|
||||||
3. 최근 2개 로컬 커밋을 `origin/total` 에 올릴지 결정
|
|
||||||
4. 회귀가 남아 있으면 `#11` 계속, 없으면 `#5` 또는 `#12/#13` 후속 정리로 이동
|
|
||||||
|
|
||||||
이 순서가 맞는 이유:
|
|
||||||
|
|
||||||
- 현재 가장 최근 변경이 seatmap + DB sync 쪽에 몰려 있다.
|
|
||||||
- 원격 `total` 은 아직 해당 수정들을 포함하지 않는다.
|
|
||||||
- 검증 없이 다른 기능으로 넘어가면 회귀 원인과 신규 작업이 다시 섞인다.
|
|
||||||
|
|
||||||
## Concrete Start Checklist
|
|
||||||
|
|
||||||
세션 시작 즉시:
|
|
||||||
|
|
||||||
1. `docs/DEV_PROD_DB_PROTOCOL.md` 다시 확인
|
|
||||||
2. 필요 시 `./scripts/sync_prod_db_to_dev.sh minimal`
|
|
||||||
3. 로그인 상태 확인
|
|
||||||
4. 아래 3개를 오피스별로 확인
|
|
||||||
- 관리자 DnD 배치 저장
|
|
||||||
- 조직도 상세 seat preview
|
|
||||||
- 비관리자 seatmap 진입 / 표시
|
|
||||||
|
|
||||||
필수 확인 오피스:
|
|
||||||
|
|
||||||
- `기술개발센터`
|
|
||||||
- `한맥빌딩 6층`
|
|
||||||
- `한맥빌딩 7층`
|
|
||||||
|
|
||||||
## Recommended Decision Tree
|
|
||||||
|
|
||||||
### Case A. 회귀가 남아 있음
|
|
||||||
|
|
||||||
- 바로 `#11` 우선
|
|
||||||
- 동시에 원인 범주를 분리
|
|
||||||
- DB sync 실패
|
|
||||||
- `seat_positions` 스키마 차이
|
|
||||||
- 프런트 fallback 오류
|
|
||||||
- 저장 API 로직 오류
|
|
||||||
|
|
||||||
### Case B. 회귀가 해소됨
|
|
||||||
|
|
||||||
- 최근 2개 커밋 푸시
|
|
||||||
- Gitea `#11`, `#3`, `#2` 코멘트 상태 업데이트
|
|
||||||
- 다음 메인 작업을 아래 중 하나로 선택
|
|
||||||
- `#5` 권한 제어 / mock login 제거
|
|
||||||
- `#12`, `#13` DB sync 안정화 마무리
|
|
||||||
- `#9` history / as-of 구조 착수
|
|
||||||
|
|
||||||
## Suggested Main Task After Verification
|
|
||||||
|
|
||||||
가장 자연스러운 다음 메인 작업은 `#5` 보다 `#12`, `#13` 마무리다.
|
|
||||||
|
|
||||||
이유:
|
|
||||||
|
|
||||||
- 지금 이 코드베이스에서 자리배치도/조직도 검증은 DB 상태에 크게 좌우된다.
|
|
||||||
- 권한 작업을 시작해도 검증 기반이 흔들리면 다시 혼선이 생긴다.
|
|
||||||
- 반대로 sync 절차와 스키마 호환을 먼저 고정하면 이후 `#5`, `#9`, `#8`, `#7` 진행이 쉬워진다.
|
|
||||||
|
|
||||||
## Short Summary
|
|
||||||
|
|
||||||
- 코드 최신 상태는 로컬 `total@24852d4`
|
|
||||||
- 원격 `total` 은 아직 최신 seatmap/sync 수정 전 상태
|
|
||||||
- 오늘 첫 목표는 `#11` 관련 회귀 검증과 `#12/#13` 기반 정리
|
|
||||||
- 검증 완료 전에는 새 기능보다 seatmap + DB 운영 안정화를 우선하는 것이 맞다
|
|
||||||
@@ -1,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 유지`, `조직현황 탭`, `프로젝트/팀 탭`
|
|
||||||
@@ -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와 검증을 무시하지 않고, 기록을 남기면서 작업한다.
|
|
||||||
@@ -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`
|
|
||||||
@@ -61,7 +61,6 @@
|
|||||||
- 1차 정리에서는 기존 실제 서빙 파일을 `served/`에 복사하고, backend 서빙 경로를 먼저 `served/`로 갱신한다.
|
- 1차 정리에서는 기존 실제 서빙 파일을 `served/`에 복사하고, backend 서빙 경로를 먼저 `served/`로 갱신한다.
|
||||||
- `사업관리대장`은 `#21`부터 wrapper decode 방식 대신 `served/ledger/index.html`과 `served/ledger/*`를 직접 서빙한다.
|
- `사업관리대장`은 `#21`부터 wrapper decode 방식 대신 `served/ledger/index.html`과 `served/ledger/*`를 직접 서빙한다.
|
||||||
- `사업관리대장` 수정 원본은 `#21` 다음 단계부터 `frontend/apps/ledger/*`를 먼저 보고, `scripts/publish_ledger_app.sh`로 runtime served 파일에 반영한다.
|
- `사업관리대장` 수정 원본은 `#21` 다음 단계부터 `frontend/apps/ledger/*`를 먼저 보고, `scripts/publish_ledger_app.sh`로 runtime served 파일에 반영한다.
|
||||||
- 기존 루트 `incoming-files/payment.html`, `incoming-files/mh.html`는 안전한 비교/복구를 위해 당분간 남겨둔다.
|
|
||||||
|
|
||||||
## Seat Map
|
## Seat Map
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
이 문서는 `8081 / work-8081` 기준 현재 PostgreSQL 테이블 26개를 역할별로 분류한 운영 기준 문서다.
|
이 문서는 현재 PostgreSQL 테이블을 역할별로 분류한 운영 기준 문서다.
|
||||||
|
|
||||||
핵심 원칙:
|
핵심 원칙:
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
## Source of truth
|
## Source of truth
|
||||||
|
|
||||||
- Primary visual source: [incoming-files/sample style.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/incoming-files/sample%20style.css)
|
- Primary visual source: [incoming-files/sample style.css](../../incoming-files/sample%20style.css)
|
||||||
- Runtime token file: [design-tokens.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/design-tokens.css)
|
- Runtime token file: [design-tokens.css](../../frontend/public/design-tokens.css)
|
||||||
- Runtime pattern file: [design-patterns.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/design-patterns.css)
|
- Runtime pattern file: [design-patterns.css](../../frontend/public/design-patterns.css)
|
||||||
|
|
||||||
`sample style.css` defines the intended MH visual language. `design-tokens.css` is the token-level SSOT, and `design-patterns.css` is the component-level SSOT that packages those tokens into reusable runtime patterns.
|
`sample style.css` defines the intended MH visual language. `design-tokens.css` is the token-level SSOT, and `design-patterns.css` is the component-level SSOT that packages those tokens into reusable runtime patterns.
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,10 @@
|
|||||||
return new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
return new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function bgNormalizeText(value) {
|
||||||
|
return String(value || "").replace(/\s+/g, " ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
function bgParseDate(value) {
|
function bgParseDate(value) {
|
||||||
var text = String(value || "").trim();
|
var text = String(value || "").trim();
|
||||||
if (!text) return null;
|
if (!text) return null;
|
||||||
@@ -36,6 +40,44 @@
|
|||||||
return bgYearFromText(row && row.eDate);
|
return bgYearFromText(row && row.eDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizedCategory(row) {
|
||||||
|
var category = bgNormalizeText(row && row.cat);
|
||||||
|
if (category.indexOf("가족사") >= 0) return "가족사";
|
||||||
|
var corp = bgNormalizeText(row && row.corp);
|
||||||
|
if (corp && corp !== "바론") return "가족사";
|
||||||
|
return "바론";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSupportServiceRow(row) {
|
||||||
|
return bgNormalizeText(row && row.name).indexOf("경영 및 기술지원 서비스") >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectTypeLabel(row) {
|
||||||
|
if (isSupportServiceRow(row)) return "기술지원서비스";
|
||||||
|
return normalizedCategory(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectTypeRank(row) {
|
||||||
|
var label = projectTypeLabel(row);
|
||||||
|
if (label === "바론") return 0;
|
||||||
|
if (label === "가족사") return 1;
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStatusLabel(status) {
|
||||||
|
var value = bgNormalizeText(status);
|
||||||
|
if (!value) return "-";
|
||||||
|
if (value === "완료") return "준공";
|
||||||
|
if (value === "진행") return "과업진행중";
|
||||||
|
if (value === "대기") return "계약대기";
|
||||||
|
if (value === "중지") return "과업중지";
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowStatusLabel(row) {
|
||||||
|
return normalizeStatusLabel(row && row.status);
|
||||||
|
}
|
||||||
|
|
||||||
function bgDisplayYear(row) {
|
function bgDisplayYear(row) {
|
||||||
var start = bgStartYear(row);
|
var start = bgStartYear(row);
|
||||||
if (start) return start;
|
if (start) return start;
|
||||||
@@ -82,7 +124,7 @@
|
|||||||
if (!(cutoff && yearStart && startDate)) return false;
|
if (!(cutoff && yearStart && startDate)) return false;
|
||||||
if (startDate > cutoff) return false;
|
if (startDate > cutoff) return false;
|
||||||
if (endDate && endDate < yearStart) return false;
|
if (endDate && endDate < yearStart) return false;
|
||||||
return !(endDate && endDate <= cutoff);
|
return rowStatusLabel(row) === "과업진행중";
|
||||||
}
|
}
|
||||||
|
|
||||||
function bgStartedInYear(row, year) {
|
function bgStartedInYear(row, year) {
|
||||||
@@ -96,7 +138,7 @@
|
|||||||
var cutoff = bgYearCutoff(year);
|
var cutoff = bgYearCutoff(year);
|
||||||
var endDate = bgDateOrYearEnd(row);
|
var endDate = bgDateOrYearEnd(row);
|
||||||
if (!(cutoff && endDate)) return false;
|
if (!(cutoff && endDate)) return false;
|
||||||
return endDate.getFullYear() === Number(year || 0) && endDate <= cutoff;
|
return rowStatusLabel(row) === "준공" && endDate.getFullYear() === Number(year || 0) && endDate <= cutoff;
|
||||||
}
|
}
|
||||||
|
|
||||||
function bgYearRange(row) {
|
function bgYearRange(row) {
|
||||||
@@ -140,16 +182,32 @@
|
|||||||
}, { c: 0, col: 0, recv: 0 });
|
}, { c: 0, col: 0, recv: 0 });
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSupportServiceRow(row) {
|
function isBaronProjectRow(row) {
|
||||||
var category = String((row && row.cat) || "").trim();
|
return projectTypeLabel(row) === "바론";
|
||||||
return category.indexOf("경영지원") >= 0 || category.indexOf("서비스") >= 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isBaronProjectRow(row) {
|
function isSoftwareProjectRow(row) {
|
||||||
var category = String((row && row.cat) || "").trim();
|
var name = bgNormalizeText(row && row.name).toLowerCase();
|
||||||
if (category.indexOf("바론") < 0) return false;
|
if (!name) return false;
|
||||||
if (isSupportServiceRow(row)) return false;
|
return [
|
||||||
return true;
|
"프로그램",
|
||||||
|
"소프트웨어",
|
||||||
|
"software",
|
||||||
|
" sw",
|
||||||
|
"sw ",
|
||||||
|
"erp",
|
||||||
|
"tova",
|
||||||
|
"ipipe",
|
||||||
|
"eg-bim",
|
||||||
|
"cad"
|
||||||
|
].some(function (keyword) {
|
||||||
|
return name.indexOf(keyword) >= 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldSinkProjectName(row) {
|
||||||
|
var name = bgNormalizeText(row && row.name);
|
||||||
|
return name.indexOf("프로그램") >= 0 || name.indexOf("사용") >= 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function bgSummarize(rows, selectedYear) {
|
function bgSummarize(rows, selectedYear) {
|
||||||
@@ -158,14 +216,18 @@
|
|||||||
var activeRows = items.filter(function (row) { return bgActiveInYear(row, targetYear); });
|
var activeRows = items.filter(function (row) { return bgActiveInYear(row, targetYear); });
|
||||||
var newProjectRows = items.filter(function (row) { return bgStartedInYear(row, targetYear); });
|
var newProjectRows = items.filter(function (row) { return bgStartedInYear(row, targetYear); });
|
||||||
var completedRows = items.filter(function (row) { return bgCompletedInYear(row, targetYear); });
|
var completedRows = items.filter(function (row) { return bgCompletedInYear(row, targetYear); });
|
||||||
var managementRows = newProjectRows.filter(isSupportServiceRow);
|
var managementRows = activeRows.filter(isSupportServiceRow);
|
||||||
|
var baronActiveRows = activeRows.filter(isBaronProjectRow);
|
||||||
return {
|
return {
|
||||||
targetYear: targetYear,
|
targetYear: targetYear,
|
||||||
activeRows: activeRows,
|
activeRows: activeRows,
|
||||||
newProjectRows: newProjectRows,
|
newProjectRows: newProjectRows,
|
||||||
completedRows: completedRows,
|
completedRows: completedRows,
|
||||||
managementRows: managementRows,
|
managementRows: managementRows,
|
||||||
managementTotals: bgTotals(managementRows)
|
managementTotals: bgTotals(managementRows),
|
||||||
|
baronActiveRows: baronActiveRows,
|
||||||
|
baronProjectTotals: bgTotals(baronActiveRows),
|
||||||
|
baronSoftwareCount: baronActiveRows.filter(isSoftwareProjectRow).length
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,13 +239,6 @@
|
|||||||
return bgActiveInYear(row, selectedYear);
|
return bgActiveInYear(row, selectedYear);
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeStatusLabel(status) {
|
|
||||||
var value = String(status || "").trim();
|
|
||||||
if (!value) return "-";
|
|
||||||
if (value.indexOf("진행") >= 0) return "과업 진행중";
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatSplitPercent(split) {
|
function formatSplitPercent(split) {
|
||||||
var numeric = parseFloat(String(split || "").replace(/[^0-9.\-]/g, ""));
|
var numeric = parseFloat(String(split || "").replace(/[^0-9.\-]/g, ""));
|
||||||
if (!Number.isFinite(numeric) || numeric === 0) return "분담율 -%";
|
if (!Number.isFinite(numeric) || numeric === 0) return "분담율 -%";
|
||||||
@@ -204,17 +259,57 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function groupSortRank(row) {
|
function groupSortRank(row) {
|
||||||
var selectedYear = Number((S.dashboard && S.dashboard.year) || projectYear(row) || 0);
|
|
||||||
var startYear = Number(projectYear(row) || 0);
|
var startYear = Number(projectYear(row) || 0);
|
||||||
if (typeof bgCompletedInYear === "function" && bgCompletedInYear(row, String(selectedYear))) return 9999;
|
|
||||||
if (!startYear) return 9998;
|
if (!startYear) return 9998;
|
||||||
return startYear;
|
return startYear;
|
||||||
}
|
}
|
||||||
|
|
||||||
function tableGroupLabel(row) {
|
function tableGroupLabel(row) {
|
||||||
var startYear = projectYear(row);
|
var startYear = projectYear(row);
|
||||||
if (/^20\d{2}$/.test(startYear)) return startYear + "년";
|
if (/^20\d{2}$/.test(startYear)) return startYear + " " + projectTypeLabel(row);
|
||||||
return "미지정";
|
return "미지정 " + projectTypeLabel(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareDashboardRows(a, b) {
|
||||||
|
var typeRankDiff = projectTypeRank(a) - projectTypeRank(b);
|
||||||
|
if (typeRankDiff !== 0) return typeRankDiff;
|
||||||
|
var groupDiff = groupSortRank(a) - groupSortRank(b);
|
||||||
|
if (groupDiff !== 0) return groupDiff;
|
||||||
|
var sinkDiff = Number(shouldSinkProjectName(a)) - Number(shouldSinkProjectName(b));
|
||||||
|
if (sinkDiff !== 0) return sinkDiff;
|
||||||
|
return bgNormalizeText(a && a.name).localeCompare(bgNormalizeText(b && b.name), "ko");
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterCategoryLabel(row) {
|
||||||
|
return projectTypeLabel(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterClientLabel(row) {
|
||||||
|
if (typeof normalizeClientDisplay === "function") {
|
||||||
|
return normalizeClientDisplay(row && row.client);
|
||||||
|
}
|
||||||
|
return bgNormalizeText(row && row.client) || "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterOrderLabel(row) {
|
||||||
|
return bgNormalizeText(row && row.order) || "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
function receivableFilterLabel(row) {
|
||||||
|
var amount = Number((row && row.recv) || 0);
|
||||||
|
if (amount <= 0) return "미수 없음";
|
||||||
|
if (amount < 10000000) return "1천만 미만";
|
||||||
|
if (amount < 100000000) return "1천만 이상";
|
||||||
|
return "1억 이상";
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshFilterDom() {
|
||||||
|
E.filterButtons = Object.fromEntries(Array.from(document.querySelectorAll(".th-trigger")).map(function (el) {
|
||||||
|
return [el.dataset.filter, el];
|
||||||
|
}));
|
||||||
|
E.filterMenus = Object.fromEntries(Array.from(document.querySelectorAll(".th-menu")).map(function (el) {
|
||||||
|
return [el.dataset.filter, el];
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderLedgerTable() {
|
function renderLedgerTable() {
|
||||||
@@ -236,12 +331,7 @@
|
|||||||
+ '<th class="num"><div class="th-head end"><button type="button" class="th-trigger" data-filter="rate" data-label="수금률"><span class="th-title">수금률</span><span class="th-mark"></span><span class="th-caret">▼</span></button><div id="filterRateMenu" class="th-menu" data-filter="rate"></div></div></th>'
|
+ '<th class="num"><div class="th-head end"><button type="button" class="th-trigger" data-filter="rate" data-label="수금률"><span class="th-title">수금률</span><span class="th-mark"></span><span class="th-caret">▼</span></button><div id="filterRateMenu" class="th-menu" data-filter="rate"></div></div></th>'
|
||||||
+ "</tr>";
|
+ "</tr>";
|
||||||
}
|
}
|
||||||
var rows = (Array.isArray(S.viewRows) ? S.viewRows : []).slice().sort(function (a, b) {
|
var rows = (Array.isArray(S.viewRows) ? S.viewRows : []).slice().sort(compareDashboardRows);
|
||||||
var ar = groupSortRank(a);
|
|
||||||
var br = groupSortRank(b);
|
|
||||||
if (ar !== br) return ar - br;
|
|
||||||
return Number(b.recv || 0) - Number(a.recv || 0);
|
|
||||||
});
|
|
||||||
S.viewRows = rows;
|
S.viewRows = rows;
|
||||||
var lastGroupLabel = "";
|
var lastGroupLabel = "";
|
||||||
E.tbody.innerHTML = rows.map(function (r) {
|
E.tbody.innerHTML = rows.map(function (r) {
|
||||||
@@ -259,7 +349,7 @@
|
|||||||
+ '<td><button type="button" class="project-link" data-project-key="' + escAttr(String(r.code || "") + "|" + String(r.name || "")) + '">' + esc(r.name || "-") + '</button><div class="subline">' + esc(r.periodText || "-") + '</div></td>'
|
+ '<td><button type="button" class="project-link" data-project-key="' + escAttr(String(r.code || "") + "|" + String(r.name || "")) + '">' + esc(r.name || "-") + '</button><div class="subline">' + esc(r.periodText || "-") + '</div></td>'
|
||||||
+ '<td><div class="client-main">' + esc((r.client || "").trim() || "-") + '</div><div class="subline">' + esc(formatSplitPercent(r.split)) + '</div></td>'
|
+ '<td><div class="client-main">' + esc((r.client || "").trim() || "-") + '</div><div class="subline">' + esc(formatSplitPercent(r.split)) + '</div></td>'
|
||||||
+ '<td><div>' + esc(r.order || "-") + '</div></td>'
|
+ '<td><div>' + esc(r.order || "-") + '</div></td>'
|
||||||
+ '<td><div class="badge ' + (String(r.status || "").indexOf("완료") >= 0 ? 'ok' : '') + '">' + esc(normalizeStatusLabel(r.status)) + '</div></td>'
|
+ '<td><div class="badge ' + (rowStatusLabel(r) === "준공" ? 'ok' : '') + '">' + esc(rowStatusLabel(r)) + '</div></td>'
|
||||||
+ '<td class="num"><strong>' + esc(won(r.cSup || 0)) + '</strong></td>'
|
+ '<td class="num"><strong>' + esc(won(r.cSup || 0)) + '</strong></td>'
|
||||||
+ '<td class="num"><strong>' + esc(r.outsourceCost ? won(r.outsourceCost) : "-") + '</strong></td>'
|
+ '<td class="num"><strong>' + esc(r.outsourceCost ? won(r.outsourceCost) : "-") + '</strong></td>'
|
||||||
+ '<td class="num"><strong>' + esc(won(r.recv || 0)) + '</strong></td>'
|
+ '<td class="num"><strong>' + esc(won(r.recv || 0)) + '</strong></td>'
|
||||||
@@ -267,6 +357,8 @@
|
|||||||
+ '<td class="num"><strong style="color:' + (isSettledRow(r) ? '#b7aa93' : '#1a5645') + '">' + esc((Number(r.rate || 0)).toFixed(2) + "%") + '</strong></td>'
|
+ '<td class="num"><strong style="color:' + (isSettledRow(r) ? '#b7aa93' : '#1a5645') + '">' + esc((Number(r.rate || 0)).toFixed(2) + "%") + '</strong></td>'
|
||||||
+ '</tr>';
|
+ '</tr>';
|
||||||
}).join("");
|
}).join("");
|
||||||
|
refreshFilterDom();
|
||||||
|
if (typeof syncColumnFilters === "function") syncColumnFilters(S.all);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderCollectionBoard(r) {
|
function renderCollectionBoard(r) {
|
||||||
@@ -379,10 +471,8 @@
|
|||||||
}
|
}
|
||||||
var years = bgEnsureYear(S.all);
|
var years = bgEnsureYear(S.all);
|
||||||
var summary = bgSummarize(S.all, S.dashboard.year);
|
var summary = bgSummarize(S.all, S.dashboard.year);
|
||||||
var rows = Array.isArray(S.rows) ? S.rows : [];
|
var totals = summary.baronProjectTotals;
|
||||||
var visibleBaronProjectRows = rows.filter(isBaronProjectRow);
|
var totalRate = totals.c > 0 ? (totals.col / totals.c) * 100 : 0;
|
||||||
var totals = bgTotals(visibleBaronProjectRows);
|
|
||||||
var totalRate = typeof rate === "function" ? rate("", totals.col, totals.col + totals.recv) : 0;
|
|
||||||
var toolbarHtml = '<div class="cards-toolbar">'
|
var toolbarHtml = '<div class="cards-toolbar">'
|
||||||
+ '<div class="cards-toolbar-row">'
|
+ '<div class="cards-toolbar-row">'
|
||||||
+ years.map(function (year) {
|
+ years.map(function (year) {
|
||||||
@@ -393,14 +483,14 @@
|
|||||||
+ '<div class="cards-toolbar-metrics">'
|
+ '<div class="cards-toolbar-metrics">'
|
||||||
+ '<button type="button" class="summary-filter-chip ' + (S.dashboard.section === "active" ? "active" : "") + '" data-dashboard-section="active"><span class="label">' + esc(summary.targetYear) + '년 진행과업</span><span class="count">' + summary.activeRows.length.toLocaleString("ko-KR") + '건</span><span class="meta">전년도 이월 사업 포함</span></button>'
|
+ '<button type="button" class="summary-filter-chip ' + (S.dashboard.section === "active" ? "active" : "") + '" data-dashboard-section="active"><span class="label">' + esc(summary.targetYear) + '년 진행과업</span><span class="count">' + summary.activeRows.length.toLocaleString("ko-KR") + '건</span><span class="meta">전년도 이월 사업 포함</span></button>'
|
||||||
+ '<button type="button" class="summary-filter-chip ' + (S.dashboard.section === "new" ? "active" : "") + '" data-dashboard-section="new"><span class="label">' + esc(summary.targetYear) + '년 신규프로젝트</span><span class="count">' + summary.newProjectRows.length.toLocaleString("ko-KR") + '건</span><span class="meta">계약기간 시작년도 기준</span></button>'
|
+ '<button type="button" class="summary-filter-chip ' + (S.dashboard.section === "new" ? "active" : "") + '" data-dashboard-section="new"><span class="label">' + esc(summary.targetYear) + '년 신규프로젝트</span><span class="count">' + summary.newProjectRows.length.toLocaleString("ko-KR") + '건</span><span class="meta">계약기간 시작년도 기준</span></button>'
|
||||||
+ '<button type="button" class="summary-filter-chip ' + (S.dashboard.section === "completed" ? "active" : "") + '" data-dashboard-section="completed"><span class="label">' + esc(summary.targetYear) + '년 완료과업</span><span class="count">' + summary.completedRows.length.toLocaleString("ko-KR") + '건</span><span class="meta">해당년도 종료 사업 기준</span></button>'
|
+ '<button type="button" class="summary-filter-chip ' + (S.dashboard.section === "completed" ? "active" : "") + '" data-dashboard-section="completed"><span class="label">' + esc(summary.targetYear) + '년 완료과업</span><span class="count">' + summary.completedRows.length.toLocaleString("ko-KR") + '건</span><span class="meta">진행상태 준공 기준</span></button>'
|
||||||
+ "</div></div>";
|
+ "</div></div>";
|
||||||
var cards = [
|
var cards = [
|
||||||
{ label: summary.targetYear + "년 프로젝트", value: visibleBaronProjectRows.length.toLocaleString("ko-KR") + " 건", note: "" },
|
{ label: summary.targetYear + "년 프로젝트", value: summary.baronActiveRows.length.toLocaleString("ko-KR") + "건 (" + summary.baronSoftwareCount.toLocaleString("ko-KR") + "건)", note: "바론 수행중 프로젝트 / SW" },
|
||||||
{ label: "계약금", value: won(totals.c), note: "" },
|
{ label: "계약금 (VAT별도)", value: won(totals.c), note: "" },
|
||||||
{ label: "수금액", value: won(totals.col), note: "" },
|
{ label: "수금액", value: won(totals.col), note: "" },
|
||||||
{ label: "미수금", value: won(totals.recv), note: "" },
|
{ label: "미수금", value: won(totals.recv), note: "" },
|
||||||
{ label: "수금률(%)", value: totalRate.toFixed(2) + "%", note: "" },
|
{ label: "수금율", value: totalRate.toFixed(2) + "%", note: "계약금 대비 수금액" },
|
||||||
{ label: "경영지원서비스 금액", value: won(summary.managementTotals.c), note: "", className: "management" }
|
{ label: "경영지원서비스 금액", value: won(summary.managementTotals.c), note: "", className: "management" }
|
||||||
];
|
];
|
||||||
E.cards.innerHTML = toolbarHtml + cards.map(function (card) {
|
E.cards.innerHTML = toolbarHtml + cards.map(function (card) {
|
||||||
@@ -429,9 +519,62 @@
|
|||||||
S.rows = searched.filter(function (r) {
|
S.rows = searched.filter(function (r) {
|
||||||
return bgMatches(r) && matchesColumnFilters(r);
|
return bgMatches(r) && matchesColumnFilters(r);
|
||||||
});
|
});
|
||||||
|
S.rows.sort(compareDashboardRows);
|
||||||
render();
|
render();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
filterDefinitions = function () {
|
||||||
|
return [
|
||||||
|
{ key: "cat", map: filterCategoryLabel },
|
||||||
|
{ key: "code", map: function (r) { return r.code || "-"; } },
|
||||||
|
{ key: "name", map: function (r) { return r.name || "-"; } },
|
||||||
|
{ key: "client", map: filterClientLabel },
|
||||||
|
{ key: "order", map: filterOrderLabel },
|
||||||
|
{ key: "status", map: rowStatusLabel },
|
||||||
|
{ key: "amount", map: amountFilterLabel },
|
||||||
|
{ key: "outsource", map: outsourceFilterLabel },
|
||||||
|
{ key: "receivable", map: receivableFilterLabel },
|
||||||
|
{ key: "collected", map: collectedFilterLabel },
|
||||||
|
{ key: "rate", map: rateFilterLabel }
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
updateFilterButtons = function () {
|
||||||
|
Object.keys(E.filterButtons || {}).forEach(function (key) {
|
||||||
|
var btn = E.filterButtons[key];
|
||||||
|
if (!btn) return;
|
||||||
|
var active = !!S.filters[key];
|
||||||
|
btn.classList.toggle("active", active);
|
||||||
|
btn.title = active ? ((btn.dataset.label || "") + ": " + S.filters[key]) : (btn.dataset.label || "");
|
||||||
|
var mark = btn.querySelector(".th-mark");
|
||||||
|
if (mark) mark.textContent = active ? "•" : "";
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
syncColumnFilters = function (rows) {
|
||||||
|
filterDefinitions().forEach(function (def) {
|
||||||
|
var values = uniqueFilterValues(rows, def.map);
|
||||||
|
if (S.filters[def.key] && !values.includes(S.filters[def.key])) delete S.filters[def.key];
|
||||||
|
renderFilterMenu(def.key, values);
|
||||||
|
});
|
||||||
|
updateFilterButtons();
|
||||||
|
};
|
||||||
|
|
||||||
|
matchesColumnFilters = function (r) {
|
||||||
|
if (S.filters.cat && filterCategoryLabel(r) !== S.filters.cat) return false;
|
||||||
|
if (S.filters.code && (r.code || "-") !== S.filters.code) return false;
|
||||||
|
if (S.filters.name && (r.name || "-") !== S.filters.name) return false;
|
||||||
|
if (S.filters.client && filterClientLabel(r) !== S.filters.client) return false;
|
||||||
|
if (S.filters.order && filterOrderLabel(r) !== S.filters.order) return false;
|
||||||
|
if (S.filters.status && rowStatusLabel(r) !== S.filters.status) return false;
|
||||||
|
if (S.filters.amount && amountFilterLabel(r) !== S.filters.amount) return false;
|
||||||
|
if (S.filters.outsource && outsourceFilterLabel(r) !== S.filters.outsource) return false;
|
||||||
|
if (S.filters.receivable && receivableFilterLabel(r) !== S.filters.receivable) return false;
|
||||||
|
if (S.filters.collected && collectedFilterLabel(r) !== S.filters.collected) return false;
|
||||||
|
if (S.filters.rate && rateFilterLabel(r) !== S.filters.rate) return false;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
if (E.cards && !E.cards.dataset.dashboardBound) {
|
if (E.cards && !E.cards.dataset.dashboardBound) {
|
||||||
E.cards.dataset.dashboardBound = "true";
|
E.cards.dataset.dashboardBound = "true";
|
||||||
E.cards.addEventListener("click", function (event) {
|
E.cards.addEventListener("click", function (event) {
|
||||||
@@ -472,6 +615,26 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var panel = document.querySelector(".panel");
|
||||||
|
if (panel && !panel.dataset.ledgerFilterBound) {
|
||||||
|
panel.dataset.ledgerFilterBound = "true";
|
||||||
|
panel.addEventListener("click", function (event) {
|
||||||
|
var trigger = event.target && event.target.closest ? event.target.closest(".th-trigger") : null;
|
||||||
|
if (trigger) {
|
||||||
|
refreshFilterDom();
|
||||||
|
event.stopPropagation();
|
||||||
|
toggleFilterMenu(trigger.dataset.filter);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var option = event.target && event.target.closest ? event.target.closest("button[data-filter-value]") : null;
|
||||||
|
var menu = event.target && event.target.closest ? event.target.closest(".th-menu") : null;
|
||||||
|
if (option && menu) {
|
||||||
|
event.stopPropagation();
|
||||||
|
setFilterValue(menu.dataset.filter, option.getAttribute("data-filter-value") || "");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
setTimeout(function () {
|
setTimeout(function () {
|
||||||
try {
|
try {
|
||||||
filter();
|
filter();
|
||||||
|
|||||||
16
frontend/apps/organization/README.md
Normal file
16
frontend/apps/organization/README.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# Organization App
|
||||||
|
|
||||||
|
조직현황 탭 전용 소스 디렉터리다.
|
||||||
|
|
||||||
|
- 편집 원본:
|
||||||
|
- `index.html`
|
||||||
|
- `assets/common.css`
|
||||||
|
- `assets/organization.css`
|
||||||
|
- `assets/organization.js`
|
||||||
|
- 실제 서빙 대상:
|
||||||
|
- `DashBoard-organization.html`
|
||||||
|
- `legacy/static/common.css`
|
||||||
|
- `legacy/static/organization.css`
|
||||||
|
- `legacy/static/organization.js`
|
||||||
|
|
||||||
|
수정 후에는 `scripts/publish_organization_app.sh`를 실행해 서빙 파일로 반영한다.
|
||||||
110
frontend/apps/organization/assets/common.css
Normal file
110
frontend/apps/organization/assets/common.css
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
@import url("/design-tokens.css?v=20260401-01");
|
||||||
|
@import url("/design-patterns.css?v=20260401-01");
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--font-sans: var(--ds-font-sans);
|
||||||
|
|
||||||
|
--color-bg: var(--ds-bg);
|
||||||
|
--color-bg-soft: var(--ds-bg-soft);
|
||||||
|
--color-surface: var(--ds-panel);
|
||||||
|
--color-surface-soft: var(--ds-panel-soft);
|
||||||
|
--color-surface-strong: var(--ds-panel-strong);
|
||||||
|
--color-text: var(--ds-ink);
|
||||||
|
--color-text-soft: var(--ds-text-soft);
|
||||||
|
--color-text-muted: var(--ds-text-muted);
|
||||||
|
--color-border: var(--ds-line);
|
||||||
|
--color-border-soft: var(--ds-line-soft);
|
||||||
|
--color-header: var(--ds-brand);
|
||||||
|
--color-header-soft: var(--ds-brand-soft);
|
||||||
|
--color-accent: var(--ds-accent);
|
||||||
|
--color-accent-soft: var(--ds-accent-soft);
|
||||||
|
--color-accent-strong: var(--ds-accent-strong);
|
||||||
|
|
||||||
|
--radius-sm: var(--ds-radius-sm);
|
||||||
|
--radius-md: var(--ds-radius-md);
|
||||||
|
--radius-lg: var(--ds-radius-lg);
|
||||||
|
--radius-xl: var(--ds-radius-xl);
|
||||||
|
--radius-pill: var(--ds-radius-pill);
|
||||||
|
|
||||||
|
--shadow-soft: var(--ds-shadow-soft);
|
||||||
|
--shadow-card: var(--ds-shadow-card);
|
||||||
|
--shadow-float: var(--ds-shadow-float);
|
||||||
|
|
||||||
|
--space-1: var(--ds-space-1);
|
||||||
|
--space-2: var(--ds-space-2);
|
||||||
|
--space-3: var(--ds-space-3);
|
||||||
|
--space-4: var(--ds-space-4);
|
||||||
|
--space-5: var(--ds-space-5);
|
||||||
|
--space-6: var(--ds-space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 100%;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
color: var(--color-text);
|
||||||
|
background: var(--ds-bg-gradient);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--ds-bg-gradient);
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea,
|
||||||
|
a {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-card {
|
||||||
|
background: var(--color-surface-soft);
|
||||||
|
border: 1px solid var(--color-border-soft);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-button-primary {
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
background: var(--color-accent);
|
||||||
|
box-shadow: var(--shadow-float);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-button-secondary {
|
||||||
|
border: 1px solid var(--color-border-soft);
|
||||||
|
color: var(--color-text);
|
||||||
|
background: var(--ds-surface-tint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-input {
|
||||||
|
border: 1px solid var(--color-border-soft);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--ds-surface-tint-strong);
|
||||||
|
color: var(--color-text);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-input:focus {
|
||||||
|
border-color: rgba(47, 153, 115, 0.45);
|
||||||
|
box-shadow: 0 0 0 4px rgba(47, 153, 115, 0.1);
|
||||||
|
}
|
||||||
1744
frontend/apps/organization/assets/organization.css
Normal file
1744
frontend/apps/organization/assets/organization.css
Normal file
File diff suppressed because it is too large
Load Diff
1979
frontend/apps/organization/assets/organization.js
Normal file
1979
frontend/apps/organization/assets/organization.js
Normal file
File diff suppressed because it is too large
Load Diff
65
frontend/apps/organization/index.html
Normal file
65
frontend/apps/organization/index.html
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>MH 조직현황 관리</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Pretendard:wght@400;600;700;900&display=swap" rel="stylesheet" />
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<link rel="stylesheet" href="/legacy/static/common.css?v=20260402-02" />
|
||||||
|
<link rel="stylesheet" href="/legacy/static/organization.css?v=20260402-02" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<input type="file" id="upload-excel" class="hidden" accept=".xlsx, .csv" />
|
||||||
|
|
||||||
|
<div class="search-section">
|
||||||
|
<div class="flex flex-col w-full">
|
||||||
|
<div class="relative flex items-center w-full">
|
||||||
|
<span class="search-icon">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
|
||||||
|
</span>
|
||||||
|
<input type="text" id="search-input" placeholder="이름 또는 조직 검색" class="search-input" />
|
||||||
|
</div>
|
||||||
|
<div id="dept-tabs" class="dept-tabs-container"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="stats-area" class="stats-section" style="padding: 10px 15px;">
|
||||||
|
<div class="flex justify-between items-center mb-0 cursor-pointer p-0" id="stats-header">
|
||||||
|
<h2 class="stats-title text-xs font-black text-slate-800 flex items-center gap-2">인원 현황 통계 <span id="total-count-badge" class="bg-indigo-100 text-indigo-600 text-[10px] px-2 py-0.5 rounded-full">0명</span></h2>
|
||||||
|
<span id="stats-toggle-icon" class="text-slate-400 text-xs transition-transform duration-200" style="transform: rotate(-90deg);">▼</span>
|
||||||
|
</div>
|
||||||
|
<div id="stats-table-container" class="mt-3 overflow-hidden transition-all duration-300" style="display: none;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="tree-root" class="org-canvas">
|
||||||
|
<div class="text-slate-400 font-bold mt-20 text-xs text-center">서버에서 조직 데이터를 불러오는 중입니다.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="admin-mode-btn" class="admin-mode-btn" data-label="관리자 모드 전환">🔐</button>
|
||||||
|
|
||||||
|
<div id="last-updated" class="fixed bottom-4 left-5 text-[10px] text-slate-400 font-bold z-[4000] pointer-events-none" style="letter-spacing: 0.02em; opacity: 0.8;"></div>
|
||||||
|
|
||||||
|
<div class="fab-container" id="fab-container">
|
||||||
|
<button class="fab-main text-white" id="fab-main">+</button>
|
||||||
|
<div class="fab-menu" id="fab-menu"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div id="modal-header-area">
|
||||||
|
<h2 id="modal-title" class="text-xl font-black mb-6 text-slate-800 border-b pb-4">구성원 정보 수정</h2>
|
||||||
|
</div>
|
||||||
|
<div id="modal-fields" class="grid grid-cols-2 gap-x-8 gap-y-5"></div>
|
||||||
|
<div id="modal-footer-area" class="flex gap-4 mt-10">
|
||||||
|
<button id="modal-cancel-btn" class="flex-1 bg-slate-100 py-3.5 rounded-xl font-bold text-sm">취소</button>
|
||||||
|
<button id="btn-save" class="flex-1 bg-indigo-600 text-white py-3.5 rounded-xl font-bold text-sm">저장</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/legacy/static/organization.js?v=20260402-02"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -32,9 +32,3 @@
|
|||||||
- `reference/ledger/MH 통합 대시보드_260320.html`
|
- `reference/ledger/MH 통합 대시보드_260320.html`
|
||||||
- `reference/ledger/MH 통합 대시보드_260320.css`
|
- `reference/ledger/MH 통합 대시보드_260320.css`
|
||||||
- `reference/ledger/사업관리대장-1.xlsx`
|
- `reference/ledger/사업관리대장-1.xlsx`
|
||||||
|
|
||||||
## Temporary Comparison Copies
|
|
||||||
|
|
||||||
- 현재 루트의 `payment.html`, `mh.html`은 당장 삭제하지 않는다.
|
|
||||||
- 이 두 파일은 기존 recovery 작업본과 현재 `served/*`를 비교하거나 되돌릴 때만 본다.
|
|
||||||
- 다음 차수에서 안전성이 확보되면 `reference/` 하위로 재배치 여부를 검토한다.
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -8,8 +8,7 @@
|
|||||||
|
|
||||||
- `ledger/`
|
- `ledger/`
|
||||||
- 사업관리대장 원본 wrapper/html/css/xlsx
|
- 사업관리대장 원본 wrapper/html/css/xlsx
|
||||||
- 이전 override 복사본
|
- 이전 override 참고 파일
|
||||||
- 중첩 백업 디렉터리
|
|
||||||
|
|
||||||
규칙:
|
규칙:
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
- 원본 HTML/CSS
|
- 원본 HTML/CSS
|
||||||
- 원본 XLSX
|
- 원본 XLSX
|
||||||
- 과거 override 참고 파일
|
- 과거 override 참고 파일
|
||||||
|
- 단일 canonical reference set만 유지
|
||||||
|
|
||||||
주의:
|
주의:
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
Binary file not shown.
@@ -4,8 +4,8 @@
|
|||||||
|
|
||||||
source-of-truth:
|
source-of-truth:
|
||||||
|
|
||||||
- [frontend/apps/ledger](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/apps/ledger)
|
- [frontend/apps/ledger](../../../frontend/apps/ledger)
|
||||||
- 반영 스크립트: [scripts/publish_ledger_app.sh](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/scripts/publish_ledger_app.sh)
|
- 반영 스크립트: [scripts/publish_ledger_app.sh](../../../scripts/publish_ledger_app.sh)
|
||||||
|
|
||||||
- `index.html`: `/integrations/ledger` 응답 본문
|
- `index.html`: `/integrations/ledger` 응답 본문
|
||||||
- `frontend/apps/ledger/index.html` 템플릿에서 publish 시 생성
|
- `frontend/apps/ledger/index.html` 템플릿에서 publish 시 생성
|
||||||
|
|||||||
@@ -10,6 +10,10 @@
|
|||||||
return new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
return new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function bgNormalizeText(value) {
|
||||||
|
return String(value || "").replace(/\s+/g, " ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
function bgParseDate(value) {
|
function bgParseDate(value) {
|
||||||
var text = String(value || "").trim();
|
var text = String(value || "").trim();
|
||||||
if (!text) return null;
|
if (!text) return null;
|
||||||
@@ -36,6 +40,44 @@
|
|||||||
return bgYearFromText(row && row.eDate);
|
return bgYearFromText(row && row.eDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizedCategory(row) {
|
||||||
|
var category = bgNormalizeText(row && row.cat);
|
||||||
|
if (category.indexOf("가족사") >= 0) return "가족사";
|
||||||
|
var corp = bgNormalizeText(row && row.corp);
|
||||||
|
if (corp && corp !== "바론") return "가족사";
|
||||||
|
return "바론";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSupportServiceRow(row) {
|
||||||
|
return bgNormalizeText(row && row.name).indexOf("경영 및 기술지원 서비스") >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectTypeLabel(row) {
|
||||||
|
if (isSupportServiceRow(row)) return "기술지원서비스";
|
||||||
|
return normalizedCategory(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectTypeRank(row) {
|
||||||
|
var label = projectTypeLabel(row);
|
||||||
|
if (label === "바론") return 0;
|
||||||
|
if (label === "가족사") return 1;
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStatusLabel(status) {
|
||||||
|
var value = bgNormalizeText(status);
|
||||||
|
if (!value) return "-";
|
||||||
|
if (value === "완료") return "준공";
|
||||||
|
if (value === "진행") return "과업진행중";
|
||||||
|
if (value === "대기") return "계약대기";
|
||||||
|
if (value === "중지") return "과업중지";
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowStatusLabel(row) {
|
||||||
|
return normalizeStatusLabel(row && row.status);
|
||||||
|
}
|
||||||
|
|
||||||
function bgDisplayYear(row) {
|
function bgDisplayYear(row) {
|
||||||
var start = bgStartYear(row);
|
var start = bgStartYear(row);
|
||||||
if (start) return start;
|
if (start) return start;
|
||||||
@@ -82,7 +124,7 @@
|
|||||||
if (!(cutoff && yearStart && startDate)) return false;
|
if (!(cutoff && yearStart && startDate)) return false;
|
||||||
if (startDate > cutoff) return false;
|
if (startDate > cutoff) return false;
|
||||||
if (endDate && endDate < yearStart) return false;
|
if (endDate && endDate < yearStart) return false;
|
||||||
return !(endDate && endDate <= cutoff);
|
return rowStatusLabel(row) === "과업진행중";
|
||||||
}
|
}
|
||||||
|
|
||||||
function bgStartedInYear(row, year) {
|
function bgStartedInYear(row, year) {
|
||||||
@@ -96,7 +138,7 @@
|
|||||||
var cutoff = bgYearCutoff(year);
|
var cutoff = bgYearCutoff(year);
|
||||||
var endDate = bgDateOrYearEnd(row);
|
var endDate = bgDateOrYearEnd(row);
|
||||||
if (!(cutoff && endDate)) return false;
|
if (!(cutoff && endDate)) return false;
|
||||||
return endDate.getFullYear() === Number(year || 0) && endDate <= cutoff;
|
return rowStatusLabel(row) === "준공" && endDate.getFullYear() === Number(year || 0) && endDate <= cutoff;
|
||||||
}
|
}
|
||||||
|
|
||||||
function bgYearRange(row) {
|
function bgYearRange(row) {
|
||||||
@@ -140,16 +182,32 @@
|
|||||||
}, { c: 0, col: 0, recv: 0 });
|
}, { c: 0, col: 0, recv: 0 });
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSupportServiceRow(row) {
|
function isBaronProjectRow(row) {
|
||||||
var category = String((row && row.cat) || "").trim();
|
return projectTypeLabel(row) === "바론";
|
||||||
return category.indexOf("경영지원") >= 0 || category.indexOf("서비스") >= 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isBaronProjectRow(row) {
|
function isSoftwareProjectRow(row) {
|
||||||
var category = String((row && row.cat) || "").trim();
|
var name = bgNormalizeText(row && row.name).toLowerCase();
|
||||||
if (category.indexOf("바론") < 0) return false;
|
if (!name) return false;
|
||||||
if (isSupportServiceRow(row)) return false;
|
return [
|
||||||
return true;
|
"프로그램",
|
||||||
|
"소프트웨어",
|
||||||
|
"software",
|
||||||
|
" sw",
|
||||||
|
"sw ",
|
||||||
|
"erp",
|
||||||
|
"tova",
|
||||||
|
"ipipe",
|
||||||
|
"eg-bim",
|
||||||
|
"cad"
|
||||||
|
].some(function (keyword) {
|
||||||
|
return name.indexOf(keyword) >= 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldSinkProjectName(row) {
|
||||||
|
var name = bgNormalizeText(row && row.name);
|
||||||
|
return name.indexOf("프로그램") >= 0 || name.indexOf("사용") >= 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function bgSummarize(rows, selectedYear) {
|
function bgSummarize(rows, selectedYear) {
|
||||||
@@ -158,14 +216,18 @@
|
|||||||
var activeRows = items.filter(function (row) { return bgActiveInYear(row, targetYear); });
|
var activeRows = items.filter(function (row) { return bgActiveInYear(row, targetYear); });
|
||||||
var newProjectRows = items.filter(function (row) { return bgStartedInYear(row, targetYear); });
|
var newProjectRows = items.filter(function (row) { return bgStartedInYear(row, targetYear); });
|
||||||
var completedRows = items.filter(function (row) { return bgCompletedInYear(row, targetYear); });
|
var completedRows = items.filter(function (row) { return bgCompletedInYear(row, targetYear); });
|
||||||
var managementRows = newProjectRows.filter(isSupportServiceRow);
|
var managementRows = activeRows.filter(isSupportServiceRow);
|
||||||
|
var baronActiveRows = activeRows.filter(isBaronProjectRow);
|
||||||
return {
|
return {
|
||||||
targetYear: targetYear,
|
targetYear: targetYear,
|
||||||
activeRows: activeRows,
|
activeRows: activeRows,
|
||||||
newProjectRows: newProjectRows,
|
newProjectRows: newProjectRows,
|
||||||
completedRows: completedRows,
|
completedRows: completedRows,
|
||||||
managementRows: managementRows,
|
managementRows: managementRows,
|
||||||
managementTotals: bgTotals(managementRows)
|
managementTotals: bgTotals(managementRows),
|
||||||
|
baronActiveRows: baronActiveRows,
|
||||||
|
baronProjectTotals: bgTotals(baronActiveRows),
|
||||||
|
baronSoftwareCount: baronActiveRows.filter(isSoftwareProjectRow).length
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,13 +239,6 @@
|
|||||||
return bgActiveInYear(row, selectedYear);
|
return bgActiveInYear(row, selectedYear);
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeStatusLabel(status) {
|
|
||||||
var value = String(status || "").trim();
|
|
||||||
if (!value) return "-";
|
|
||||||
if (value.indexOf("진행") >= 0) return "과업 진행중";
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatSplitPercent(split) {
|
function formatSplitPercent(split) {
|
||||||
var numeric = parseFloat(String(split || "").replace(/[^0-9.\-]/g, ""));
|
var numeric = parseFloat(String(split || "").replace(/[^0-9.\-]/g, ""));
|
||||||
if (!Number.isFinite(numeric) || numeric === 0) return "분담율 -%";
|
if (!Number.isFinite(numeric) || numeric === 0) return "분담율 -%";
|
||||||
@@ -204,17 +259,57 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function groupSortRank(row) {
|
function groupSortRank(row) {
|
||||||
var selectedYear = Number((S.dashboard && S.dashboard.year) || projectYear(row) || 0);
|
|
||||||
var startYear = Number(projectYear(row) || 0);
|
var startYear = Number(projectYear(row) || 0);
|
||||||
if (typeof bgCompletedInYear === "function" && bgCompletedInYear(row, String(selectedYear))) return 9999;
|
|
||||||
if (!startYear) return 9998;
|
if (!startYear) return 9998;
|
||||||
return startYear;
|
return startYear;
|
||||||
}
|
}
|
||||||
|
|
||||||
function tableGroupLabel(row) {
|
function tableGroupLabel(row) {
|
||||||
var startYear = projectYear(row);
|
var startYear = projectYear(row);
|
||||||
if (/^20\d{2}$/.test(startYear)) return startYear + "년";
|
if (/^20\d{2}$/.test(startYear)) return startYear + " " + projectTypeLabel(row);
|
||||||
return "미지정";
|
return "미지정 " + projectTypeLabel(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareDashboardRows(a, b) {
|
||||||
|
var typeRankDiff = projectTypeRank(a) - projectTypeRank(b);
|
||||||
|
if (typeRankDiff !== 0) return typeRankDiff;
|
||||||
|
var groupDiff = groupSortRank(a) - groupSortRank(b);
|
||||||
|
if (groupDiff !== 0) return groupDiff;
|
||||||
|
var sinkDiff = Number(shouldSinkProjectName(a)) - Number(shouldSinkProjectName(b));
|
||||||
|
if (sinkDiff !== 0) return sinkDiff;
|
||||||
|
return bgNormalizeText(a && a.name).localeCompare(bgNormalizeText(b && b.name), "ko");
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterCategoryLabel(row) {
|
||||||
|
return projectTypeLabel(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterClientLabel(row) {
|
||||||
|
if (typeof normalizeClientDisplay === "function") {
|
||||||
|
return normalizeClientDisplay(row && row.client);
|
||||||
|
}
|
||||||
|
return bgNormalizeText(row && row.client) || "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterOrderLabel(row) {
|
||||||
|
return bgNormalizeText(row && row.order) || "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
function receivableFilterLabel(row) {
|
||||||
|
var amount = Number((row && row.recv) || 0);
|
||||||
|
if (amount <= 0) return "미수 없음";
|
||||||
|
if (amount < 10000000) return "1천만 미만";
|
||||||
|
if (amount < 100000000) return "1천만 이상";
|
||||||
|
return "1억 이상";
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshFilterDom() {
|
||||||
|
E.filterButtons = Object.fromEntries(Array.from(document.querySelectorAll(".th-trigger")).map(function (el) {
|
||||||
|
return [el.dataset.filter, el];
|
||||||
|
}));
|
||||||
|
E.filterMenus = Object.fromEntries(Array.from(document.querySelectorAll(".th-menu")).map(function (el) {
|
||||||
|
return [el.dataset.filter, el];
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderLedgerTable() {
|
function renderLedgerTable() {
|
||||||
@@ -236,12 +331,7 @@
|
|||||||
+ '<th class="num"><div class="th-head end"><button type="button" class="th-trigger" data-filter="rate" data-label="수금률"><span class="th-title">수금률</span><span class="th-mark"></span><span class="th-caret">▼</span></button><div id="filterRateMenu" class="th-menu" data-filter="rate"></div></div></th>'
|
+ '<th class="num"><div class="th-head end"><button type="button" class="th-trigger" data-filter="rate" data-label="수금률"><span class="th-title">수금률</span><span class="th-mark"></span><span class="th-caret">▼</span></button><div id="filterRateMenu" class="th-menu" data-filter="rate"></div></div></th>'
|
||||||
+ "</tr>";
|
+ "</tr>";
|
||||||
}
|
}
|
||||||
var rows = (Array.isArray(S.viewRows) ? S.viewRows : []).slice().sort(function (a, b) {
|
var rows = (Array.isArray(S.viewRows) ? S.viewRows : []).slice().sort(compareDashboardRows);
|
||||||
var ar = groupSortRank(a);
|
|
||||||
var br = groupSortRank(b);
|
|
||||||
if (ar !== br) return ar - br;
|
|
||||||
return Number(b.recv || 0) - Number(a.recv || 0);
|
|
||||||
});
|
|
||||||
S.viewRows = rows;
|
S.viewRows = rows;
|
||||||
var lastGroupLabel = "";
|
var lastGroupLabel = "";
|
||||||
E.tbody.innerHTML = rows.map(function (r) {
|
E.tbody.innerHTML = rows.map(function (r) {
|
||||||
@@ -259,7 +349,7 @@
|
|||||||
+ '<td><button type="button" class="project-link" data-project-key="' + escAttr(String(r.code || "") + "|" + String(r.name || "")) + '">' + esc(r.name || "-") + '</button><div class="subline">' + esc(r.periodText || "-") + '</div></td>'
|
+ '<td><button type="button" class="project-link" data-project-key="' + escAttr(String(r.code || "") + "|" + String(r.name || "")) + '">' + esc(r.name || "-") + '</button><div class="subline">' + esc(r.periodText || "-") + '</div></td>'
|
||||||
+ '<td><div class="client-main">' + esc((r.client || "").trim() || "-") + '</div><div class="subline">' + esc(formatSplitPercent(r.split)) + '</div></td>'
|
+ '<td><div class="client-main">' + esc((r.client || "").trim() || "-") + '</div><div class="subline">' + esc(formatSplitPercent(r.split)) + '</div></td>'
|
||||||
+ '<td><div>' + esc(r.order || "-") + '</div></td>'
|
+ '<td><div>' + esc(r.order || "-") + '</div></td>'
|
||||||
+ '<td><div class="badge ' + (String(r.status || "").indexOf("완료") >= 0 ? 'ok' : '') + '">' + esc(normalizeStatusLabel(r.status)) + '</div></td>'
|
+ '<td><div class="badge ' + (rowStatusLabel(r) === "준공" ? 'ok' : '') + '">' + esc(rowStatusLabel(r)) + '</div></td>'
|
||||||
+ '<td class="num"><strong>' + esc(won(r.cSup || 0)) + '</strong></td>'
|
+ '<td class="num"><strong>' + esc(won(r.cSup || 0)) + '</strong></td>'
|
||||||
+ '<td class="num"><strong>' + esc(r.outsourceCost ? won(r.outsourceCost) : "-") + '</strong></td>'
|
+ '<td class="num"><strong>' + esc(r.outsourceCost ? won(r.outsourceCost) : "-") + '</strong></td>'
|
||||||
+ '<td class="num"><strong>' + esc(won(r.recv || 0)) + '</strong></td>'
|
+ '<td class="num"><strong>' + esc(won(r.recv || 0)) + '</strong></td>'
|
||||||
@@ -267,6 +357,8 @@
|
|||||||
+ '<td class="num"><strong style="color:' + (isSettledRow(r) ? '#b7aa93' : '#1a5645') + '">' + esc((Number(r.rate || 0)).toFixed(2) + "%") + '</strong></td>'
|
+ '<td class="num"><strong style="color:' + (isSettledRow(r) ? '#b7aa93' : '#1a5645') + '">' + esc((Number(r.rate || 0)).toFixed(2) + "%") + '</strong></td>'
|
||||||
+ '</tr>';
|
+ '</tr>';
|
||||||
}).join("");
|
}).join("");
|
||||||
|
refreshFilterDom();
|
||||||
|
if (typeof syncColumnFilters === "function") syncColumnFilters(S.all);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderCollectionBoard(r) {
|
function renderCollectionBoard(r) {
|
||||||
@@ -379,10 +471,8 @@
|
|||||||
}
|
}
|
||||||
var years = bgEnsureYear(S.all);
|
var years = bgEnsureYear(S.all);
|
||||||
var summary = bgSummarize(S.all, S.dashboard.year);
|
var summary = bgSummarize(S.all, S.dashboard.year);
|
||||||
var rows = Array.isArray(S.rows) ? S.rows : [];
|
var totals = summary.baronProjectTotals;
|
||||||
var visibleBaronProjectRows = rows.filter(isBaronProjectRow);
|
var totalRate = totals.c > 0 ? (totals.col / totals.c) * 100 : 0;
|
||||||
var totals = bgTotals(visibleBaronProjectRows);
|
|
||||||
var totalRate = typeof rate === "function" ? rate("", totals.col, totals.col + totals.recv) : 0;
|
|
||||||
var toolbarHtml = '<div class="cards-toolbar">'
|
var toolbarHtml = '<div class="cards-toolbar">'
|
||||||
+ '<div class="cards-toolbar-row">'
|
+ '<div class="cards-toolbar-row">'
|
||||||
+ years.map(function (year) {
|
+ years.map(function (year) {
|
||||||
@@ -393,14 +483,14 @@
|
|||||||
+ '<div class="cards-toolbar-metrics">'
|
+ '<div class="cards-toolbar-metrics">'
|
||||||
+ '<button type="button" class="summary-filter-chip ' + (S.dashboard.section === "active" ? "active" : "") + '" data-dashboard-section="active"><span class="label">' + esc(summary.targetYear) + '년 진행과업</span><span class="count">' + summary.activeRows.length.toLocaleString("ko-KR") + '건</span><span class="meta">전년도 이월 사업 포함</span></button>'
|
+ '<button type="button" class="summary-filter-chip ' + (S.dashboard.section === "active" ? "active" : "") + '" data-dashboard-section="active"><span class="label">' + esc(summary.targetYear) + '년 진행과업</span><span class="count">' + summary.activeRows.length.toLocaleString("ko-KR") + '건</span><span class="meta">전년도 이월 사업 포함</span></button>'
|
||||||
+ '<button type="button" class="summary-filter-chip ' + (S.dashboard.section === "new" ? "active" : "") + '" data-dashboard-section="new"><span class="label">' + esc(summary.targetYear) + '년 신규프로젝트</span><span class="count">' + summary.newProjectRows.length.toLocaleString("ko-KR") + '건</span><span class="meta">계약기간 시작년도 기준</span></button>'
|
+ '<button type="button" class="summary-filter-chip ' + (S.dashboard.section === "new" ? "active" : "") + '" data-dashboard-section="new"><span class="label">' + esc(summary.targetYear) + '년 신규프로젝트</span><span class="count">' + summary.newProjectRows.length.toLocaleString("ko-KR") + '건</span><span class="meta">계약기간 시작년도 기준</span></button>'
|
||||||
+ '<button type="button" class="summary-filter-chip ' + (S.dashboard.section === "completed" ? "active" : "") + '" data-dashboard-section="completed"><span class="label">' + esc(summary.targetYear) + '년 완료과업</span><span class="count">' + summary.completedRows.length.toLocaleString("ko-KR") + '건</span><span class="meta">해당년도 종료 사업 기준</span></button>'
|
+ '<button type="button" class="summary-filter-chip ' + (S.dashboard.section === "completed" ? "active" : "") + '" data-dashboard-section="completed"><span class="label">' + esc(summary.targetYear) + '년 완료과업</span><span class="count">' + summary.completedRows.length.toLocaleString("ko-KR") + '건</span><span class="meta">진행상태 준공 기준</span></button>'
|
||||||
+ "</div></div>";
|
+ "</div></div>";
|
||||||
var cards = [
|
var cards = [
|
||||||
{ label: summary.targetYear + "년 프로젝트", value: visibleBaronProjectRows.length.toLocaleString("ko-KR") + " 건", note: "" },
|
{ label: summary.targetYear + "년 프로젝트", value: summary.baronActiveRows.length.toLocaleString("ko-KR") + "건 (" + summary.baronSoftwareCount.toLocaleString("ko-KR") + "건)", note: "바론 수행중 프로젝트 / SW" },
|
||||||
{ label: "계약금", value: won(totals.c), note: "" },
|
{ label: "계약금 (VAT별도)", value: won(totals.c), note: "" },
|
||||||
{ label: "수금액", value: won(totals.col), note: "" },
|
{ label: "수금액", value: won(totals.col), note: "" },
|
||||||
{ label: "미수금", value: won(totals.recv), note: "" },
|
{ label: "미수금", value: won(totals.recv), note: "" },
|
||||||
{ label: "수금률(%)", value: totalRate.toFixed(2) + "%", note: "" },
|
{ label: "수금율", value: totalRate.toFixed(2) + "%", note: "계약금 대비 수금액" },
|
||||||
{ label: "경영지원서비스 금액", value: won(summary.managementTotals.c), note: "", className: "management" }
|
{ label: "경영지원서비스 금액", value: won(summary.managementTotals.c), note: "", className: "management" }
|
||||||
];
|
];
|
||||||
E.cards.innerHTML = toolbarHtml + cards.map(function (card) {
|
E.cards.innerHTML = toolbarHtml + cards.map(function (card) {
|
||||||
@@ -429,9 +519,62 @@
|
|||||||
S.rows = searched.filter(function (r) {
|
S.rows = searched.filter(function (r) {
|
||||||
return bgMatches(r) && matchesColumnFilters(r);
|
return bgMatches(r) && matchesColumnFilters(r);
|
||||||
});
|
});
|
||||||
|
S.rows.sort(compareDashboardRows);
|
||||||
render();
|
render();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
filterDefinitions = function () {
|
||||||
|
return [
|
||||||
|
{ key: "cat", map: filterCategoryLabel },
|
||||||
|
{ key: "code", map: function (r) { return r.code || "-"; } },
|
||||||
|
{ key: "name", map: function (r) { return r.name || "-"; } },
|
||||||
|
{ key: "client", map: filterClientLabel },
|
||||||
|
{ key: "order", map: filterOrderLabel },
|
||||||
|
{ key: "status", map: rowStatusLabel },
|
||||||
|
{ key: "amount", map: amountFilterLabel },
|
||||||
|
{ key: "outsource", map: outsourceFilterLabel },
|
||||||
|
{ key: "receivable", map: receivableFilterLabel },
|
||||||
|
{ key: "collected", map: collectedFilterLabel },
|
||||||
|
{ key: "rate", map: rateFilterLabel }
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
updateFilterButtons = function () {
|
||||||
|
Object.keys(E.filterButtons || {}).forEach(function (key) {
|
||||||
|
var btn = E.filterButtons[key];
|
||||||
|
if (!btn) return;
|
||||||
|
var active = !!S.filters[key];
|
||||||
|
btn.classList.toggle("active", active);
|
||||||
|
btn.title = active ? ((btn.dataset.label || "") + ": " + S.filters[key]) : (btn.dataset.label || "");
|
||||||
|
var mark = btn.querySelector(".th-mark");
|
||||||
|
if (mark) mark.textContent = active ? "•" : "";
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
syncColumnFilters = function (rows) {
|
||||||
|
filterDefinitions().forEach(function (def) {
|
||||||
|
var values = uniqueFilterValues(rows, def.map);
|
||||||
|
if (S.filters[def.key] && !values.includes(S.filters[def.key])) delete S.filters[def.key];
|
||||||
|
renderFilterMenu(def.key, values);
|
||||||
|
});
|
||||||
|
updateFilterButtons();
|
||||||
|
};
|
||||||
|
|
||||||
|
matchesColumnFilters = function (r) {
|
||||||
|
if (S.filters.cat && filterCategoryLabel(r) !== S.filters.cat) return false;
|
||||||
|
if (S.filters.code && (r.code || "-") !== S.filters.code) return false;
|
||||||
|
if (S.filters.name && (r.name || "-") !== S.filters.name) return false;
|
||||||
|
if (S.filters.client && filterClientLabel(r) !== S.filters.client) return false;
|
||||||
|
if (S.filters.order && filterOrderLabel(r) !== S.filters.order) return false;
|
||||||
|
if (S.filters.status && rowStatusLabel(r) !== S.filters.status) return false;
|
||||||
|
if (S.filters.amount && amountFilterLabel(r) !== S.filters.amount) return false;
|
||||||
|
if (S.filters.outsource && outsourceFilterLabel(r) !== S.filters.outsource) return false;
|
||||||
|
if (S.filters.receivable && receivableFilterLabel(r) !== S.filters.receivable) return false;
|
||||||
|
if (S.filters.collected && collectedFilterLabel(r) !== S.filters.collected) return false;
|
||||||
|
if (S.filters.rate && rateFilterLabel(r) !== S.filters.rate) return false;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
if (E.cards && !E.cards.dataset.dashboardBound) {
|
if (E.cards && !E.cards.dataset.dashboardBound) {
|
||||||
E.cards.dataset.dashboardBound = "true";
|
E.cards.dataset.dashboardBound = "true";
|
||||||
E.cards.addEventListener("click", function (event) {
|
E.cards.addEventListener("click", function (event) {
|
||||||
@@ -472,6 +615,26 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var panel = document.querySelector(".panel");
|
||||||
|
if (panel && !panel.dataset.ledgerFilterBound) {
|
||||||
|
panel.dataset.ledgerFilterBound = "true";
|
||||||
|
panel.addEventListener("click", function (event) {
|
||||||
|
var trigger = event.target && event.target.closest ? event.target.closest(".th-trigger") : null;
|
||||||
|
if (trigger) {
|
||||||
|
refreshFilterDom();
|
||||||
|
event.stopPropagation();
|
||||||
|
toggleFilterMenu(trigger.dataset.filter);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var option = event.target && event.target.closest ? event.target.closest("button[data-filter-value]") : null;
|
||||||
|
var menu = event.target && event.target.closest ? event.target.closest(".th-menu") : null;
|
||||||
|
if (option && menu) {
|
||||||
|
event.stopPropagation();
|
||||||
|
setFilterValue(menu.dataset.filter, option.getAttribute("data-filter-value") || "");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
setTimeout(function () {
|
setTimeout(function () {
|
||||||
try {
|
try {
|
||||||
filter();
|
filter();
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ echo "[smoke] running 8081 endpoint checks"
|
|||||||
docker exec mh-dashboard-organization-dev-backend-1 python - <<'PY'
|
docker exec mh-dashboard-organization-dev-backend-1 python - <<'PY'
|
||||||
import sys
|
import sys
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
checks = [
|
checks = [
|
||||||
@@ -43,6 +44,21 @@ for name, url, needle in checks:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
failed.append(f"{name}: {exc}")
|
failed.append(f"{name}: {exc}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen("http://127.0.0.1:8000/api/integration/summary", timeout=8) as response:
|
||||||
|
payload = json.loads(response.read().decode())
|
||||||
|
counts = payload.get("counts") or {}
|
||||||
|
work_logs = int(counts.get("work_logs") or 0)
|
||||||
|
vouchers = int(counts.get("vouchers") or 0)
|
||||||
|
if work_logs <= 0:
|
||||||
|
failed.append(f"analysis-summary: work_logs is {work_logs}")
|
||||||
|
if vouchers <= 0:
|
||||||
|
failed.append(f"analysis-summary: vouchers is {vouchers}")
|
||||||
|
if work_logs > 0 and vouchers > 0:
|
||||||
|
print(f"[ok] analysis-summary -> work_logs={work_logs}, vouchers={vouchers}")
|
||||||
|
except Exception as exc:
|
||||||
|
failed.append(f"analysis-summary: {exc}")
|
||||||
|
|
||||||
if failed:
|
if failed:
|
||||||
print("[smoke] failures detected:")
|
print("[smoke] failures detected:")
|
||||||
for item in failed:
|
for item in failed:
|
||||||
|
|||||||
13
scripts/publish_organization_app.sh
Executable file
13
scripts/publish_organization_app.sh
Executable file
@@ -0,0 +1,13 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
APP_DIR="${ROOT_DIR}/frontend/apps/organization"
|
||||||
|
|
||||||
|
cp "${APP_DIR}/index.html" "${ROOT_DIR}/DashBoard-organization.html"
|
||||||
|
cp "${APP_DIR}/assets/common.css" "${ROOT_DIR}/legacy/static/common.css"
|
||||||
|
cp "${APP_DIR}/assets/organization.css" "${ROOT_DIR}/legacy/static/organization.css"
|
||||||
|
cp "${APP_DIR}/assets/organization.js" "${ROOT_DIR}/legacy/static/organization.js"
|
||||||
|
|
||||||
|
echo "Published organization app source to legacy runtime files"
|
||||||
@@ -5,9 +5,7 @@ set -euo pipefail
|
|||||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
APP_DIR="${ROOT_DIR}/frontend/apps/payment"
|
APP_DIR="${ROOT_DIR}/frontend/apps/payment"
|
||||||
TARGET_FILE="${ROOT_DIR}/incoming-files/served/payment.html"
|
TARGET_FILE="${ROOT_DIR}/incoming-files/served/payment.html"
|
||||||
COMPARE_FILE="${ROOT_DIR}/incoming-files/payment.html"
|
|
||||||
|
|
||||||
cp "${APP_DIR}/index.html" "${TARGET_FILE}"
|
cp "${APP_DIR}/index.html" "${TARGET_FILE}"
|
||||||
cp "${APP_DIR}/index.html" "${COMPARE_FILE}"
|
|
||||||
|
|
||||||
echo "Published payment app source to ${TARGET_FILE}"
|
echo "Published payment app source to ${TARGET_FILE}"
|
||||||
|
|||||||
@@ -5,9 +5,7 @@ set -euo pipefail
|
|||||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
APP_DIR="${ROOT_DIR}/frontend/apps/team"
|
APP_DIR="${ROOT_DIR}/frontend/apps/team"
|
||||||
TARGET_FILE="${ROOT_DIR}/incoming-files/served/mh.html"
|
TARGET_FILE="${ROOT_DIR}/incoming-files/served/mh.html"
|
||||||
COMPARE_FILE="${ROOT_DIR}/incoming-files/mh.html"
|
|
||||||
|
|
||||||
cp "${APP_DIR}/index.html" "${TARGET_FILE}"
|
cp "${APP_DIR}/index.html" "${TARGET_FILE}"
|
||||||
cp "${APP_DIR}/index.html" "${COMPARE_FILE}"
|
|
||||||
|
|
||||||
echo "Published team app source to ${TARGET_FILE}"
|
echo "Published team app source to ${TARGET_FILE}"
|
||||||
|
|||||||
@@ -9,6 +9,28 @@ DEV_PROJECT_NAME="${DEV_PROJECT_NAME:-mh-dashboard-organization-dev}"
|
|||||||
DEV_COMPOSE_FILE="${DEV_COMPOSE_FILE:-${DEV_DIR}/docker-compose.8081.yml}"
|
DEV_COMPOSE_FILE="${DEV_COMPOSE_FILE:-${DEV_DIR}/docker-compose.8081.yml}"
|
||||||
SCOPE="${1:-minimal}"
|
SCOPE="${1:-minimal}"
|
||||||
|
|
||||||
|
ANALYSIS_TABLES=(
|
||||||
|
integration_import_batches
|
||||||
|
integration_raw_organization_rows
|
||||||
|
integration_raw_mh_rows
|
||||||
|
integration_raw_mh_pm_rows
|
||||||
|
integration_raw_payment_rows
|
||||||
|
integration_project_aliases
|
||||||
|
integration_project_category_mappings
|
||||||
|
integration_project_pm_assignments
|
||||||
|
integration_projects
|
||||||
|
integration_work_logs
|
||||||
|
integration_work_log_segments
|
||||||
|
integration_vouchers
|
||||||
|
)
|
||||||
|
|
||||||
|
MINIMAL_PRESERVE_TABLES=(
|
||||||
|
integration_project_pm_assignments
|
||||||
|
integration_work_logs
|
||||||
|
integration_work_log_segments
|
||||||
|
integration_vouchers
|
||||||
|
)
|
||||||
|
|
||||||
if [[ ! -f "${PROD_DIR}/docker-compose.yml" ]]; then
|
if [[ ! -f "${PROD_DIR}/docker-compose.yml" ]]; then
|
||||||
echo "Production workspace not found: ${PROD_DIR}" >&2
|
echo "Production workspace not found: ${PROD_DIR}" >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -38,35 +60,11 @@ case "${SCOPE}" in
|
|||||||
)
|
)
|
||||||
;;
|
;;
|
||||||
analysis)
|
analysis)
|
||||||
TABLES=(
|
TABLES=("${ANALYSIS_TABLES[@]}")
|
||||||
integration_import_batches
|
|
||||||
integration_raw_organization_rows
|
|
||||||
integration_raw_mh_rows
|
|
||||||
integration_raw_mh_pm_rows
|
|
||||||
integration_raw_payment_rows
|
|
||||||
integration_project_aliases
|
|
||||||
integration_project_category_mappings
|
|
||||||
integration_project_pm_assignments
|
|
||||||
integration_projects
|
|
||||||
integration_work_logs
|
|
||||||
integration_work_log_segments
|
|
||||||
integration_vouchers
|
|
||||||
)
|
|
||||||
;;
|
;;
|
||||||
full)
|
full)
|
||||||
TABLES=(
|
TABLES=(
|
||||||
integration_import_batches
|
"${ANALYSIS_TABLES[@]}"
|
||||||
integration_raw_organization_rows
|
|
||||||
integration_raw_mh_rows
|
|
||||||
integration_raw_mh_pm_rows
|
|
||||||
integration_raw_payment_rows
|
|
||||||
integration_project_aliases
|
|
||||||
integration_project_category_mappings
|
|
||||||
integration_project_pm_assignments
|
|
||||||
integration_projects
|
|
||||||
integration_work_logs
|
|
||||||
integration_work_log_segments
|
|
||||||
integration_vouchers
|
|
||||||
member_aliases
|
member_aliases
|
||||||
member_overrides
|
member_overrides
|
||||||
member_retirements
|
member_retirements
|
||||||
@@ -81,6 +79,16 @@ case "${SCOPE}" in
|
|||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
PRESERVE_TABLES=()
|
||||||
|
if [[ "${SCOPE}" == "minimal" ]]; then
|
||||||
|
PRESERVE_TABLES=("${MINIMAL_PRESERVE_TABLES[@]}")
|
||||||
|
fi
|
||||||
|
|
||||||
|
DUMP_TABLES=("${TABLES[@]}")
|
||||||
|
if [[ ${#PRESERVE_TABLES[@]} -gt 0 ]]; then
|
||||||
|
DUMP_TABLES+=("${PRESERVE_TABLES[@]}")
|
||||||
|
fi
|
||||||
|
|
||||||
PROD_COMPOSE=(docker compose --project-directory "${PROD_DIR}")
|
PROD_COMPOSE=(docker compose --project-directory "${PROD_DIR}")
|
||||||
DEV_COMPOSE=(docker compose -p "${DEV_PROJECT_NAME}" --env-file "${DEV_DIR}/.env" -f "${DEV_COMPOSE_FILE}")
|
DEV_COMPOSE=(docker compose -p "${DEV_PROJECT_NAME}" --env-file "${DEV_DIR}/.env" -f "${DEV_COMPOSE_FILE}")
|
||||||
|
|
||||||
@@ -129,7 +137,7 @@ echo "[4/8] Building truncate script for ${SCOPE} scope"
|
|||||||
|
|
||||||
echo "[5/8] Dumping ${SCOPE} data from 8080 source DB"
|
echo "[5/8] Dumping ${SCOPE} data from 8080 source DB"
|
||||||
TABLE_ARGS=()
|
TABLE_ARGS=()
|
||||||
for table in "${TABLES[@]}"; do
|
for table in "${DUMP_TABLES[@]}"; do
|
||||||
TABLE_ARGS+=(-t "public.${table}")
|
TABLE_ARGS+=(-t "public.${table}")
|
||||||
done
|
done
|
||||||
run_compose "${PROD_DIR}" "${PROD_COMPOSE[@]}" exec -T db \
|
run_compose "${PROD_DIR}" "${PROD_COMPOSE[@]}" exec -T db \
|
||||||
@@ -193,7 +201,7 @@ echo "[7.8/8] Resetting serial sequences"
|
|||||||
echo "SELECT setval(pg_get_serial_sequence('public.member_retirements', 'id'), COALESCE((SELECT MAX(id) FROM public.member_retirements), 1), true);"
|
echo "SELECT setval(pg_get_serial_sequence('public.member_retirements', 'id'), COALESCE((SELECT MAX(id) FROM public.member_retirements), 1), true);"
|
||||||
echo "SELECT setval(pg_get_serial_sequence('public.seat_maps', 'id'), COALESCE((SELECT MAX(id) FROM public.seat_maps), 1), true);"
|
echo "SELECT setval(pg_get_serial_sequence('public.seat_maps', 'id'), COALESCE((SELECT MAX(id) FROM public.seat_maps), 1), true);"
|
||||||
echo "SELECT setval(pg_get_serial_sequence('public.seat_slots', 'id'), COALESCE((SELECT MAX(id) FROM public.seat_slots), 1), true);"
|
echo "SELECT setval(pg_get_serial_sequence('public.seat_slots', 'id'), COALESCE((SELECT MAX(id) FROM public.seat_slots), 1), true);"
|
||||||
if [[ "${SCOPE}" == "analysis" || "${SCOPE}" == "full" ]]; then
|
if [[ "${SCOPE}" == "analysis" || "${SCOPE}" == "full" || "${#PRESERVE_TABLES[@]}" -gt 0 ]]; then
|
||||||
echo "SELECT setval(pg_get_serial_sequence('public.integration_import_batches', 'id'), COALESCE((SELECT MAX(id) FROM public.integration_import_batches), 1), true);"
|
echo "SELECT setval(pg_get_serial_sequence('public.integration_import_batches', 'id'), COALESCE((SELECT MAX(id) FROM public.integration_import_batches), 1), true);"
|
||||||
echo "SELECT setval(pg_get_serial_sequence('public.integration_raw_organization_rows', 'id'), COALESCE((SELECT MAX(id) FROM public.integration_raw_organization_rows), 1), true);"
|
echo "SELECT setval(pg_get_serial_sequence('public.integration_raw_organization_rows', 'id'), COALESCE((SELECT MAX(id) FROM public.integration_raw_organization_rows), 1), true);"
|
||||||
echo "SELECT setval(pg_get_serial_sequence('public.integration_raw_mh_rows', 'id'), COALESCE((SELECT MAX(id) FROM public.integration_raw_mh_rows), 1), true);"
|
echo "SELECT setval(pg_get_serial_sequence('public.integration_raw_mh_rows', 'id'), COALESCE((SELECT MAX(id) FROM public.integration_raw_mh_rows), 1), true);"
|
||||||
@@ -236,7 +244,7 @@ UNION ALL
|
|||||||
SELECT 'auth_users', COUNT(*)::text FROM auth.users
|
SELECT 'auth_users', COUNT(*)::text FROM auth.users
|
||||||
ORDER BY table_name;
|
ORDER BY table_name;
|
||||||
SQL
|
SQL
|
||||||
if [[ "${SCOPE}" == "analysis" || "${SCOPE}" == "full" ]]; then
|
if [[ "${SCOPE}" == "analysis" || "${SCOPE}" == "full" || "${#PRESERVE_TABLES[@]}" -gt 0 ]]; then
|
||||||
cat <<'SQL'
|
cat <<'SQL'
|
||||||
SELECT 'integration_work_logs', COUNT(*)::text FROM public.integration_work_logs
|
SELECT 'integration_work_logs', COUNT(*)::text FROM public.integration_work_logs
|
||||||
UNION ALL
|
UNION ALL
|
||||||
|
|||||||
Reference in New Issue
Block a user