Add multi-office seat maps and dev/prod DB sync protocol

This commit is contained in:
hyunho
2026-03-27 16:34:43 +09:00
parent 1d15cf9b9b
commit d66614123e
16 changed files with 3427 additions and 146 deletions

View File

@@ -8,6 +8,7 @@ import hmac
from io import BytesIO, StringIO from io import BytesIO, StringIO
import json import json
import math import math
from decimal import Decimal, ROUND_HALF_UP
from pathlib import Path from pathlib import Path
import re import re
import secrets import secrets
@@ -42,9 +43,24 @@ app.add_middleware(
LEGACY_STATIC_DIR = LEGACY_DIR / "static" LEGACY_STATIC_DIR = LEGACY_DIR / "static"
INCOMING_FILES_DIR = BASE_DIR / "incoming-files" INCOMING_FILES_DIR = BASE_DIR / "incoming-files"
FIXED_OFFICE_SOURCE_KEY = "technical-development-center" FIXED_OFFICE_SOURCE_KEY = "technical-development-center"
FIXED_OFFICE_NAME = "기술개발센터" FIXED_OFFICE_CONFIGS = {
FIXED_OFFICE_TEMPLATE_PATH = Path(__file__).with_name("center_chair_viewer_template.html") "technical-development-center": {
_fixed_office_cache: dict[str, object] | None = None "name": "기술개발센터",
"html_path": INCOMING_FILES_DIR / "seat" / "center_chair_people_map.html",
"payload_path": INCOMING_FILES_DIR / "seat" / "center_chair_people_payload.js",
},
"hanmac-building-6f": {
"name": "한맥빌딩 6층",
"html_path": INCOMING_FILES_DIR / "seat" / "center_chair_people_map_6f.html",
"payload_path": INCOMING_FILES_DIR / "seat" / "center_chair_people_payload_6f.js",
},
"hanmac-building-7f": {
"name": "한맥빌딩 7층",
"html_path": INCOMING_FILES_DIR / "seat" / "center_chair_people_map_7f.html",
"payload_path": INCOMING_FILES_DIR / "seat" / "center_chair_people_payload_7f.js",
},
}
_fixed_office_cache: dict[str, dict[str, object]] = {}
AUTH_DEFAULT_PASSWORD = "1111" AUTH_DEFAULT_PASSWORD = "1111"
AUTH_PASSWORD_ITERATIONS = 390000 AUTH_PASSWORD_ITERATIONS = 390000
AUTH_SESSION_HOURS = 12 AUTH_SESSION_HOURS = 12
@@ -462,8 +478,11 @@ def fetch_active_seat_map() -> dict[str, object] | None:
return cur.fetchone() return cur.fetchone()
def ensure_fixed_office_seat_map(activate: bool = True) -> dict[str, object]: def ensure_fixed_office_seat_map(office_key: str = FIXED_OFFICE_SOURCE_KEY, activate: bool = True) -> dict[str, object]:
template = parse_fixed_office_template() config = FIXED_OFFICE_CONFIGS.get(office_key)
if not config:
raise HTTPException(status_code=404, detail="Fixed office configuration not found.")
template = parse_fixed_office_template(office_key)
slots = template["slots"] slots = template["slots"]
with get_conn() as conn: with get_conn() as conn:
with conn.cursor() as cur: with conn.cursor() as cur:
@@ -475,7 +494,7 @@ def ensure_fixed_office_seat_map(activate: bool = True) -> dict[str, object]:
AND source_url = %s AND source_url = %s
LIMIT 1 LIMIT 1
""", """,
(FIXED_OFFICE_SOURCE_KEY,), (office_key,),
) )
row = cur.fetchone() row = cur.fetchone()
if activate: if activate:
@@ -491,7 +510,7 @@ def ensure_fixed_office_seat_map(activate: bool = True) -> dict[str, object]:
VALUES (%s, '', 'fixed_html', %s, '', NULL, NULL, NULL, NULL, NULL, NULL, 1, 1, 0, %s) VALUES (%s, '', 'fixed_html', %s, '', NULL, NULL, NULL, NULL, NULL, NULL, 1, 1, 0, %s)
RETURNING id RETURNING id
""", """,
(FIXED_OFFICE_NAME, FIXED_OFFICE_SOURCE_KEY, activate), (str(config["name"]), office_key, activate),
) )
seat_map_id = int(cur.fetchone()["id"]) seat_map_id = int(cur.fetchone()["id"])
else: else:
@@ -511,7 +530,7 @@ def ensure_fixed_office_seat_map(activate: bool = True) -> dict[str, object]:
updated_at = NOW() updated_at = NOW()
WHERE id = %s WHERE id = %s
""", """,
(FIXED_OFFICE_NAME, FIXED_OFFICE_SOURCE_KEY, activate, seat_map_id), (str(config["name"]), office_key, activate, seat_map_id),
) )
cur.execute("SELECT id, slot_key FROM seat_slots WHERE seat_map_id = %s", (seat_map_id,)) cur.execute("SELECT id, slot_key FROM seat_slots WHERE seat_map_id = %s", (seat_map_id,))
@@ -591,18 +610,36 @@ def decode_segment_values(raw_base64: str) -> list[int]:
return [item[0] for item in struct.iter_unpack("<i", decoded)] return [item[0] for item in struct.iter_unpack("<i", decoded)]
def parse_fixed_office_template() -> dict[str, object]: def parse_fixed_office_template(office_key: str = FIXED_OFFICE_SOURCE_KEY) -> dict[str, object]:
global _fixed_office_cache cached = _fixed_office_cache.get(office_key)
if _fixed_office_cache is not None: if cached is not None:
return _fixed_office_cache return cached
if not FIXED_OFFICE_TEMPLATE_PATH.exists():
raise HTTPException(status_code=500, detail="Fixed office viewer template not found.")
html = FIXED_OFFICE_TEMPLATE_PATH.read_text(encoding="utf-8") config = FIXED_OFFICE_CONFIGS.get(office_key)
match = re.search(r"const DATA = (\{.*?\});\n\s*function decodeSegments", html, flags=re.S) if not config:
if not match: raise HTTPException(status_code=404, detail="Fixed office configuration not found.")
raise HTTPException(status_code=500, detail="Fixed office viewer data not found.")
data = json.loads(match.group(1)) html_path = Path(str(config["html_path"]))
payload_path = Path(str(config["payload_path"]))
if not html_path.exists():
raise HTTPException(status_code=500, detail=f"Fixed office viewer template not found: {office_key}")
if not payload_path.exists():
raise HTTPException(status_code=500, detail=f"Fixed office payload not found: {office_key}")
html = html_path.read_text(encoding="utf-8")
payload_js = payload_path.read_text(encoding="utf-8")
payload_match = re.search(r"window\.CHAIR_MAP_DATA\s*=\s*(\{.*\});?\s*$", payload_js, flags=re.S)
if not payload_match:
raise HTTPException(status_code=500, detail=f"Fixed office viewer data not found: {office_key}")
html = re.sub(
r'<script\s+src="\./[^"]+payload[^"]*\.js"></script>',
f"<script>{payload_js}</script>",
html,
count=1,
)
data = json.loads(payload_match.group(1))
chair_values = decode_segment_values(str(data["chairSegsB64"])) chair_values = decode_segment_values(str(data["chairSegsB64"]))
slots: list[dict[str, object]] = [] slots: list[dict[str, object]] = []
for index, chair in enumerate(data["chairs"]): for index, chair in enumerate(data["chairs"]):
@@ -633,12 +670,13 @@ def parse_fixed_office_template() -> dict[str, object]:
"layer_name": str(name), "layer_name": str(name),
} }
) )
_fixed_office_cache = { parsed = {
"html": html, "html": html,
"data": data, "data": data,
"slots": slots, "slots": slots,
} }
return _fixed_office_cache _fixed_office_cache[office_key] = parsed
return parsed
def is_chair_layer(layer_name: str) -> bool: def is_chair_layer(layer_name: str) -> bool:
@@ -1170,12 +1208,14 @@ def fetch_seat_layout(seat_map_id: int) -> dict[str, object]:
) )
placements = cur.fetchall() placements = cur.fetchall()
viewer_data: dict[str, object] | None = None viewer_data: dict[str, object] | None = None
if seat_map["source_type"] == "fixed_html" and seat_map.get("source_url") == FIXED_OFFICE_SOURCE_KEY: office_key = str(seat_map.get("source_url") or FIXED_OFFICE_SOURCE_KEY)
template = parse_fixed_office_template() fixed_office = FIXED_OFFICE_CONFIGS.get(office_key)
if seat_map["source_type"] == "fixed_html" and fixed_office:
template = parse_fixed_office_template(office_key)
viewer_data = { viewer_data = {
"meta": { "meta": {
"chair_count": len(template["slots"]), "chair_count": len(template["slots"]),
"office": FIXED_OFFICE_NAME, "office": str(fixed_office["name"]),
} }
} }
elif seat_map["source_type"] == "dxf" and seat_map.get("source_url"): elif seat_map["source_type"] == "dxf" and seat_map.get("source_url"):
@@ -1230,7 +1270,8 @@ def build_center_chair_viewer_html(layout: dict[str, object]) -> str:
placed_literal = json.dumps(sorted(set(placed_keys)), ensure_ascii=False, separators=(",", ":")) placed_literal = json.dumps(sorted(set(placed_keys)), ensure_ascii=False, separators=(",", ":"))
assignments_literal = json.dumps(assignment_items, ensure_ascii=False, separators=(",", ":")) assignments_literal = json.dumps(assignment_items, ensure_ascii=False, separators=(",", ":"))
if seat_map.get("source_type") == "fixed_html": if seat_map.get("source_type") == "fixed_html":
html = parse_fixed_office_template()["html"] office_key = str(seat_map.get("source_url") or FIXED_OFFICE_SOURCE_KEY)
html = parse_fixed_office_template(office_key)["html"]
else: else:
viewer_data = layout.get("viewer_data") viewer_data = layout.get("viewer_data")
if not isinstance(viewer_data, dict): if not isinstance(viewer_data, dict):
@@ -2806,8 +2847,35 @@ def fetch_project_metrics(limit: int = 500, start_date: str | None = None, end_d
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute( cur.execute(
""" """
WITH work_by_project AS ( WITH project_base AS (
SELECT SELECT
CASE
WHEN COALESCE(project_code, '') <> '' THEN project_code
ELSE regexp_replace(
lower(COALESCE(NULLIF(project_name, ''), NULLIF(display_name, ''), NULLIF(intranet_name, ''), '')),
'[^0-9a-z가-힣]+',
'',
'g'
)
END AS project_key,
project_code,
project_name,
display_name,
business_area,
business_subarea
FROM integration_projects
),
work_by_project AS (
SELECT
CASE
WHEN COALESCE(project_code, '') <> '' THEN project_code
ELSE regexp_replace(
lower(COALESCE(NULLIF(project_name, ''), '')),
'[^0-9a-z가-힣]+',
'',
'g'
)
END AS project_key,
COALESCE(project_code, '') AS project_code, COALESCE(project_code, '') AS project_code,
COALESCE(NULLIF(project_name, ''), COALESCE(project_code, '')) AS project_name, COALESCE(NULLIF(project_name, ''), COALESCE(project_code, '')) AS project_name,
SUM(hours) AS total_hours, SUM(hours) AS total_hours,
@@ -2817,10 +2885,19 @@ def fetch_project_metrics(limit: int = 500, start_date: str | None = None, end_d
JOIN integration_work_logs ON integration_work_logs.id = integration_work_log_segments.work_log_id JOIN integration_work_logs ON integration_work_logs.id = integration_work_log_segments.work_log_id
WHERE (%s::date IS NULL OR integration_work_logs.work_date >= %s::date) WHERE (%s::date IS NULL OR integration_work_logs.work_date >= %s::date)
AND (%s::date IS NULL OR integration_work_logs.work_date <= %s::date) AND (%s::date IS NULL OR integration_work_logs.work_date <= %s::date)
GROUP BY 1, 2 GROUP BY 1, 2, 3
), ),
voucher_by_project AS ( voucher_by_project AS (
SELECT SELECT
CASE
WHEN COALESCE(project_code, '') <> '' THEN project_code
ELSE regexp_replace(
lower(COALESCE(NULLIF(project_name, ''), '')),
'[^0-9a-z가-힣]+',
'',
'g'
)
END AS project_key,
COALESCE(project_code, '') AS project_code, COALESCE(project_code, '') AS project_code,
COALESCE(NULLIF(project_name, ''), COALESCE(project_code, '')) AS project_name, COALESCE(NULLIF(project_name, ''), COALESCE(project_code, '')) AS project_name,
SUM(income_amount) AS total_income, SUM(income_amount) AS total_income,
@@ -2829,7 +2906,8 @@ def fetch_project_metrics(limit: int = 500, start_date: str | None = None, end_d
FROM integration_vouchers FROM integration_vouchers
WHERE (%s::date IS NULL OR COALESCE(issue_date, claim_date) >= %s::date) WHERE (%s::date IS NULL OR COALESCE(issue_date, claim_date) >= %s::date)
AND (%s::date IS NULL OR COALESCE(issue_date, claim_date) <= %s::date) AND (%s::date IS NULL OR COALESCE(issue_date, claim_date) <= %s::date)
GROUP BY 1, 2 AND COALESCE(voucher_type, '') <> '제외'
GROUP BY 1, 2, 3
) )
SELECT SELECT
COALESCE(p.project_code, w.project_code, v.project_code) AS project_code, COALESCE(p.project_code, w.project_code, v.project_code) AS project_code,
@@ -2844,9 +2922,9 @@ def fetch_project_metrics(limit: int = 500, start_date: str | None = None, end_d
COALESCE(w.overtime_hours, 0) AS overtime_hours, COALESCE(w.overtime_hours, 0) AS overtime_hours,
COALESCE(v.voucher_count, 0) AS voucher_count, COALESCE(v.voucher_count, 0) AS voucher_count,
COALESCE(w.work_log_count, 0) AS work_log_count COALESCE(w.work_log_count, 0) AS work_log_count
FROM integration_projects p FROM project_base p
FULL OUTER JOIN work_by_project w ON w.project_code = p.project_code FULL OUTER JOIN work_by_project w ON w.project_key = p.project_key
FULL OUTER JOIN voucher_by_project v ON v.project_code = COALESCE(p.project_code, w.project_code) FULL OUTER JOIN voucher_by_project v ON v.project_key = COALESCE(p.project_key, w.project_key)
ORDER BY project_code ASC ORDER BY project_code ASC
LIMIT %s LIMIT %s
""", """,
@@ -2986,6 +3064,19 @@ def payment_analysis_parse_number(value: object) -> float:
return 0.0 return 0.0
def round_half_up_to_int(value: float | Decimal) -> int:
return int(Decimal(str(value)).quantize(Decimal("1"), rounding=ROUND_HALF_UP))
def round_half_up_to_2(value: float | Decimal) -> float:
return float(Decimal(str(value)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))
def calculate_labor_cost(hours: float | Decimal, rate: int | float | Decimal, multiplier: float | Decimal) -> int:
amount = Decimal(str(hours)) * Decimal(str(rate)) * Decimal(str(multiplier))
return round_half_up_to_int(amount)
def build_payment_work_rows_from_raw_mh( def build_payment_work_rows_from_raw_mh(
raw_rows: list[list[object]], raw_rows: list[list[object]],
category_by_project_key: dict[str, dict[str, str]], category_by_project_key: dict[str, dict[str, str]],
@@ -3057,6 +3148,7 @@ def build_payment_work_rows_from_raw_mh(
position = clean_text(payment_analysis_get_value(row, ["직책", "직급"])) position = clean_text(payment_analysis_get_value(row, ["직책", "직급"]))
user_state = clean_text(payment_analysis_get_value(row, ["user_state", "User State", "user state", "userstate", "User_State"])) user_state = clean_text(payment_analysis_get_value(row, ["user_state", "User State", "user state", "userstate", "User_State"]))
weekend_flag = clean_text(payment_analysis_get_value(row, ["주말/지각"])) weekend_flag = clean_text(payment_analysis_get_value(row, ["주말/지각"]))
is_weekend = "주말" in user_state or "주말" in weekend_flag
member_name = clean_text(payment_analysis_get_value(row, ["이름"])) member_name = clean_text(payment_analysis_get_value(row, ["이름"]))
work_date = clean_text(payment_analysis_get_value(row, ["근무일자", "날짜", "일자"])) work_date = clean_text(payment_analysis_get_value(row, ["근무일자", "날짜", "일자"]))
imported_labor = payment_analysis_parse_number(payment_analysis_get_value(row, ["산정금액", "인건비"])) imported_labor = payment_analysis_parse_number(payment_analysis_get_value(row, ["산정금액", "인건비"]))
@@ -3073,7 +3165,7 @@ def build_payment_work_rows_from_raw_mh(
weighted = [] weighted = []
total_weight = 0.0 total_weight = 0.0
for idx, segment in enumerate(segments): for idx, segment in enumerate(segments):
multiplier = 1.5 if ("주말" in user_state or segment["overtime"]) else 1.0 multiplier = 1.5 if (is_weekend or segment["overtime"]) else 1.0
weight = segment["hours"] * multiplier weight = segment["hours"] * multiplier
weighted.append((idx, weight)) weighted.append((idx, weight))
total_weight += weight total_weight += weight
@@ -3108,8 +3200,8 @@ def build_payment_work_rows_from_raw_mh(
if allocations: if allocations:
labor = int(allocations[idx] or 0) labor = int(allocations[idx] or 0)
else: else:
multiplier = 1.5 if ("주말" in user_state or segment["overtime"]) else 1.0 multiplier = 1.5 if (is_weekend or segment["overtime"]) else 1.0
labor = round(hours * rate * multiplier) labor = calculate_labor_cost(hours, rate, multiplier)
parsed_row = { parsed_row = {
"__values": [ "__values": [
work_date, work_date,
@@ -3245,8 +3337,8 @@ def fetch_payment_source_rows() -> dict[str, object]:
position = clean_text(row["title"]) position = clean_text(row["title"])
raw_hours = float(row["hours"] or 0) raw_hours = float(row["hours"] or 0)
adjusted_overtime_hours = float(row["overtime_hours_adjusted"] or 0) adjusted_overtime_hours = float(row["overtime_hours_adjusted"] or 0)
hours = adjusted_overtime_hours if bool(row["is_overtime"]) and adjusted_overtime_hours > 0 else raw_hours hours = adjusted_overtime_hours if bool(row["is_overtime"]) else raw_hours
hours = round(hours, 2) hours = round_half_up_to_2(hours)
rate = 28900 rate = 28900
if "이사" in position or "수석" in position: if "이사" in position or "수석" in position:
rate = 46600 rate = 46600
@@ -3254,7 +3346,11 @@ def fetch_payment_source_rows() -> dict[str, object]:
rate = 40500 rate = 40500
elif "선임" in position: elif "선임" in position:
rate = 35300 rate = 35300
labor = round(hours * rate * (1.5 if bool(row["is_overtime"]) or "주말" in clean_text(row["weekend_late_flag"]) else 1)) labor = calculate_labor_cost(
hours,
rate,
1.5 if bool(row["is_overtime"]) or "주말" in clean_text(row["weekend_late_flag"]) else 1,
)
parsed_row = { parsed_row = {
"__values": [ "__values": [
clean_text(row["work_date"]), clean_text(row["work_date"]),
@@ -3785,8 +3881,9 @@ async def create_dxf_seat_map(file: UploadFile = File(...), name: str = Form(...
@app.get("/api/seat-maps/active") @app.get("/api/seat-maps/active")
def get_active_seat_map() -> dict[str, dict[str, object]]: def get_active_seat_map(office_key: str | None = None) -> dict[str, dict[str, object]]:
seat_map = ensure_fixed_office_seat_map(activate=True) requested_key = (office_key or "").strip() or FIXED_OFFICE_SOURCE_KEY
seat_map = ensure_fixed_office_seat_map(requested_key, activate=requested_key == FIXED_OFFICE_SOURCE_KEY)
if seat_map is None: if seat_map is None:
raise HTTPException(status_code=404, detail="Active seat map not found.") raise HTTPException(status_code=404, detail="Active seat map not found.")
return {"item": seat_map} return {"item": seat_map}

View File

@@ -0,0 +1,182 @@
# Dev / Prod DB Protocol
## 목적
- `8081` 작업용은 기능 개발과 화면 검증을 먼저 수행하는 환경이다.
- `8080` 공개용은 실제 기준 데이터와 운영 화면을 제공하는 환경이다.
- 코드와 데이터의 기준을 분리해서 관리하되, 데이터 정본은 항상 `8080` 공개용 DB로 유지한다.
## 현재 구조
### 코드 경로
- 공개용 `8080`: `/home/hyunho/projects/mh-dashboard-organization`
- 작업용 `8081`: `/tmp/mh-dashboard-organization-dev`
### DB 볼륨
- 공개용 `8080`: `mh-dashboard-organization_postgres_data`
- 작업용 `8081`: `mh-dashboard-organization-dev_postgres_data`
즉 현재는 코드도 분리, DB도 분리 상태다.
## 정본 기준
- 코드 선행 환경: `8081`
- 데이터 정본: `8080`
- 공개 반영 기준: `8081`에서 검증 완료된 코드만 `8080`에 승격
중요:
- `8081` DB는 독립 정본이 아니다.
- `8081` DB는 `8080` DB를 기준으로 맞춘 검증용 복제본이어야 한다.
## 왜 이 규칙이 필요한가
- 조직현황, 조직도, 자리배치 인원, 퇴사자 제외, 멤버 수는 코드보다 DB 영향이 크다.
- 작업용 DB가 공개용과 달라지면 기능 검증 결과 자체가 왜곡된다.
- 원인 분석 시 `코드 차이``DB 차이`를 분리할 수 있어야 한다.
## 현재 확인된 차이 예시
2026-03-27 확인 기준:
- `members`
- `8080`: `227`
- `8081`: `236`
- `member_retirements`
- `8080`: `9`
- `8081`: `0`
- `seat_maps`
- `8080`: `21`
- `8081`: `3`
- `seat_positions`
- `8080`: `5`
- `8081`: `0`
- `seat_slots`
- `8080`: `57308`
- `8081`: `370`
## 기준 테이블 분류
### A. 공개용 정본 기준으로 항상 맞춰야 하는 테이블
- `members`
- `member_aliases`
- `member_overrides`
- `member_retirements`
- `seat_maps`
- `seat_slots`
- `seat_positions`
### B. 원본 재적재로 다시 만들 수 있는 통합 테이블
- `integration_import_batches`
- `integration_raw_organization_rows`
- `integration_raw_mh_rows`
- `integration_raw_mh_pm_rows`
- `integration_raw_payment_rows`
- `integration_projects`
- `integration_project_aliases`
- `integration_project_category_mappings`
- `integration_project_pm_assignments`
- `integration_work_logs`
- `integration_work_log_segments`
- `integration_vouchers`
### C. 별도 정책이 필요한 영역
- `snapshots`
- 인증 관련 스키마와 테이블
## 작업 프로토콜
### 1. 작업 시작 전
1. `8080``8081` 모두 기동 상태 확인
2. 이번 작업이 `코드 변경`인지 `데이터 변경`인지 먼저 구분
3. 공개용 기준 데이터가 필요한 화면이면 `8081` DB를 먼저 `8080` 기준으로 맞춤
### 2. 기능 개발 중
1. 코드 수정은 먼저 `8081`에서 수행
2. UI, 계산식, 자리배치도 동작은 `8081`에서 확인
3. 조직도/멤버/자리배치 검증은 공개용 기준 데이터가 반영된 `8081` DB에서만 수행
### 3. 검증 완료 후
1. 코드만 `8080`으로 승격
2. 데이터 반영이 필요한 기능은 별도 절차를 문서화한 뒤 적용
3. 공개용 DB를 개발 실험용으로 사용하지 않음
## 금지 사항
- `8081` DB를 장기간 독립 정본처럼 취급하지 않기
- 퇴사자, 멤버, 좌석 정보를 작업용에서 수작업으로만 유지하지 않기
- DB 차이를 무시하고 `8081` 검증 결과가 `8080`과 같다고 가정하지 않기
## 권장 동기화 범위
### 최소 범위
조직도/자리배치도 검증 전 반드시 동기화:
1. `members`
2. `member_aliases`
3. `member_overrides`
4. `member_retirements`
5. `seat_maps`
6. `seat_slots`
7. `seat_positions`
### 전체 범위
분석 화면까지 공개용 기준으로 검증해야 하면 아래도 포함:
1. `integration_import_batches`
2. `integration_raw_*`
3. `integration_projects`
4. `integration_project_*`
5. `integration_work_logs`
6. `integration_work_log_segments`
7. `integration_vouchers`
## 세션 시작 체크
1. 지금 작업이 `코드 변경`인지 `데이터 변경`인지 구분
2. 공개용 기준 데이터가 필요한지 판단
3. 필요하면 `8081` DB를 `8080` 기준으로 먼저 동기화
4. 그 뒤 기능 개발과 검증 수행
5. 검증 완료 후 공개용에 코드 승격
## 다음 액션
- `8081` DB를 `8080` 기준으로 맞추는 반복 가능한 동기화 절차를 만든다
- 최소한 `A 그룹` 테이블은 수동 기억에 의존하지 않고 다시 수행 가능해야 한다
- 이후 모든 작업은 이 문서를 기본 프로토콜로 따른다
## 실행 절차
반복 가능한 동기화 스크립트:
- [sync_prod_db_to_dev.sh](/home/hyunho/projects/mh-dashboard-organization/scripts/sync_prod_db_to_dev.sh)
사용 방법:
```bash
chmod +x scripts/sync_prod_db_to_dev.sh
./scripts/sync_prod_db_to_dev.sh minimal
./scripts/sync_prod_db_to_dev.sh full
```
규칙:
- `minimal`
- 조직도, 멤버, 자리배치도 검증 전 사용
- `full`
- 분석 화면까지 공개용 기준 데이터로 맞춰야 할 때 사용
주의:
- 스크립트는 `8080` DB 데이터를 덤프해서 `8081` DB의 대상 테이블을 비우고 다시 적재한다
- `8081`에서만 존재하던 대상 테이블 데이터는 사라진다
- 따라서 실행 전 현재 작업용 DB 상태를 유지해야 하면 별도 백업 후 실행한다

View File

@@ -1,5 +1,12 @@
# 인프라 검증 체크리스트 # 인프라 검증 체크리스트
## 현재 확인 상태
- 2026-03-27 기준 `docker compose ps` 에서 `proxy`, `frontend`, `backend`, `db` 모두 `healthy`
- 2026-03-27 기준 `curl http://localhost:8080/api/health` 정상
- 2026-03-27 기준 `curl http://localhost:8080/api/members` 에서 `items` 비어 있지 않음
- 다른 PC 접속도 현재 확인됨
- 개발/운영 DB 분리 운영 원칙은 [DEV_PROD_DB_PROTOCOL.md](/home/hyunho/projects/mh-dashboard-organization/docs/DEV_PROD_DB_PROTOCOL.md) 기준으로 관리
## 1. 컨테이너 기동 ## 1. 컨테이너 기동
- `docker compose build` - `docker compose build`
- `docker compose up -d` - `docker compose up -d`
@@ -32,4 +39,8 @@
- 확인 기준: - 확인 기준:
- DB 데이터 유지 - DB 데이터 유지
- 업로드 파일 유지 - 업로드 파일 유지
- 스냅샷 파일 유지
## 6. 제외 또는 후속 검증 항목
- 월간 스냅샷 파일 유지 검증은 현재 코드 기준 미구현 항목
- 스냅샷 기능을 다시 범위에 넣을 경우 별도 API/파일 경로/다운로드 검증 절차를 추가해야 함
- `8081`에서 조직도, 멤버, 자리배치도 검증 전에는 `8080` 정본 DB 기준 동기화가 필요함

View File

@@ -3,8 +3,9 @@
## Current Base ## Current Base
- branch: `total` - branch: `total`
- latest integration commit: `61b5638` - latest checked commit: `1d15cf9`
- main history doc: [DEVELOPMENT_HISTORY.md](/home/hyunho/projects/mh-dashboard-organization/docs/DEVELOPMENT_HISTORY.md) - main history doc: [DEVELOPMENT_HISTORY.md](/home/hyunho/projects/mh-dashboard-organization/docs/DEVELOPMENT_HISTORY.md)
- dev/prod protocol: [DEV_PROD_DB_PROTOCOL.md](/home/hyunho/projects/mh-dashboard-organization/docs/DEV_PROD_DB_PROTOCOL.md)
## What Was Finished ## What Was Finished
@@ -50,15 +51,36 @@
- `member_retirements` - `member_retirements`
- `member_overrides` - `member_overrides`
### Auth Baseline
- 실제 로그인 API 연결 완료
- 프런트 로그인 화면이 `/api/auth/login` 사용
- 세션/로그아웃/세션 조회 API 구성 완료
- 사용 테이블:
- `auth.users`
- `auth.sessions`
- `auth.login_audit_logs`
- 현재 남은 범위:
- mock login 정리
- 역할별 권한 체크 적용
- 쓰기 API 보호 범위 정리
### External Access ### External Access
- WSL 내부 8080 리슨 확인 - WSL 내부 8080 리슨 확인
- Windows `portproxy`를 이용해 다른 PC에서 접속 가능하게 설정 - 현재 다른 PC에서 접속 확인
- 현재 기준 주소: - 현재 기준 주소:
- `http://172.16.40.144:8080` - `http://172.16.40.144:8080`
## Important Runtime Notes ## Important Runtime Notes
### Dev / Prod Protocol
- 코드 선행은 `8081`, 공개 반영은 `8080`
- 데이터 정본은 `8080` DB
- `8081` DB는 독립 정본이 아니라 `8080` 기준 복제본처럼 관리해야 함
- 조직도, 멤버, 자리배치 검증 전에는 `DEV_PROD_DB_PROTOCOL.md`를 먼저 확인
### Seat Map Save ### Seat Map Save
- 저장이 안 되면 먼저 backend 로그에서 `PUT /api/seat-maps/{id}/layout` 상태코드 확인 - 저장이 안 되면 먼저 backend 로그에서 `PUT /api/seat-maps/{id}/layout` 상태코드 확인
@@ -79,14 +101,18 @@
## Open Issues ## Open Issues
- `#2` 백엔드 영속 저장 구조 운영 마무리 및 스냅샷 검증 - `#2` 백엔드 영속 저장 구조 운영 마무리
- `#3` 사무실 좌석 배치도 조회 및 관리자 편집 기능 고도화 - `#3` 사무실 좌석 배치도 조회 및 관리자 편집 기능 고도화
- `#5` 실제 인증 체계 전환 - `#5` 실제 인증 체계 전환
- `#6` 4개 기능 통합 대시보드 프레임 및 공통 헤더 구축
- `#7` 자리배치도 팀별 색상 오버레이 표시 - `#7` 자리배치도 팀별 색상 오버레이 표시
- `#8` 자리배치도 좌석 클릭 시 개인 상위 조직 트리 표시 - `#8` 자리배치도 좌석 클릭 시 개인 상위 조직 트리 표시
- `#9` 조직도·자리배치도 변경 이력 버전 누적 저장 - `#9` 조직도·자리배치도 변경 이력 버전 누적 저장
현재 해석:
- `#6`은 코드 기준 사실상 완료 상태이며 Gitea 정리 대상
- `#5`는 "로그인 구현"보다 "권한 제어 마무리"가 핵심
- `#2`의 기존 "스냅샷 검증" 범위는 현재 코드와 불일치하므로 범위 재정의 필요
## Unfinished Ideas Discussed Today ## Unfinished Ideas Discussed Today
### Seat Map UX ### Seat Map UX
@@ -101,10 +127,15 @@
### History / Versioning ### History / Versioning
- 조직도와 자리배치도 수정 이력을 버전 누적형으로 저장 - 조직도와 자리배치도 수정 이력을 버전 누적형으로 저장
- 원본 DB와 별도의 history/snapshot 구조 설계 - 원본 DB와 별도의 history/version 구조 설계
- 날짜/버전 형식 예: - `valid_from`, `valid_to` 기반 시점 조회(as-of date) 구조 적용
- `00.00.00` - 날짜 또는 revision label 기준으로 버전 묶음 관리
- 또는 날짜 기반 revision - 상세 설계 문서:
- [HISTORY_ASOF_DB_PLAN.md](/home/hyunho/projects/mh-dashboard-organization/docs/HISTORY_ASOF_DB_PLAN.md)
주의:
- 현재 코드에는 조직도/자리배치도 버전 이력 기능이 아직 없음
- 월간 스냅샷 방향은 범위에서 제외
### Project Analysis Accuracy ### Project Analysis Accuracy
@@ -113,25 +144,28 @@
### Auth / Permission ### Auth / Permission
- mock login을 실제 인증 체계로 전환 - mock login을 개발용 fallback 수준으로 제한하거나 제거
- 역할별 접근 제어 정리 - 역할별 접근 제어 정리
- 조직도/자리배치도/분석 화면 권한 경계 재정리 - 조직도/자리배치도/분석 화면 권한 경계 재정리
## Recommended Next Work Order ## Recommended Next Work Order
1. 자리배치도 저장/표시를 브라우저에서 한 번 더 실사용 검증 1. `#2` 범위를 현재 코드 기준으로 재정의하고 영속성 운영 검증 완료
2. `#7`, `#8`, `#9` 중 우선순위 확정 2. `#5`에서 권한 체크, mock login 정리, 쓰기 API 보호 적용
3. 프로젝트별 분석 남은 오차 정밀 보정 3. `8081` DB를 `8080` 정본 기준으로 동기화하는 반복 가능한 절차 마련
4. 실제 인증 체계 설계/구현 4. `#9`를 as-of date 기반 history 구조로 설계 후 `members`, `seat_positions` 부터 이력화
5. 그 다음 `#8`, 나머지 도면 추가, `#7`, 프로젝트 분석 오차 보정 순으로 진행
## Quick Resume Prompt ## Quick Resume Prompt
다음 세션 시작 시 아래 기준으로 이어가면 된다. 다음 세션 시작 시 아래 기준으로 이어가면 된다.
- 브랜치 `total`에서 시작 - 브랜치 `total`에서 시작
- 최근 커밋 `61b5638` 확인 - 최근 커밋 `1d15cf9` 확인
- `docs/DEVELOPMENT_HISTORY.md` - `docs/DEVELOPMENT_HISTORY.md`
- `docs/NEXT_SESSION_CHECKPOINT.md` - `docs/NEXT_SESSION_CHECKPOINT.md`
- Gitea 이슈 `#7`, `#8`, `#9` - `docs/DEV_PROD_DB_PROTOCOL.md`
- `docs/HISTORY_ASOF_DB_PLAN.md`
- Gitea 이슈 `#2`, `#5`, `#9`
그리고 먼저 현재 외부 접속 자리배치 저장이 정상인지 확인한 뒤 다음 기능 개발로 넘어간다. 그리고 먼저 현재 외부 접속, 자리배치 저장, 실제 로그인 동작을 확인한 뒤 다음 기능 개발로 넘어간다.

View File

@@ -101,8 +101,8 @@ const APP_BASE_URL = String(window.__MH_BASE_URL || "").replace(/\/$/, "");
const seatMapOffices = [ const seatMapOffices = [
{ key: "technical-development-center", label: "기술개발센터", ready: true }, { key: "technical-development-center", label: "기술개발센터", ready: true },
{ key: "hanmac-building-7f", label: "한맥빌딩 7층", ready: false }, { key: "hanmac-building-6f", label: "한맥빌딩 6층", ready: true },
{ key: "hanmac-building-6f", label: "한맥빌딩 6층", ready: false }, { key: "hanmac-building-7f", label: "한맥빌딩 7층", ready: true },
]; ];
const viewLabels = { const viewLabels = {
@@ -148,7 +148,7 @@ const seatMapState = {
forceReadOnly: false, forceReadOnly: false,
}; };
let currentView = "organization"; let currentView = "project";
const globalDateState = { const globalDateState = {
loaded: true, loaded: true,
startDate: "2026-01-01", startDate: "2026-01-01",
@@ -1168,22 +1168,7 @@ async function loadSeatMapData(force = false) {
try { try {
const office = getCurrentSeatMapOffice(); const office = getCurrentSeatMapOffice();
if (!office.ready) { const activePayload = await fetchJson(`/api/seat-maps/active?office_key=${encodeURIComponent(office.key)}`);
const membersPayload = await fetchJson("/api/members");
seatMapState.seatMap = null;
seatMapState.members = Array.isArray(membersPayload.items) ? membersPayload.items : [];
seatMapState.slots = [];
seatMapState.placements = [];
seatMapState.zoom = 1;
seatMapState.hoveredSlotId = null;
seatMapState.editMode = canEditSeatMap();
resetSeatMapDraft();
seatMapState.loaded = true;
setSeatMapStatus(`${office.label} 도면은 아직 등록 전입니다.`, "info");
renderSeatMap();
return;
}
const activePayload = await fetchJson("/api/seat-maps/active");
const activeSeatMap = activePayload.item; const activeSeatMap = activePayload.item;
const layoutPayload = await fetchJson(`/api/seat-maps/${activeSeatMap.id}/layout`); const layoutPayload = await fetchJson(`/api/seat-maps/${activeSeatMap.id}/layout`);
seatMapState.seatMap = { seatMapState.seatMap = {
@@ -1479,12 +1464,10 @@ if (loginForm) {
body: formData, body: formData,
}); });
setSession(payload); setSession(payload);
setActiveView("project");
loginForm.reset(); loginForm.reset();
loginMessage.textContent = ""; loginMessage.textContent = "";
renderAuth(); renderAuth();
if (currentView === "seatmap-admin" || currentView === "seatmap-readonly") {
await loadSeatMapData(true);
}
} catch (error) { } catch (error) {
loginMessage.textContent = error.message || "로그인에 실패했습니다."; loginMessage.textContent = error.message || "로그인에 실패했습니다.";
} }

View File

@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MH 조직현황 대시보드</title> <title>MH 대시보드-공개용</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Pretendard:wght@400;600;700;900&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Pretendard:wght@400;600;700;900&display=swap" rel="stylesheet">

View File

@@ -1316,7 +1316,8 @@
</div> </div>
<div id="mh-topbar-actions" class="flex flex-wrap items-center justify-end gap-3"> <div id="mh-topbar-actions" class="flex flex-wrap items-center justify-end gap-3">
<div id="mh-scope-toggle" aria-label="조직 구분 선택"> <div id="mh-scope-toggle" aria-label="조직 구분 선택">
<button type="button" class="mh-scope-btn active" data-scope="gpd">GPD</button> <button type="button" class="mh-scope-btn active" data-scope="all">전체</button>
<button type="button" class="mh-scope-btn" data-scope="gpd">GPD</button>
<button type="button" class="mh-scope-btn" data-scope="tdc">TDC</button> <button type="button" class="mh-scope-btn" data-scope="tdc">TDC</button>
</div> </div>
<div id="mh-inline-filters"> <div id="mh-inline-filters">
@@ -1483,13 +1484,16 @@
let teamData = []; let teamData = [];
let allTeams = []; let allTeams = [];
let allPeopleData = []; let allPeopleData = [];
let currentScope = 'gpd'; let searchTeams = [];
let searchPeopleData = [];
let currentScope = 'all';
let matrixBizOpenState = {}; let matrixBizOpenState = {};
let matrixProjectFilter = 'all'; let matrixProjectFilter = 'all';
let currentMatrixData = {}; let currentMatrixData = {};
let personChartOpenState = {}; let personChartOpenState = {};
let personCalendarState = { name: '', team: '', year: 2026, month: 0 }; let personCalendarState = { name: '', team: '', year: 2026, month: 0 };
let lastUploadedBinary = ''; let lastUploadedBinary = '';
let lastPmSheetData = [];
let projectPmMap = new Map(); let projectPmMap = new Map();
let memberTeamMap = new Map(); let memberTeamMap = new Map();
const GPD_TEAM_DIVISIONS = new Set(['총괄', '영업']); const GPD_TEAM_DIVISIONS = new Set(['총괄', '영업']);
@@ -1622,6 +1626,14 @@
return true; return true;
}; };
const getScopedRows = (scope = currentScope) => teamData.slice(1).filter(row => isRowInScope(row, scope)); const getScopedRows = (scope = currentScope) => teamData.slice(1).filter(row => isRowInScope(row, scope));
const getTeamScope = (teamName) => {
const normalizedTeam = String(teamName || '').trim();
if (!normalizedTeam) return 'all';
const matchedRow = teamData.slice(1).find(row => String(row?.[columnMap.team] || '').trim() === normalizedTeam);
const division = getRowTeamDivision(matchedRow);
if (!division) return 'all';
return GPD_TEAM_DIVISIONS.has(division) ? 'gpd' : 'tdc';
};
const buildScopedPeopleData = (rows) => { const buildScopedPeopleData = (rows) => {
const seen = new Set(); const seen = new Set();
return rows.map(r => ({ return rows.map(r => ({
@@ -2239,6 +2251,8 @@
if (!payload) return; if (!payload) return;
if (payload.binary) { if (payload.binary) {
loadWorkbookBinary(payload.binary); loadWorkbookBinary(payload.binary);
} else if (Array.isArray(payload.teamData)) {
applyMhSourceRows(payload.teamData, Array.isArray(payload.pmSheet) ? payload.pmSheet : []);
} }
if (payload.scope) { if (payload.scope) {
currentScope = payload.scope === 'tdc' ? 'tdc' : 'gpd'; currentScope = payload.scope === 'tdc' ? 'tdc' : 'gpd';
@@ -2261,7 +2275,7 @@
function openTeamAnalysisPopup(team) { function openTeamAnalysisPopup(team) {
if (!teamData.length || !lastUploadedBinary) return; if (!teamData.length) return;
closeProjectModal(); closeProjectModal();
@@ -2280,12 +2294,17 @@
const payload = { const payload = {
source: 'team-popup-init', source: 'team-popup-init',
binary: lastUploadedBinary,
team, team,
scope: popupScope, scope: popupScope,
startDate: document.getElementById('start-date').value || '', startDate: document.getElementById('start-date').value || '',
endDate: document.getElementById('end-date').value || '' endDate: document.getElementById('end-date').value || ''
}; };
if (lastUploadedBinary) {
payload.binary = lastUploadedBinary;
} else {
payload.teamData = teamData;
payload.pmSheet = lastPmSheetData;
}
const sendPayload = () => { const sendPayload = () => {
if (!popupWindow || popupWindow.closed) return; if (!popupWindow || popupWindow.closed) return;
try { try {
@@ -2329,16 +2348,13 @@
// --- Handlers --- // --- Handlers ---
function loadWorkbookBinary(binaryStr) { function applyMhSourceRows(nextTeamData, nextPmSheet = []) {
if (!binaryStr) return; teamData = Array.isArray(nextTeamData) ? nextTeamData : [];
lastUploadedBinary = binaryStr; lastPmSheetData = Array.isArray(nextPmSheet) ? nextPmSheet : [];
const workbook = XLSX.read(binaryStr, {type: 'binary', cellDates: true, dateNF: 'yyyy-mm-dd'});
teamData = XLSX.utils.sheet_to_json(workbook.Sheets[workbook.SheetNames[0]], {header: 1, defval: ""});
const pmSheet = workbook.SheetNames[1] ? XLSX.utils.sheet_to_json(workbook.Sheets[workbook.SheetNames[1]], {header: 1, defval: ""}) : [];
buildColumnMap(); buildColumnMap();
rebuildMemberTeamMap(); rebuildMemberTeamMap();
projectPmMap = new Map(); projectPmMap = new Map();
pmSheet.forEach(row => { lastPmSheetData.forEach(row => {
const projectCode = String(row?.[0] || '').trim(); const projectCode = String(row?.[0] || '').trim();
const pmName = String(row?.[1] || '').trim(); const pmName = String(row?.[1] || '').trim();
if (projectCode && pmName) projectPmMap.set(projectCode, pmName); if (projectCode && pmName) projectPmMap.set(projectCode, pmName);
@@ -2346,6 +2362,15 @@
initTeamTabAfterUpload(); initTeamTabAfterUpload();
} }
function loadWorkbookBinary(binaryStr) {
if (!binaryStr) return;
lastUploadedBinary = binaryStr;
const workbook = XLSX.read(binaryStr, {type: 'binary', cellDates: true, dateNF: 'yyyy-mm-dd'});
const nextTeamData = XLSX.utils.sheet_to_json(workbook.Sheets[workbook.SheetNames[0]], {header: 1, defval: ""});
const nextPmSheet = workbook.SheetNames[1] ? XLSX.utils.sheet_to_json(workbook.Sheets[workbook.SheetNames[1]], {header: 1, defval: ""}) : [];
applyMhSourceRows(nextTeamData, nextPmSheet);
}
const fileInput = document.getElementById('file-input'); const fileInput = document.getElementById('file-input');
const uploadMhButton = document.getElementById('btn-upload-mh'); const uploadMhButton = document.getElementById('btn-upload-mh');
@@ -2408,16 +2433,10 @@
const response = await fetch('/api/integration/mh-source', { credentials: 'same-origin' }); const response = await fetch('/api/integration/mh-source', { credentials: 'same-origin' });
if (!response.ok) throw new Error(`HTTP ${response.status}`); if (!response.ok) throw new Error(`HTTP ${response.status}`);
const payload = await response.json(); const payload = await response.json();
teamData = Array.isArray(payload.teamData) ? payload.teamData : []; applyMhSourceRows(
buildColumnMap(); Array.isArray(payload.teamData) ? payload.teamData : [],
rebuildMemberTeamMap(); Array.isArray(payload.pmSheet) ? payload.pmSheet : []
projectPmMap = new Map(); );
(Array.isArray(payload.pmSheet) ? payload.pmSheet : []).forEach(row => {
const projectCode = String(row?.[0] || '').trim();
const pmName = String(row?.[1] || '').trim();
if (projectCode && pmName) projectPmMap.set(projectCode, pmName);
});
initTeamTabAfterUpload();
} catch (error) { } catch (error) {
console.error(error); console.error(error);
} }
@@ -2439,9 +2458,12 @@
const currentTeam = preserveSelection ? teamSelect.value : ''; const currentTeam = preserveSelection ? teamSelect.value : '';
const currentPerson = preserveSelection ? personSelect.value : ''; const currentPerson = preserveSelection ? personSelect.value : '';
const scopedRows = getScopedRows(); const scopedRows = getScopedRows();
const allRows = getScopedRows('all');
allTeams = [...new Set(scopedRows.map(r => String(r[columnMap.team] || '').trim()))].filter(Boolean).sort(); allTeams = [...new Set(scopedRows.map(r => String(r[columnMap.team] || '').trim()))].filter(Boolean).sort();
allPeopleData = buildScopedPeopleData(scopedRows); allPeopleData = buildScopedPeopleData(scopedRows);
searchTeams = [...new Set(allRows.map(r => String(r[columnMap.team] || '').trim()))].filter(Boolean).sort();
searchPeopleData = buildScopedPeopleData(allRows);
teamSelect.innerHTML = '<option value="">팀 선택</option>' + allTeams.map(t => `<option value="${t}">${t}</option>`).join(''); teamSelect.innerHTML = '<option value="">팀 선택</option>' + allTeams.map(t => `<option value="${t}">${t}</option>`).join('');
@@ -2463,7 +2485,7 @@
} }
function setScope(scope, preserveSelection = true) { function setScope(scope, preserveSelection = true) {
currentScope = scope === 'tdc' ? 'tdc' : 'gpd'; currentScope = scope === 'gpd' || scope === 'tdc' ? scope : 'all';
syncScopeButtons(); syncScopeButtons();
refreshScopedSelections(preserveSelection); refreshScopedSelections(preserveSelection);
render(); render();
@@ -2520,9 +2542,11 @@
} }
const matchedTeams = allTeams.filter(t => t.toLowerCase().includes(val)); const matchedTeams = searchTeams.filter(t => t.toLowerCase().includes(val));
const matchedPeople = allPeopleData.filter(p => p.name.toLowerCase().includes(val)); const matchedPeople = searchPeopleData.filter(p =>
p.name.toLowerCase().includes(val) || p.team.toLowerCase().includes(val)
);
@@ -2589,21 +2613,15 @@
if (type === 'team') { if (type === 'team') {
setScope(getTeamScope(item.dataset.value), false);
teamSelect.value = item.dataset.value; teamSelect.value = item.dataset.value;
updateFilters(); updateFilters();
personSelect.value = ""; personSelect.value = "";
} else { } else {
setScope(getTeamScope(item.dataset.team), false);
teamSelect.value = item.dataset.team; teamSelect.value = item.dataset.team;
updateFilters(); updateFilters();
personSelect.value = item.dataset.name; personSelect.value = item.dataset.name;
} }
mainSearchInput.value = ""; mainSearchInput.value = "";

View File

@@ -421,6 +421,7 @@ const App = () => {
if (parts.length < 3) return clean; if (parts.length < 3) return clean;
return `${parts[0]}-${parts[1].padStart(2, '0')}-${parts[2].padStart(2, '0')}`; return `${parts[0]}-${parts[1].padStart(2, '0')}-${parts[2].padStart(2, '0')}`;
}; };
const getExpenseDate = (row) => norm(getVal(row, ['발행일', '청구일', '발행 일자', '청구 일자'], 2));
const processedData = useMemo(() => { const processedData = useMemo(() => {
if (!dataLoaded.expense || !dataLoaded.work) return null; if (!dataLoaded.expense || !dataLoaded.work) return null;
@@ -484,7 +485,8 @@ const App = () => {
const workerName = norm(getVal(row, ['이름'])); const workerName = norm(getVal(row, ['이름']));
const position = norm(getVal(row, ['직책', '직급'])); const position = norm(getVal(row, ['직책', '직급']));
const userState = norm(getVal(row, ['user_state', 'User State', 'user state', 'userstate', 'User_State'])); const userState = norm(getVal(row, ['user_state', 'User State', 'user state', 'userstate', 'User_State']));
const isWeekend = userState.includes('주말'); const weekendFlag = norm(getVal(row, ['주말/지각']));
const isWeekend = userState.includes('주말') || weekendFlag.includes('주말');
const isMhSchema = hasNamedHeader(row, '메인업무 프로젝트명') || hasNamedHeader(row, '연장근무 프로젝트명') || hasNamedHeader(row, '연장근무 시간(가공)'); const isMhSchema = hasNamedHeader(row, '메인업무 프로젝트명') || hasNamedHeader(row, '연장근무 프로젝트명') || hasNamedHeader(row, '연장근무 시간(가공)');
const importedLabor = pnum(getVal(row, ['산정금액', '인건비'])); const importedLabor = pnum(getVal(row, ['산정금액', '인건비']));
const overtimeHoursFromRow = isMhSchema const overtimeHoursFromRow = isMhSchema
@@ -623,7 +625,7 @@ const App = () => {
return raw.length >= 7 ? raw.slice(0, 7) : raw; return raw.length >= 7 ? raw.slice(0, 7) : raw;
}; };
const excludedWorkers = new Set(['정태원', '양병홍', '장계석', '장종찬', '김원식', '김형준']); const excludedWorkers = new Set(['정태원', '양병홍', '장종찬', '김형준']);
const selectedProjectKey = normalizeProjectKey(selectedProject === '전체' ? '' : selectedProject); const selectedProjectKey = normalizeProjectKey(selectedProject === '전체' ? '' : selectedProject);
const projectSearchKey = normalizeProjectKey(projectSearch); const projectSearchKey = normalizeProjectKey(projectSearch);
@@ -648,7 +650,7 @@ const App = () => {
const d1FromRow = normalizeD1Category(getVal(item, ['D1', '매출/비매출', '매출비매출'], 14)); const d1FromRow = normalizeD1Category(getVal(item, ['D1', '매출/비매출', '매출비매출'], 14));
if (!d1FromRow) return false; // D1(K) empty rows are ignored. if (!d1FromRow) return false; // D1(K) empty rows are ignored.
const info = projectToDepth[pKey] || unknownDepth; const info = projectToDepth[pKey] || unknownDepth;
const issueDate = getVal(item, ['발행일'], 2); const issueDate = getExpenseDate(item);
return (selectedRev === '전체' || info.d1 === selectedRev) && return (selectedRev === '전체' || info.d1 === selectedRev) &&
(selectedD1 === '전체' || info.d2 === selectedD1) && (selectedD1 === '전체' || info.d2 === selectedD1) &&
(selectedD2 === '전체' || info.d3 === selectedD2) && (selectedD2 === '전체' || info.d3 === selectedD2) &&
@@ -667,7 +669,7 @@ const App = () => {
const baseAllExp = expenseRaw.filter(item => { const baseAllExp = expenseRaw.filter(item => {
const d1FromRow = normalizeD1Category(getVal(item, ['D1', '매출/비매출', '매출비매출'], 14)); const d1FromRow = normalizeD1Category(getVal(item, ['D1', '매출/비매출', '매출비매출'], 14));
if (!d1FromRow) return false; if (!d1FromRow) return false;
const issueDate = getVal(item, ['발행일'], 2); const issueDate = getExpenseDate(item);
return isWithinRange(issueDate); return isWithinRange(issueDate);
}); });
@@ -996,7 +998,7 @@ const App = () => {
else if (div === '외주비' || div === '외주') category = '외주비'; else if (div === '외주비' || div === '외주') category = '외주비';
else if (div === '제외') return; else if (div === '제외') return;
const issueDate = norm(getVal(e, ['발행일', '발행 일자', '일자'], 2)); const issueDate = getExpenseDate(e);
expenseDetailByCategory[category].push({ expenseDetailByCategory[category].push({
issueMonth: toIssueMonth(issueDate), issueMonth: toIssueMonth(issueDate),
issueDate, issueDate,

View File

@@ -450,6 +450,7 @@ const App = () => {
if (parts.length < 3) return clean; if (parts.length < 3) return clean;
return `${parts[0]}-${parts[1].padStart(2, '0')}-${parts[2].padStart(2, '0')}`; return `${parts[0]}-${parts[1].padStart(2, '0')}-${parts[2].padStart(2, '0')}`;
}; };
const getExpenseDate = (row) => norm(getVal(row, ['발행일', '청구일', '발행 일자', '청구 일자'], 2));
const processedData = useMemo(() => { const processedData = useMemo(() => {
if (!dataLoaded.expense || !dataLoaded.work) return null; if (!dataLoaded.expense || !dataLoaded.work) return null;
@@ -513,7 +514,8 @@ const App = () => {
const workerName = norm(getVal(row, ['이름'])); const workerName = norm(getVal(row, ['이름']));
const position = norm(getVal(row, ['직책', '직급'])); const position = norm(getVal(row, ['직책', '직급']));
const userState = norm(getVal(row, ['user_state', 'User State', 'user state', 'userstate', 'User_State'])); const userState = norm(getVal(row, ['user_state', 'User State', 'user state', 'userstate', 'User_State']));
const isWeekend = userState.includes('주말'); const weekendFlag = norm(getVal(row, ['주말/지각']));
const isWeekend = userState.includes('주말') || weekendFlag.includes('주말');
const isMhSchema = hasNamedHeader(row, '메인업무 프로젝트명') || hasNamedHeader(row, '연장근무 프로젝트명') || hasNamedHeader(row, '연장근무 시간(가공)'); const isMhSchema = hasNamedHeader(row, '메인업무 프로젝트명') || hasNamedHeader(row, '연장근무 프로젝트명') || hasNamedHeader(row, '연장근무 시간(가공)');
const importedLabor = pnum(getVal(row, ['산정금액', '인건비'])); const importedLabor = pnum(getVal(row, ['산정금액', '인건비']));
const overtimeHoursFromRow = isMhSchema const overtimeHoursFromRow = isMhSchema
@@ -652,7 +654,7 @@ const App = () => {
return raw.length >= 7 ? raw.slice(0, 7) : raw; return raw.length >= 7 ? raw.slice(0, 7) : raw;
}; };
const excludedWorkers = new Set(['정태원', '양병홍', '장계석', '장종찬', '김원식', '김형준']); const excludedWorkers = new Set(['정태원', '양병홍', '장종찬', '김형준']);
const selectedProjectKey = normalizeProjectKey(selectedProject === '전체' ? '' : selectedProject); const selectedProjectKey = normalizeProjectKey(selectedProject === '전체' ? '' : selectedProject);
const projectSearchKey = normalizeProjectKey(projectSearch); const projectSearchKey = normalizeProjectKey(projectSearch);
@@ -677,7 +679,7 @@ const App = () => {
const d1FromRow = normalizeD1Category(getVal(item, ['D1', '매출/비매출', '매출비매출'], 14)); const d1FromRow = normalizeD1Category(getVal(item, ['D1', '매출/비매출', '매출비매출'], 14));
if (!d1FromRow) return false; // D1(K) empty rows are ignored. if (!d1FromRow) return false; // D1(K) empty rows are ignored.
const info = projectToDepth[pKey] || unknownDepth; const info = projectToDepth[pKey] || unknownDepth;
const issueDate = getVal(item, ['발행일'], 2); const issueDate = getExpenseDate(item);
return (selectedRev === '전체' || info.d1 === selectedRev) && return (selectedRev === '전체' || info.d1 === selectedRev) &&
(selectedD1 === '전체' || info.d2 === selectedD1) && (selectedD1 === '전체' || info.d2 === selectedD1) &&
(selectedD2 === '전체' || info.d3 === selectedD2) && (selectedD2 === '전체' || info.d3 === selectedD2) &&
@@ -696,7 +698,7 @@ const App = () => {
const baseAllExp = expenseRaw.filter(item => { const baseAllExp = expenseRaw.filter(item => {
const d1FromRow = normalizeD1Category(getVal(item, ['D1', '매출/비매출', '매출비매출'], 14)); const d1FromRow = normalizeD1Category(getVal(item, ['D1', '매출/비매출', '매출비매출'], 14));
if (!d1FromRow) return false; if (!d1FromRow) return false;
const issueDate = getVal(item, ['발행일'], 2); const issueDate = getExpenseDate(item);
return isWithinRange(issueDate); return isWithinRange(issueDate);
}); });
@@ -1025,7 +1027,7 @@ const App = () => {
else if (div === '외주비' || div === '외주') category = '외주비'; else if (div === '외주비' || div === '외주') category = '외주비';
else if (div === '제외') return; else if (div === '제외') return;
const issueDate = norm(getVal(e, ['발행일', '발행 일자', '일자'], 2)); const issueDate = getExpenseDate(e);
expenseDetailByCategory[category].push({ expenseDetailByCategory[category].push({
issueMonth: toIssueMonth(issueDate), issueMonth: toIssueMonth(issueDate),
issueDate, issueDate,

View File

@@ -0,0 +1,931 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>center chair people map</title>
<style>
:root {
--ink: #152330;
--muted: #627286;
--paper: rgba(255,255,255,0.86);
--line: rgba(21,35,48,0.1);
--accent: #0f766e;
--bg: #edf2f6;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "IBM Plex Sans KR", "Pretendard", sans-serif;
color: var(--ink);
background:
radial-gradient(circle at top left, rgba(15,118,110,0.11), transparent 22%),
linear-gradient(180deg, #f5f8fb 0%, #e8eef3 100%);
}
.page {
min-height: 100vh;
padding: 0;
}
.shell {
min-height: 100vh;
}
.panel {
border-radius: 0;
border: none;
background: transparent;
backdrop-filter: none;
box-shadow: none;
}
.actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
button {
border: none;
border-radius: 999px;
padding: 10px 14px;
font: inherit;
font-weight: 700;
cursor: pointer;
color: white;
background: linear-gradient(135deg, #0f766e, #115e59);
box-shadow: 0 10px 22px rgba(15,118,110,0.18);
}
button.alt {
color: var(--ink);
background: rgba(255,255,255,0.9);
border: 1px solid var(--line);
box-shadow: none;
}
.viewer {
position: relative;
overflow: hidden;
min-height: 100vh;
}
.viewer-head {
position: absolute;
top: 16px;
left: 16px;
right: 16px;
z-index: 2;
display: flex;
justify-content: space-between;
gap: 12px;
pointer-events: none;
}
.chip {
padding: 10px 12px;
border-radius: 16px;
background: rgba(255,255,255,0.82);
border: 1px solid rgba(255,255,255,0.94);
color: var(--muted);
font-size: 13px;
font-weight: 700;
box-shadow: 0 8px 24px rgba(21,35,48,0.08);
}
.viewer-actions {
position: absolute;
left: 16px;
top: 64px;
z-index: 2;
display: flex;
gap: 8px;
}
.mapper {
position: absolute;
top: 76px;
left: 50%;
transform: translateX(-50%);
width: min(94vw, 1320px);
max-height: min(56vh, 560px);
overflow: hidden;
z-index: 4;
border-radius: 20px;
background: rgba(234, 239, 247, 0.95);
border: 1px solid rgba(101, 119, 146, 0.22);
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.2);
display: flex;
flex-direction: column;
backdrop-filter: blur(6px);
}
.hidden-off {
display: none !important;
}
.mapper-head {
padding: 10px 14px;
border-bottom: 1px solid rgba(101,119,146,0.18);
font-size: 12px;
color: #51607a;
font-weight: 700;
line-height: 1.35;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
background: rgba(255,255,255,0.6);
}
.mapper-head strong {
display: block;
color: #17243b;
font-size: 20px;
margin-bottom: 2px;
}
.mapper-head .alt {
padding: 8px 10px;
font-size: 12px;
white-space: nowrap;
}
.org-chart {
margin: 0;
padding: 14px;
overflow: auto;
display: grid;
gap: 12px;
}
.org-top {
margin: 0 auto;
width: min(100%, 420px);
border-radius: 14px;
overflow: hidden;
border: 1px solid rgba(67, 84, 118, 0.25);
background: #fff;
}
.org-top-title {
background: #1e2f4d;
color: #fff;
text-align: center;
font-size: 34px;
font-weight: 800;
line-height: 1.1;
padding: 16px 12px;
letter-spacing: -0.03em;
}
.org-top-members {
padding: 10px;
display: grid;
gap: 6px;
background: rgba(255,255,255,0.95);
}
.org-teams {
display: grid;
grid-template-columns: repeat(7, minmax(160px, 1fr));
gap: 10px;
align-items: start;
}
.org-team {
border: 1px solid rgba(110, 126, 152, 0.25);
border-radius: 10px;
overflow: hidden;
background: rgba(255,255,255,0.95);
min-width: 0;
}
.org-team h4 {
margin: 0;
padding: 9px 10px;
font-size: 14px;
color: #21324e;
font-weight: 800;
border-bottom: 1px solid rgba(110, 126, 152, 0.2);
background: rgba(240, 245, 252, 0.96);
}
.org-members {
padding: 7px;
display: grid;
gap: 6px;
}
.org-person {
border: 1px solid rgba(116, 133, 161, 0.25);
background: rgba(255,255,255,0.95);
border-radius: 8px;
padding: 6px 8px;
cursor: pointer;
transition: background 120ms ease, border-color 120ms ease;
min-width: 0;
}
.org-person.active {
border-color: rgba(15,118,110,0.6);
background: rgba(15,118,110,0.11);
}
.org-person.assigned {
border-color: rgba(37,99,235,0.5);
background: rgba(37,99,235,0.1);
}
.org-person strong {
display: block;
font-size: 13px;
line-height: 1.3;
color: #15233a;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.org-person small {
display: block;
color: #5a6a86;
font-size: 11px;
line-height: 1.25;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 980px) {
.mapper {
top: 72px;
width: min(96vw, 920px);
max-height: 58vh;
}
.viewer-actions {
top: 64px;
left: 12px;
right: 12px;
flex-wrap: wrap;
}
.mapper-head strong {
font-size: 16px;
}
.org-top-title {
font-size: 24px;
}
.org-teams {
grid-template-columns: repeat(3, minmax(150px, 1fr));
}
}
canvas {
width: 100%;
height: 100%;
display: block;
cursor: grab;
}
canvas.dragging { cursor: grabbing; }
.tooltip {
position: absolute;
min-width: 170px;
padding: 12px 14px;
border-radius: 16px;
background: rgba(17,24,39,0.94);
color: white;
pointer-events: none;
opacity: 0;
transform: translate(12px, 12px);
transition: opacity 120ms ease;
z-index: 3;
}
.tooltip.visible { opacity: 1; }
.tooltip strong { display: block; margin-bottom: 6px; font-size: 14px; }
.tooltip div { font-size: 12px; line-height: 1.45; color: rgba(255,255,255,0.82); }
</style>
</head>
<body>
<div class="page">
<div class="shell">
<main class="panel viewer">
<div class="viewer-head">
<div class="chip" id="scale-chip"></div>
<div class="chip" id="hover-chip">chair hover: none</div>
</div>
<div class="viewer-actions">
<button type="button" id="fit-btn">전체 맞춤</button>
<button type="button" class="alt" id="clear-btn">선택 지우기</button>
</div>
<aside class="mapper hidden-off">
<div class="mapper-head">
<div id="mapper-status">
<strong>조직 현황</strong>
<span>선택 인원 없음</span>
</div>
<button type="button" class="alt" id="clear-assign-btn">매칭 초기화</button>
</div>
<div class="org-chart" id="org-chart"></div>
</aside>
<canvas id="canvas"></canvas>
<div class="tooltip" id="tooltip"></div>
</main>
</div>
</div>
<script src="./center_chair_people_payload.js"></script>
<script>
const DATA = window.CHAIR_MAP_DATA;
function decodeSegments(base64) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
return new Int32Array(bytes.buffer);
}
const bgTileRanges = DATA.bgTileRanges;
const bgSegValues = decodeSegments(DATA.bgSegsB64);
const chairSegValues = decodeSegments(DATA.chairSegsB64);
const chairs = DATA.chairs.map(([key, name, kind, start, count]) => ({
key, name, kind, start, count
}));
const meta = DATA.meta;
const world = meta.headerBounds;
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
const tooltip = document.getElementById("tooltip");
const scaleChip = document.getElementById("scale-chip");
const hoverChip = document.getElementById("hover-chip");
const STORAGE_KEY = "ptc-chair-selection";
const PEOPLE_STORAGE_KEY = "ptc-chair-people";
const ASSIGN_STORAGE_KEY = "ptc-chair-assignments";
const ACTIVE_PERSON_STORAGE_KEY = "ptc-chair-active-person";
const clearAssignBtn = document.getElementById("clear-assign-btn");
const orgChartEl = document.getElementById("org-chart");
const mapperStatus = document.getElementById("mapper-status");
// Prevent stale auto-highlights from previous sessions.
localStorage.removeItem(STORAGE_KEY);
localStorage.removeItem(ACTIVE_PERSON_STORAGE_KEY);
localStorage.removeItem(ASSIGN_STORAGE_KEY);
const placed = new Set();
let people = JSON.parse(localStorage.getItem(PEOPLE_STORAGE_KEY) || "[]");
let chairAssignments = {};
let activePersonId = null;
const ORG_TEMPLATE = {
top: {
name: "총괄기획실",
count: 53,
members: [
{ name: "장종찬", dept: "총괄기획실", title: "기획실장" },
{ name: "김원식", dept: "총괄기획실", title: "전무이사" },
],
},
teams: [
{ name: "경영기획팀", count: 6, members: ["김우진", "임민정", "국혜린", "최선아", "김윤재", "이미영"] },
{ name: "인재성장팀", count: 5, members: ["조태희", "최근혜", "류원준", "주안기", "정성호"] },
{ name: "ERP 기획팀", count: 5, members: ["류호성", "문형식", "최요제", "황대일", "이채봉"] },
{ name: "디자인기획팀", count: 17, members: ["신혜영", "정은혜", "김태식", "최예은", "채선영", "최영환", "윤봄이", "이예진", "허유나", "마희연", "김수현", "박지영", "권순호", "정두휘", "김정석", "정지윤", "양숙영"] },
{ name: "기술기획팀", count: 11, members: ["김원기", "홍아름", "이경민", "김혜인", "황동환", "최찬호", "이태훈", "김신지", "조찬영", "김용연", "한치영"] },
{ name: "협업증진팀", count: 3, members: ["성형일", "박주한", "한승민"] },
{ name: "솔루션통합팀", count: 4, members: ["권혁진", "염승호", "윤준수", "김지영"] },
],
};
const chairGeometry = chairs.map((chair) => {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
const path = new Path2D();
const hitSegments = new Float32Array(chair.count * 4);
let segCursor = 0;
for (let i = chair.start; i < chair.start + chair.count; i += 1) {
const offset = i * 4;
const x1 = chairSegValues[offset] / 10;
const y1 = chairSegValues[offset + 1] / 10;
const x2 = chairSegValues[offset + 2] / 10;
const y2 = chairSegValues[offset + 3] / 10;
path.moveTo(x1, y1);
path.lineTo(x2, y2);
hitSegments[segCursor] = x1;
hitSegments[segCursor + 1] = y1;
hitSegments[segCursor + 2] = x2;
hitSegments[segCursor + 3] = y2;
segCursor += 4;
minX = Math.min(minX, x1, x2);
minY = Math.min(minY, y1, y2);
maxX = Math.max(maxX, x1, x2);
maxY = Math.max(maxY, y1, y2);
}
return {
...chair,
minX,
minY,
maxX,
maxY,
area: Math.max(1, (maxX - minX) * (maxY - minY)),
path,
hitSegments,
};
});
function renumberChairKeys(chairItems) {
if (!chairItems.length) return;
const heights = chairItems
.map((chair) => Math.max(1, chair.maxY - chair.minY))
.sort((a, b) => a - b);
const medianHeight = heights[Math.floor(heights.length / 2)] || 1;
const rowTolerance = Math.max(40, medianHeight * 0.9);
const sorted = [...chairItems].sort((a, b) => {
const ay = (a.minY + a.maxY) * 0.5;
const by = (b.minY + b.maxY) * 0.5;
if (Math.abs(by - ay) > rowTolerance) return by - ay; // top -> bottom
const ax = (a.minX + a.maxX) * 0.5;
const bx = (b.minX + b.maxX) * 0.5;
return ax - bx; // left -> right
});
sorted.forEach((chair, index) => {
chair.key = String(index + 1);
chair.seatNo = index + 1;
});
}
renumberChairKeys(chairGeometry);
const PICK_GRID_SIZE = 1800;
const chairPickGrid = new Map();
function pickGridKey(gx, gy) {
return `${gx},${gy}`;
}
chairGeometry.forEach((chair, index) => {
const minGX = Math.floor(chair.minX / PICK_GRID_SIZE);
const maxGX = Math.floor(chair.maxX / PICK_GRID_SIZE);
const minGY = Math.floor(chair.minY / PICK_GRID_SIZE);
const maxGY = Math.floor(chair.maxY / PICK_GRID_SIZE);
for (let gx = minGX; gx <= maxGX; gx += 1) {
for (let gy = minGY; gy <= maxGY; gy += 1) {
const key = pickGridKey(gx, gy);
if (!chairPickGrid.has(key)) chairPickGrid.set(key, []);
chairPickGrid.get(key).push(index);
}
}
});
const camera = { scale: 1, offsetX: 0, offsetY: 0 };
let pixelRatio = window.devicePixelRatio || 1;
let pointer = { x: 0, y: 0 };
let dragging = false;
let dragStart = null;
let hovered = null;
let rafPending = false;
function normalizePeople(raw) {
return raw
.map((person, index) => {
if (!person || !person.name) return null;
return {
id: person.id || `person-${index + 1}`,
name: String(person.name).trim(),
dept: String(person.dept || "").trim(),
title: String(person.title || "").trim(),
};
})
.filter(Boolean);
}
function createTemplatePeople() {
const generated = [];
let seq = 1;
ORG_TEMPLATE.top.members.forEach((member) => {
generated.push({
id: `org-${seq++}`,
name: member.name,
dept: member.dept,
title: member.title,
});
});
ORG_TEMPLATE.teams.forEach((team) => {
team.members.forEach((name) => {
generated.push({
id: `org-${seq++}`,
name,
dept: team.name,
title: "선임",
});
});
});
return generated;
}
people = normalizePeople(people);
const templateReady = people.some((person) => person.name === "장종찬" && person.dept === "총괄기획실");
if (!templateReady) {
people = createTemplatePeople();
localStorage.setItem(PEOPLE_STORAGE_KEY, JSON.stringify(people));
}
const chairKeySet = new Set(chairGeometry.map((chair) => chair.key));
chairAssignments = Object.fromEntries(
Object.entries(chairAssignments).filter(([chairKey, personId]) => (
chairKeySet.has(chairKey) && people.some((person) => person.id === personId)
))
);
if (activePersonId && !people.some((person) => person.id === activePersonId)) activePersonId = null;
function persistPeople() {
localStorage.setItem(PEOPLE_STORAGE_KEY, JSON.stringify(people));
}
function persistAssignments() {
localStorage.setItem(ASSIGN_STORAGE_KEY, JSON.stringify(chairAssignments));
}
function persistActivePerson() {
if (!activePersonId) localStorage.removeItem(ACTIVE_PERSON_STORAGE_KEY);
else localStorage.setItem(ACTIVE_PERSON_STORAGE_KEY, activePersonId);
}
function assignmentCount() {
return Object.keys(chairAssignments).length;
}
function getPersonById(id) {
return people.find((person) => person.id === id) || null;
}
function getChairByPerson(personId) {
for (const [chairKey, assignedPersonId] of Object.entries(chairAssignments)) {
if (assignedPersonId === personId) return chairKey;
}
return null;
}
function renderPeopleList() {
const activePerson = getPersonById(activePersonId);
const countText = `${assignmentCount()} / ${people.length} 매칭`;
mapperStatus.innerHTML = `<strong>조직 현황</strong><span>${activePerson ? `${activePerson.name} 선택됨` : "선택 인원 없음"} · ${countText}</span>`;
const findPerson = (dept, name) => people.find((person) => person.dept === dept && person.name === name) || null;
const personCard = (person, roleText) => {
if (!person) return "";
const chairKey = getChairByPerson(person.id);
const assignedClass = chairKey ? " assigned" : "";
const activeClass = person.id === activePersonId ? " active" : "";
return `
<article class="org-person${assignedClass}${activeClass}" data-person-id="${person.id}">
<strong>${person.name}</strong>
<small>${person.title || roleText || "-"}</small>
<small>${chairKey ? `좌석 ${chairKey}` : "좌석 미지정"}</small>
</article>
`;
};
const topHtml = ORG_TEMPLATE.top.members
.map((member) => personCard(findPerson(member.dept, member.name), member.title))
.join("");
const teamsHtml = ORG_TEMPLATE.teams.map((team) => {
const membersHtml = team.members
.map((name) => personCard(findPerson(team.name, name), "선임"))
.join("");
return `
<section class="org-team">
<h4>${team.name} (${team.count})</h4>
<div class="org-members">${membersHtml}</div>
</section>
`;
}).join("");
orgChartEl.innerHTML = `
<section class="org-top">
<div class="org-top-title">${ORG_TEMPLATE.top.name} (${ORG_TEMPLATE.top.count})</div>
<div class="org-top-members">${topHtml}</div>
</section>
<section class="org-teams">${teamsHtml}</section>
`;
}
function worldToScreen(x, y) {
return {
x: x * camera.scale + camera.offsetX,
y: (world.maxY - y + world.minY) * camera.scale + camera.offsetY,
};
}
function screenToWorld(x, y) {
return {
x: (x - camera.offsetX) / camera.scale,
y: world.maxY + world.minY - (y - camera.offsetY) / camera.scale,
};
}
function resize() {
pixelRatio = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = Math.round(rect.width * pixelRatio);
canvas.height = Math.round(rect.height * pixelRatio);
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
fit();
}
function fit() {
const rect = canvas.getBoundingClientRect();
const width = world.maxX - world.minX;
const height = world.maxY - world.minY;
const pad = 36;
const scaleX = (rect.width - pad * 2) / width;
const scaleY = (rect.height - pad * 2) / height;
camera.scale = Math.min(scaleX, scaleY);
camera.offsetX = pad - world.minX * camera.scale + (rect.width - pad * 2 - width * camera.scale) / 2;
camera.offsetY = pad - world.minY * camera.scale + (rect.height - pad * 2 - height * camera.scale) / 2;
requestDraw();
}
function drawGrid(width, height) {
ctx.save();
ctx.strokeStyle = "rgba(21,35,48,0.05)";
ctx.lineWidth = 1;
for (let x = 120; x < width; x += 120) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
ctx.stroke();
}
for (let y = 120; y < height; y += 120) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(width, y);
ctx.stroke();
}
ctx.restore();
}
function pickChair(screenX, screenY) {
const threshold = 12;
const pointerWorld = screenToWorld(screenX, screenY);
const thresholdWorld = threshold / camera.scale;
const thresholdWorldSq = thresholdWorld * thresholdWorld;
const minGX = Math.floor((pointerWorld.x - thresholdWorld) / PICK_GRID_SIZE);
const maxGX = Math.floor((pointerWorld.x + thresholdWorld) / PICK_GRID_SIZE);
const minGY = Math.floor((pointerWorld.y - thresholdWorld) / PICK_GRID_SIZE);
const maxGY = Math.floor((pointerWorld.y + thresholdWorld) / PICK_GRID_SIZE);
const candidateIndexes = [];
const seen = new Set();
for (let gx = minGX; gx <= maxGX; gx += 1) {
for (let gy = minGY; gy <= maxGY; gy += 1) {
const candidates = chairPickGrid.get(pickGridKey(gx, gy));
if (!candidates) continue;
for (const index of candidates) {
if (seen.has(index)) continue;
seen.add(index);
candidateIndexes.push(index);
}
}
}
let best = null;
for (const index of candidateIndexes) {
const chair = chairGeometry[index];
if (
pointerWorld.x < chair.minX - thresholdWorld ||
pointerWorld.x > chair.maxX + thresholdWorld ||
pointerWorld.y < chair.minY - thresholdWorld ||
pointerWorld.y > chair.maxY + thresholdWorld
) continue;
let distSq = Infinity;
for (let i = 0; i < chair.hitSegments.length; i += 4) {
const x1 = chair.hitSegments[i];
const y1 = chair.hitSegments[i + 1];
const x2 = chair.hitSegments[i + 2];
const y2 = chair.hitSegments[i + 3];
const dx = x2 - x1;
const dy = y2 - y1;
const len2 = dx * dx + dy * dy;
let segDistSq;
if (len2 === 0) {
const px = pointerWorld.x - x1;
const py = pointerWorld.y - y1;
segDistSq = px * px + py * py;
} else {
let t = ((pointerWorld.x - x1) * dx + (pointerWorld.y - y1) * dy) / len2;
t = Math.max(0, Math.min(1, t));
const lx = x1 + t * dx;
const ly = y1 + t * dy;
const px = pointerWorld.x - lx;
const py = pointerWorld.y - ly;
segDistSq = px * px + py * py;
}
if (segDistSq < distSq) distSq = segDistSq;
if (distSq <= thresholdWorldSq * 0.3) break;
}
if (distSq > thresholdWorldSq) continue;
const dist = Math.sqrt(distSq) * camera.scale;
if (!best) {
best = { chair, dist };
continue;
}
const distGap = dist - best.dist;
if (distGap < -0.75) {
best = { chair, dist };
continue;
}
if (Math.abs(distGap) <= 2) {
const areaGap = chair.area - best.chair.area;
if (areaGap < -1) {
best = { chair, dist };
continue;
}
if (
Math.abs(areaGap) <= 1 &&
chair.kind === "block" &&
best.chair.kind !== "block"
) {
best = { chair, dist };
}
}
}
return best ? best.chair : null;
}
function renderTooltip() {
if (!hovered) {
tooltip.classList.remove("visible");
hoverChip.textContent = "chair hover: none";
return;
}
hoverChip.textContent = `chair hover: ${hovered.name}`;
tooltip.innerHTML = `
<strong>${hovered.name}</strong>
<div>chair key: ${hovered.key}</div>
<div>${placed.has(hovered.key) ? "선택됨" : "클릭하면 선택"}</div>
<div>${chairAssignments[hovered.key] ? `배치: ${(getPersonById(chairAssignments[hovered.key]) || { name: "알수없음" }).name}` : "배치 인원 없음"}</div>
`;
tooltip.style.left = `${pointer.x + 14}px`;
tooltip.style.top = `${pointer.y + 14}px`;
tooltip.classList.add("visible");
}
function requestDraw() {
if (rafPending) return;
rafPending = true;
window.requestAnimationFrame(() => {
rafPending = false;
draw();
});
}
function applyWorldTransform() {
ctx.setTransform(
pixelRatio * camera.scale,
0,
0,
-pixelRatio * camera.scale,
pixelRatio * camera.offsetX,
pixelRatio * ((world.maxY + world.minY) * camera.scale + camera.offsetY)
);
}
function draw() {
const rect = canvas.getBoundingClientRect();
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
ctx.clearRect(0, 0, rect.width, rect.height);
drawGrid(rect.width, rect.height);
const viewA = screenToWorld(0, rect.height);
const viewB = screenToWorld(rect.width, 0);
const viewMinX = Math.min(viewA.x, viewB.x);
const viewMaxX = Math.max(viewA.x, viewB.x);
const viewMinY = Math.min(viewA.y, viewB.y);
const viewMaxY = Math.max(viewA.y, viewB.y);
ctx.save();
applyWorldTransform();
ctx.strokeStyle = "rgba(100, 116, 139, 0.28)";
ctx.lineWidth = 1 / camera.scale;
const tileSize = meta.backgroundTileSize;
const tileMinX = Math.floor(viewMinX / tileSize);
const tileMaxX = Math.floor(viewMaxX / tileSize);
const tileMinY = Math.floor(viewMinY / tileSize);
const tileMaxY = Math.floor(viewMaxY / tileSize);
for (let tx = tileMinX; tx <= tileMaxX; tx += 1) {
for (let ty = tileMinY; ty <= tileMaxY; ty += 1) {
const range = bgTileRanges[`${tx},${ty}`];
if (!range) continue;
const start = range[0];
const count = range[1];
for (let i = start; i < start + count; i += 1) {
const offset = i * 4;
const x1 = bgSegValues[offset] / 10;
const y1 = bgSegValues[offset + 1] / 10;
const x2 = bgSegValues[offset + 2] / 10;
const y2 = bgSegValues[offset + 3] / 10;
if (
Math.max(x1, x2) < viewMinX ||
Math.min(x1, x2) > viewMaxX ||
Math.max(y1, y2) < viewMinY ||
Math.min(y1, y2) > viewMaxY
) continue;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
}
}
}
ctx.restore();
hovered = dragging ? null : pickChair(pointer.x, pointer.y);
ctx.save();
applyWorldTransform();
ctx.lineWidth = 1.45 / camera.scale;
ctx.lineCap = "round";
ctx.lineJoin = "round";
for (const chair of chairGeometry) {
if (chair.maxX < viewMinX || chair.minX > viewMaxX || chair.maxY < viewMinY || chair.minY > viewMaxY) continue;
const active = hovered && hovered.key === chair.key;
const selected = placed.has(chair.key);
const assignedPersonId = chairAssignments[chair.key];
const activePersonChair = activePersonId && assignedPersonId === activePersonId;
const assigned = Boolean(assignedPersonId);
const baseWidth = chair.kind === "block" ? 1.45 : 1.35;
ctx.strokeStyle = activePersonChair
? "rgba(234, 179, 8, 1)"
: assigned
? "rgba(37, 99, 235, 0.98)"
: selected
? "rgba(220, 38, 38, 0.98)"
: active
? "rgba(15, 118, 110, 0.98)"
: chair.kind === "group"
? "rgba(16, 134, 149, 0.74)"
: "rgba(21, 149, 142, 0.8)";
ctx.lineWidth = (activePersonChair ? 2.8 : assigned ? 2.4 : selected ? 2.6 : active ? 2.1 : baseWidth) / camera.scale;
ctx.stroke(chair.path);
}
ctx.restore();
scaleChip.textContent = `scale ${camera.scale.toFixed(4)}x`;
renderTooltip();
}
function persistPlaced() {
localStorage.setItem(STORAGE_KEY, JSON.stringify([...placed]));
}
canvas.addEventListener("pointerdown", (event) => {
dragging = true;
dragStart = { x: event.clientX, y: event.clientY, offsetX: camera.offsetX, offsetY: camera.offsetY };
canvas.classList.add("dragging");
});
window.addEventListener("pointerup", (event) => {
if (dragging && dragStart) {
const move = Math.hypot(event.clientX - dragStart.x, event.clientY - dragStart.y);
if (move < 4) {
const rect = canvas.getBoundingClientRect();
const picked = pickChair(event.clientX - rect.left, event.clientY - rect.top);
if (picked) {
if (placed.has(picked.key)) placed.delete(picked.key);
else placed.add(picked.key);
persistPlaced();
if (activePersonId) {
const currentChair = getChairByPerson(activePersonId);
if (chairAssignments[picked.key] === activePersonId) {
delete chairAssignments[picked.key];
} else {
if (currentChair && currentChair !== picked.key) delete chairAssignments[currentChair];
chairAssignments[picked.key] = activePersonId;
}
persistAssignments();
renderPeopleList();
}
}
}
}
dragging = false;
dragStart = null;
canvas.classList.remove("dragging");
requestDraw();
});
window.addEventListener("pointermove", (event) => {
const rect = canvas.getBoundingClientRect();
pointer = { x: event.clientX - rect.left, y: event.clientY - rect.top };
if (dragging && dragStart) {
camera.offsetX = dragStart.offsetX + (event.clientX - dragStart.x);
camera.offsetY = dragStart.offsetY + (event.clientY - dragStart.y);
}
requestDraw();
});
canvas.addEventListener("wheel", (event) => {
event.preventDefault();
const rect = canvas.getBoundingClientRect();
const mx = event.clientX - rect.left;
const my = event.clientY - rect.top;
const before = screenToWorld(mx, my);
const factor = event.deltaY < 0 ? 1.08 : 0.92;
camera.scale = Math.max(0.002, Math.min(2, camera.scale * factor));
const after = worldToScreen(before.x, before.y);
camera.offsetX += mx - after.x;
camera.offsetY += my - after.y;
requestDraw();
}, { passive: false });
document.getElementById("fit-btn").addEventListener("click", fit);
document.getElementById("clear-btn").addEventListener("click", () => {
placed.clear();
persistPlaced();
requestDraw();
});
clearAssignBtn.addEventListener("click", () => {
chairAssignments = {};
persistAssignments();
renderPeopleList();
requestDraw();
});
orgChartEl.addEventListener("click", (event) => {
const item = event.target.closest(".org-person[data-person-id]");
if (!item) return;
const personId = item.getAttribute("data-person-id");
activePersonId = personId === activePersonId ? null : personId;
persistActivePerson();
renderPeopleList();
requestDraw();
});
window.addEventListener("resize", resize);
renderPeopleList();
resize();
</script>
</body>
</html>

View File

@@ -0,0 +1,932 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>center chair people map 6f</title>
<style>
:root {
--ink: #152330;
--muted: #627286;
--paper: rgba(255,255,255,0.86);
--line: rgba(21,35,48,0.1);
--accent: #0f766e;
--bg: #edf2f6;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "IBM Plex Sans KR", "Pretendard", sans-serif;
color: var(--ink);
background:
radial-gradient(circle at top left, rgba(15,118,110,0.11), transparent 22%),
linear-gradient(180deg, #f5f8fb 0%, #e8eef3 100%);
}
.page {
min-height: 100vh;
padding: 0;
}
.shell {
min-height: 100vh;
}
.panel {
border-radius: 0;
border: none;
background: transparent;
backdrop-filter: none;
box-shadow: none;
}
.actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
button {
border: none;
border-radius: 999px;
padding: 10px 14px;
font: inherit;
font-weight: 700;
cursor: pointer;
color: white;
background: linear-gradient(135deg, #0f766e, #115e59);
box-shadow: 0 10px 22px rgba(15,118,110,0.18);
}
button.alt {
color: var(--ink);
background: rgba(255,255,255,0.9);
border: 1px solid var(--line);
box-shadow: none;
}
.viewer {
position: relative;
overflow: hidden;
min-height: 100vh;
}
.viewer-head {
position: absolute;
top: 16px;
left: 16px;
right: 16px;
z-index: 2;
display: flex;
justify-content: space-between;
gap: 12px;
pointer-events: none;
}
.chip {
padding: 10px 12px;
border-radius: 16px;
background: rgba(255,255,255,0.82);
border: 1px solid rgba(255,255,255,0.94);
color: var(--muted);
font-size: 13px;
font-weight: 700;
box-shadow: 0 8px 24px rgba(21,35,48,0.08);
}
.viewer-actions {
position: absolute;
left: 16px;
top: 64px;
z-index: 2;
display: flex;
gap: 8px;
}
.mapper {
position: absolute;
top: 76px;
left: 50%;
transform: translateX(-50%);
width: min(94vw, 1320px);
max-height: min(56vh, 560px);
overflow: hidden;
z-index: 4;
border-radius: 20px;
background: rgba(234, 239, 247, 0.95);
border: 1px solid rgba(101, 119, 146, 0.22);
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.2);
display: flex;
flex-direction: column;
backdrop-filter: blur(6px);
}
.hidden-off {
display: none !important;
}
.mapper-head {
padding: 10px 14px;
border-bottom: 1px solid rgba(101,119,146,0.18);
font-size: 12px;
color: #51607a;
font-weight: 700;
line-height: 1.35;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
background: rgba(255,255,255,0.6);
}
.mapper-head strong {
display: block;
color: #17243b;
font-size: 20px;
margin-bottom: 2px;
}
.mapper-head .alt {
padding: 8px 10px;
font-size: 12px;
white-space: nowrap;
}
.org-chart {
margin: 0;
padding: 14px;
overflow: auto;
display: grid;
gap: 12px;
}
.org-top {
margin: 0 auto;
width: min(100%, 420px);
border-radius: 14px;
overflow: hidden;
border: 1px solid rgba(67, 84, 118, 0.25);
background: #fff;
}
.org-top-title {
background: #1e2f4d;
color: #fff;
text-align: center;
font-size: 34px;
font-weight: 800;
line-height: 1.1;
padding: 16px 12px;
letter-spacing: -0.03em;
}
.org-top-members {
padding: 10px;
display: grid;
gap: 6px;
background: rgba(255,255,255,0.95);
}
.org-teams {
display: grid;
grid-template-columns: repeat(7, minmax(160px, 1fr));
gap: 10px;
align-items: start;
}
.org-team {
border: 1px solid rgba(110, 126, 152, 0.25);
border-radius: 10px;
overflow: hidden;
background: rgba(255,255,255,0.95);
min-width: 0;
}
.org-team h4 {
margin: 0;
padding: 9px 10px;
font-size: 14px;
color: #21324e;
font-weight: 800;
border-bottom: 1px solid rgba(110, 126, 152, 0.2);
background: rgba(240, 245, 252, 0.96);
}
.org-members {
padding: 7px;
display: grid;
gap: 6px;
}
.org-person {
border: 1px solid rgba(116, 133, 161, 0.25);
background: rgba(255,255,255,0.95);
border-radius: 8px;
padding: 6px 8px;
cursor: pointer;
transition: background 120ms ease, border-color 120ms ease;
min-width: 0;
}
.org-person.active {
border-color: rgba(15,118,110,0.6);
background: rgba(15,118,110,0.11);
}
.org-person.assigned {
border-color: rgba(37,99,235,0.5);
background: rgba(37,99,235,0.1);
}
.org-person strong {
display: block;
font-size: 13px;
line-height: 1.3;
color: #15233a;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.org-person small {
display: block;
color: #5a6a86;
font-size: 11px;
line-height: 1.25;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 980px) {
.mapper {
top: 72px;
width: min(96vw, 920px);
max-height: 58vh;
}
.viewer-actions {
top: 64px;
left: 12px;
right: 12px;
flex-wrap: wrap;
}
.mapper-head strong {
font-size: 16px;
}
.org-top-title {
font-size: 24px;
}
.org-teams {
grid-template-columns: repeat(3, minmax(150px, 1fr));
}
}
canvas {
width: 100%;
height: 100%;
display: block;
cursor: grab;
}
canvas.dragging { cursor: grabbing; }
.tooltip {
position: absolute;
min-width: 170px;
padding: 12px 14px;
border-radius: 16px;
background: rgba(17,24,39,0.94);
color: white;
pointer-events: none;
opacity: 0;
transform: translate(12px, 12px);
transition: opacity 120ms ease;
z-index: 3;
}
.tooltip.visible { opacity: 1; }
.tooltip strong { display: block; margin-bottom: 6px; font-size: 14px; }
.tooltip div { font-size: 12px; line-height: 1.45; color: rgba(255,255,255,0.82); }
</style>
</head>
<body>
<div class="page">
<div class="shell">
<main class="panel viewer">
<div class="viewer-head">
<div class="chip" id="scale-chip"></div>
<div class="chip" id="hover-chip">chair hover: none</div>
</div>
<div class="viewer-actions">
<button type="button" id="fit-btn">전체 맞춤</button>
<button type="button" class="alt" id="clear-btn">선택 지우기</button>
</div>
<aside class="mapper hidden-off">
<div class="mapper-head">
<div id="mapper-status">
<strong>조직 현황</strong>
<span>선택 인원 없음</span>
</div>
<button type="button" class="alt" id="clear-assign-btn">매칭 초기화</button>
</div>
<div class="org-chart" id="org-chart"></div>
</aside>
<canvas id="canvas"></canvas>
<div class="tooltip" id="tooltip"></div>
</main>
</div>
</div>
<script src="./center_chair_people_payload_6f.js"></script>
<script>
const DATA = window.CHAIR_MAP_DATA;
function decodeSegments(base64) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
return new Int32Array(bytes.buffer);
}
const bgTileRanges = DATA.bgTileRanges;
const bgSegValues = decodeSegments(DATA.bgSegsB64);
const chairSegValues = decodeSegments(DATA.chairSegsB64);
const chairs = DATA.chairs.map(([key, name, kind, start, count]) => ({
key, name, kind, start, count
}));
const meta = DATA.meta;
const world = meta.headerBounds;
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
const tooltip = document.getElementById("tooltip");
const scaleChip = document.getElementById("scale-chip");
const hoverChip = document.getElementById("hover-chip");
const STORAGE_KEY = "ptc-chair-selection";
const PEOPLE_STORAGE_KEY = "ptc-chair-people";
const ASSIGN_STORAGE_KEY = "ptc-chair-assignments";
const ACTIVE_PERSON_STORAGE_KEY = "ptc-chair-active-person";
const clearAssignBtn = document.getElementById("clear-assign-btn");
const orgChartEl = document.getElementById("org-chart");
const mapperStatus = document.getElementById("mapper-status");
// Prevent stale auto-highlights from previous sessions.
localStorage.removeItem(STORAGE_KEY);
localStorage.removeItem(ACTIVE_PERSON_STORAGE_KEY);
localStorage.removeItem(ASSIGN_STORAGE_KEY);
const placed = new Set();
let people = JSON.parse(localStorage.getItem(PEOPLE_STORAGE_KEY) || "[]");
let chairAssignments = {};
let activePersonId = null;
const ORG_TEMPLATE = {
top: {
name: "총괄기획실",
count: 53,
members: [
{ name: "장종찬", dept: "총괄기획실", title: "기획실장" },
{ name: "김원식", dept: "총괄기획실", title: "전무이사" },
],
},
teams: [
{ name: "경영기획팀", count: 6, members: ["김우진", "임민정", "국혜린", "최선아", "김윤재", "이미영"] },
{ name: "인재성장팀", count: 5, members: ["조태희", "최근혜", "류원준", "주안기", "정성호"] },
{ name: "ERP 기획팀", count: 5, members: ["류호성", "문형식", "최요제", "황대일", "이채봉"] },
{ name: "디자인기획팀", count: 17, members: ["신혜영", "정은혜", "김태식", "최예은", "채선영", "최영환", "윤봄이", "이예진", "허유나", "마희연", "김수현", "박지영", "권순호", "정두휘", "김정석", "정지윤", "양숙영"] },
{ name: "기술기획팀", count: 11, members: ["김원기", "홍아름", "이경민", "김혜인", "황동환", "최찬호", "이태훈", "김신지", "조찬영", "김용연", "한치영"] },
{ name: "협업증진팀", count: 3, members: ["성형일", "박주한", "한승민"] },
{ name: "솔루션통합팀", count: 4, members: ["권혁진", "염승호", "윤준수", "김지영"] },
],
};
const chairGeometry = chairs.map((chair) => {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
const path = new Path2D();
const hitSegments = new Float32Array(chair.count * 4);
let segCursor = 0;
for (let i = chair.start; i < chair.start + chair.count; i += 1) {
const offset = i * 4;
const x1 = chairSegValues[offset] / 10;
const y1 = chairSegValues[offset + 1] / 10;
const x2 = chairSegValues[offset + 2] / 10;
const y2 = chairSegValues[offset + 3] / 10;
path.moveTo(x1, y1);
path.lineTo(x2, y2);
hitSegments[segCursor] = x1;
hitSegments[segCursor + 1] = y1;
hitSegments[segCursor + 2] = x2;
hitSegments[segCursor + 3] = y2;
segCursor += 4;
minX = Math.min(minX, x1, x2);
minY = Math.min(minY, y1, y2);
maxX = Math.max(maxX, x1, x2);
maxY = Math.max(maxY, y1, y2);
}
return {
...chair,
minX,
minY,
maxX,
maxY,
area: Math.max(1, (maxX - minX) * (maxY - minY)),
path,
hitSegments,
};
});
function renumberChairKeys(chairItems) {
if (!chairItems.length) return;
const heights = chairItems
.map((chair) => Math.max(1, chair.maxY - chair.minY))
.sort((a, b) => a - b);
const medianHeight = heights[Math.floor(heights.length / 2)] || 1;
const rowTolerance = Math.max(40, medianHeight * 0.9);
const sorted = [...chairItems].sort((a, b) => {
const ay = (a.minY + a.maxY) * 0.5;
const by = (b.minY + b.maxY) * 0.5;
if (Math.abs(by - ay) > rowTolerance) return by - ay; // top -> bottom
const ax = (a.minX + a.maxX) * 0.5;
const bx = (b.minX + b.maxX) * 0.5;
return ax - bx; // left -> right
});
sorted.forEach((chair, index) => {
chair.key = String(index + 1);
chair.seatNo = index + 1;
});
}
renumberChairKeys(chairGeometry);
const PICK_GRID_SIZE = 1800;
const chairPickGrid = new Map();
function pickGridKey(gx, gy) {
return `${gx},${gy}`;
}
chairGeometry.forEach((chair, index) => {
const minGX = Math.floor(chair.minX / PICK_GRID_SIZE);
const maxGX = Math.floor(chair.maxX / PICK_GRID_SIZE);
const minGY = Math.floor(chair.minY / PICK_GRID_SIZE);
const maxGY = Math.floor(chair.maxY / PICK_GRID_SIZE);
for (let gx = minGX; gx <= maxGX; gx += 1) {
for (let gy = minGY; gy <= maxGY; gy += 1) {
const key = pickGridKey(gx, gy);
if (!chairPickGrid.has(key)) chairPickGrid.set(key, []);
chairPickGrid.get(key).push(index);
}
}
});
const camera = { scale: 1, offsetX: 0, offsetY: 0 };
let pixelRatio = window.devicePixelRatio || 1;
let pointer = { x: 0, y: 0 };
let dragging = false;
let dragStart = null;
let hovered = null;
let rafPending = false;
function normalizePeople(raw) {
return raw
.map((person, index) => {
if (!person || !person.name) return null;
return {
id: person.id || `person-${index + 1}`,
name: String(person.name).trim(),
dept: String(person.dept || "").trim(),
title: String(person.title || "").trim(),
};
})
.filter(Boolean);
}
function createTemplatePeople() {
const generated = [];
let seq = 1;
ORG_TEMPLATE.top.members.forEach((member) => {
generated.push({
id: `org-${seq++}`,
name: member.name,
dept: member.dept,
title: member.title,
});
});
ORG_TEMPLATE.teams.forEach((team) => {
team.members.forEach((name) => {
generated.push({
id: `org-${seq++}`,
name,
dept: team.name,
title: "선임",
});
});
});
return generated;
}
people = normalizePeople(people);
const templateReady = people.some((person) => person.name === "장종찬" && person.dept === "총괄기획실");
if (!templateReady) {
people = createTemplatePeople();
localStorage.setItem(PEOPLE_STORAGE_KEY, JSON.stringify(people));
}
const chairKeySet = new Set(chairGeometry.map((chair) => chair.key));
chairAssignments = Object.fromEntries(
Object.entries(chairAssignments).filter(([chairKey, personId]) => (
chairKeySet.has(chairKey) && people.some((person) => person.id === personId)
))
);
if (activePersonId && !people.some((person) => person.id === activePersonId)) activePersonId = null;
function persistPeople() {
localStorage.setItem(PEOPLE_STORAGE_KEY, JSON.stringify(people));
}
function persistAssignments() {
localStorage.setItem(ASSIGN_STORAGE_KEY, JSON.stringify(chairAssignments));
}
function persistActivePerson() {
if (!activePersonId) localStorage.removeItem(ACTIVE_PERSON_STORAGE_KEY);
else localStorage.setItem(ACTIVE_PERSON_STORAGE_KEY, activePersonId);
}
function assignmentCount() {
return Object.keys(chairAssignments).length;
}
function getPersonById(id) {
return people.find((person) => person.id === id) || null;
}
function getChairByPerson(personId) {
for (const [chairKey, assignedPersonId] of Object.entries(chairAssignments)) {
if (assignedPersonId === personId) return chairKey;
}
return null;
}
function renderPeopleList() {
const activePerson = getPersonById(activePersonId);
const countText = `${assignmentCount()} / ${people.length} 매칭`;
mapperStatus.innerHTML = `<strong>조직 현황</strong><span>${activePerson ? `${activePerson.name} 선택됨` : "선택 인원 없음"} · ${countText}</span>`;
const findPerson = (dept, name) => people.find((person) => person.dept === dept && person.name === name) || null;
const personCard = (person, roleText) => {
if (!person) return "";
const chairKey = getChairByPerson(person.id);
const assignedClass = chairKey ? " assigned" : "";
const activeClass = person.id === activePersonId ? " active" : "";
return `
<article class="org-person${assignedClass}${activeClass}" data-person-id="${person.id}">
<strong>${person.name}</strong>
<small>${person.title || roleText || "-"}</small>
<small>${chairKey ? `좌석 ${chairKey}` : "좌석 미지정"}</small>
</article>
`;
};
const topHtml = ORG_TEMPLATE.top.members
.map((member) => personCard(findPerson(member.dept, member.name), member.title))
.join("");
const teamsHtml = ORG_TEMPLATE.teams.map((team) => {
const membersHtml = team.members
.map((name) => personCard(findPerson(team.name, name), "선임"))
.join("");
return `
<section class="org-team">
<h4>${team.name} (${team.count})</h4>
<div class="org-members">${membersHtml}</div>
</section>
`;
}).join("");
orgChartEl.innerHTML = `
<section class="org-top">
<div class="org-top-title">${ORG_TEMPLATE.top.name} (${ORG_TEMPLATE.top.count})</div>
<div class="org-top-members">${topHtml}</div>
</section>
<section class="org-teams">${teamsHtml}</section>
`;
}
function worldToScreen(x, y) {
return {
x: x * camera.scale + camera.offsetX,
y: (world.maxY - y + world.minY) * camera.scale + camera.offsetY,
};
}
function screenToWorld(x, y) {
return {
x: (x - camera.offsetX) / camera.scale,
y: world.maxY + world.minY - (y - camera.offsetY) / camera.scale,
};
}
function resize() {
pixelRatio = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = Math.round(rect.width * pixelRatio);
canvas.height = Math.round(rect.height * pixelRatio);
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
fit();
}
function fit() {
const rect = canvas.getBoundingClientRect();
const width = world.maxX - world.minX;
const height = world.maxY - world.minY;
const pad = 36;
const scaleX = (rect.width - pad * 2) / width;
const scaleY = (rect.height - pad * 2) / height;
camera.scale = Math.min(scaleX, scaleY);
camera.offsetX = pad - world.minX * camera.scale + (rect.width - pad * 2 - width * camera.scale) / 2;
camera.offsetY = pad - world.minY * camera.scale + (rect.height - pad * 2 - height * camera.scale) / 2;
requestDraw();
}
function drawGrid(width, height) {
ctx.save();
ctx.strokeStyle = "rgba(21,35,48,0.05)";
ctx.lineWidth = 1;
for (let x = 120; x < width; x += 120) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
ctx.stroke();
}
for (let y = 120; y < height; y += 120) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(width, y);
ctx.stroke();
}
ctx.restore();
}
function pickChair(screenX, screenY) {
const threshold = 12;
const pointerWorld = screenToWorld(screenX, screenY);
const thresholdWorld = threshold / camera.scale;
const thresholdWorldSq = thresholdWorld * thresholdWorld;
const minGX = Math.floor((pointerWorld.x - thresholdWorld) / PICK_GRID_SIZE);
const maxGX = Math.floor((pointerWorld.x + thresholdWorld) / PICK_GRID_SIZE);
const minGY = Math.floor((pointerWorld.y - thresholdWorld) / PICK_GRID_SIZE);
const maxGY = Math.floor((pointerWorld.y + thresholdWorld) / PICK_GRID_SIZE);
const candidateIndexes = [];
const seen = new Set();
for (let gx = minGX; gx <= maxGX; gx += 1) {
for (let gy = minGY; gy <= maxGY; gy += 1) {
const candidates = chairPickGrid.get(pickGridKey(gx, gy));
if (!candidates) continue;
for (const index of candidates) {
if (seen.has(index)) continue;
seen.add(index);
candidateIndexes.push(index);
}
}
}
let best = null;
for (const index of candidateIndexes) {
const chair = chairGeometry[index];
if (
pointerWorld.x < chair.minX - thresholdWorld ||
pointerWorld.x > chair.maxX + thresholdWorld ||
pointerWorld.y < chair.minY - thresholdWorld ||
pointerWorld.y > chair.maxY + thresholdWorld
) continue;
let distSq = Infinity;
for (let i = 0; i < chair.hitSegments.length; i += 4) {
const x1 = chair.hitSegments[i];
const y1 = chair.hitSegments[i + 1];
const x2 = chair.hitSegments[i + 2];
const y2 = chair.hitSegments[i + 3];
const dx = x2 - x1;
const dy = y2 - y1;
const len2 = dx * dx + dy * dy;
let segDistSq;
if (len2 === 0) {
const px = pointerWorld.x - x1;
const py = pointerWorld.y - y1;
segDistSq = px * px + py * py;
} else {
let t = ((pointerWorld.x - x1) * dx + (pointerWorld.y - y1) * dy) / len2;
t = Math.max(0, Math.min(1, t));
const lx = x1 + t * dx;
const ly = y1 + t * dy;
const px = pointerWorld.x - lx;
const py = pointerWorld.y - ly;
segDistSq = px * px + py * py;
}
if (segDistSq < distSq) distSq = segDistSq;
if (distSq <= thresholdWorldSq * 0.3) break;
}
if (distSq > thresholdWorldSq) continue;
const dist = Math.sqrt(distSq) * camera.scale;
if (!best) {
best = { chair, dist };
continue;
}
const distGap = dist - best.dist;
if (distGap < -0.75) {
best = { chair, dist };
continue;
}
if (Math.abs(distGap) <= 2) {
const areaGap = chair.area - best.chair.area;
if (areaGap < -1) {
best = { chair, dist };
continue;
}
if (
Math.abs(areaGap) <= 1 &&
chair.kind === "block" &&
best.chair.kind !== "block"
) {
best = { chair, dist };
}
}
}
return best ? best.chair : null;
}
function renderTooltip() {
if (!hovered) {
tooltip.classList.remove("visible");
hoverChip.textContent = "chair hover: none";
return;
}
hoverChip.textContent = `chair hover: ${hovered.name}`;
tooltip.innerHTML = `
<strong>${hovered.name}</strong>
<div>chair key: ${hovered.key}</div>
<div>${placed.has(hovered.key) ? "선택됨" : "클릭하면 선택"}</div>
<div>${chairAssignments[hovered.key] ? `배치: ${(getPersonById(chairAssignments[hovered.key]) || { name: "알수없음" }).name}` : "배치 인원 없음"}</div>
`;
tooltip.style.left = `${pointer.x + 14}px`;
tooltip.style.top = `${pointer.y + 14}px`;
tooltip.classList.add("visible");
}
function requestDraw() {
if (rafPending) return;
rafPending = true;
window.requestAnimationFrame(() => {
rafPending = false;
draw();
});
}
function applyWorldTransform() {
ctx.setTransform(
pixelRatio * camera.scale,
0,
0,
-pixelRatio * camera.scale,
pixelRatio * camera.offsetX,
pixelRatio * ((world.maxY + world.minY) * camera.scale + camera.offsetY)
);
}
function draw() {
const rect = canvas.getBoundingClientRect();
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
ctx.clearRect(0, 0, rect.width, rect.height);
drawGrid(rect.width, rect.height);
const viewA = screenToWorld(0, rect.height);
const viewB = screenToWorld(rect.width, 0);
const viewMinX = Math.min(viewA.x, viewB.x);
const viewMaxX = Math.max(viewA.x, viewB.x);
const viewMinY = Math.min(viewA.y, viewB.y);
const viewMaxY = Math.max(viewA.y, viewB.y);
ctx.save();
applyWorldTransform();
ctx.strokeStyle = "rgba(100, 116, 139, 0.28)";
ctx.lineWidth = 1 / camera.scale;
const tileSize = meta.backgroundTileSize;
const tileMinX = Math.floor(viewMinX / tileSize);
const tileMaxX = Math.floor(viewMaxX / tileSize);
const tileMinY = Math.floor(viewMinY / tileSize);
const tileMaxY = Math.floor(viewMaxY / tileSize);
for (let tx = tileMinX; tx <= tileMaxX; tx += 1) {
for (let ty = tileMinY; ty <= tileMaxY; ty += 1) {
const range = bgTileRanges[`${tx},${ty}`];
if (!range) continue;
const start = range[0];
const count = range[1];
for (let i = start; i < start + count; i += 1) {
const offset = i * 4;
const x1 = bgSegValues[offset] / 10;
const y1 = bgSegValues[offset + 1] / 10;
const x2 = bgSegValues[offset + 2] / 10;
const y2 = bgSegValues[offset + 3] / 10;
if (
Math.max(x1, x2) < viewMinX ||
Math.min(x1, x2) > viewMaxX ||
Math.max(y1, y2) < viewMinY ||
Math.min(y1, y2) > viewMaxY
) continue;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
}
}
}
ctx.restore();
hovered = dragging ? null : pickChair(pointer.x, pointer.y);
ctx.save();
applyWorldTransform();
ctx.lineWidth = 1.45 / camera.scale;
ctx.lineCap = "round";
ctx.lineJoin = "round";
for (const chair of chairGeometry) {
if (chair.maxX < viewMinX || chair.minX > viewMaxX || chair.maxY < viewMinY || chair.minY > viewMaxY) continue;
const active = hovered && hovered.key === chair.key;
const selected = placed.has(chair.key);
const assignedPersonId = chairAssignments[chair.key];
const activePersonChair = activePersonId && assignedPersonId === activePersonId;
const assigned = Boolean(assignedPersonId);
const baseWidth = chair.kind === "block" ? 1.45 : 1.35;
ctx.strokeStyle = activePersonChair
? "rgba(234, 179, 8, 1)"
: assigned
? "rgba(37, 99, 235, 0.98)"
: selected
? "rgba(220, 38, 38, 0.98)"
: active
? "rgba(15, 118, 110, 0.98)"
: chair.kind === "group"
? "rgba(16, 134, 149, 0.74)"
: "rgba(21, 149, 142, 0.8)";
ctx.lineWidth = (activePersonChair ? 2.8 : assigned ? 2.4 : selected ? 2.6 : active ? 2.1 : baseWidth) / camera.scale;
ctx.stroke(chair.path);
}
ctx.restore();
scaleChip.textContent = `scale ${camera.scale.toFixed(4)}x`;
renderTooltip();
}
function persistPlaced() {
localStorage.setItem(STORAGE_KEY, JSON.stringify([...placed]));
}
canvas.addEventListener("pointerdown", (event) => {
dragging = true;
dragStart = { x: event.clientX, y: event.clientY, offsetX: camera.offsetX, offsetY: camera.offsetY };
canvas.classList.add("dragging");
});
window.addEventListener("pointerup", (event) => {
if (dragging && dragStart) {
const move = Math.hypot(event.clientX - dragStart.x, event.clientY - dragStart.y);
if (move < 4) {
const rect = canvas.getBoundingClientRect();
const picked = pickChair(event.clientX - rect.left, event.clientY - rect.top);
if (picked) {
if (placed.has(picked.key)) placed.delete(picked.key);
else placed.add(picked.key);
persistPlaced();
if (activePersonId) {
const currentChair = getChairByPerson(activePersonId);
if (chairAssignments[picked.key] === activePersonId) {
delete chairAssignments[picked.key];
} else {
if (currentChair && currentChair !== picked.key) delete chairAssignments[currentChair];
chairAssignments[picked.key] = activePersonId;
}
persistAssignments();
renderPeopleList();
}
}
}
}
dragging = false;
dragStart = null;
canvas.classList.remove("dragging");
requestDraw();
});
window.addEventListener("pointermove", (event) => {
const rect = canvas.getBoundingClientRect();
pointer = { x: event.clientX - rect.left, y: event.clientY - rect.top };
if (dragging && dragStart) {
camera.offsetX = dragStart.offsetX + (event.clientX - dragStart.x);
camera.offsetY = dragStart.offsetY + (event.clientY - dragStart.y);
}
requestDraw();
});
canvas.addEventListener("wheel", (event) => {
event.preventDefault();
const rect = canvas.getBoundingClientRect();
const mx = event.clientX - rect.left;
const my = event.clientY - rect.top;
const before = screenToWorld(mx, my);
const factor = event.deltaY < 0 ? 1.08 : 0.92;
camera.scale = Math.max(0.002, Math.min(2, camera.scale * factor));
const after = worldToScreen(before.x, before.y);
camera.offsetX += mx - after.x;
camera.offsetY += my - after.y;
requestDraw();
}, { passive: false });
document.getElementById("fit-btn").addEventListener("click", fit);
document.getElementById("clear-btn").addEventListener("click", () => {
placed.clear();
persistPlaced();
requestDraw();
});
clearAssignBtn.addEventListener("click", () => {
chairAssignments = {};
persistAssignments();
renderPeopleList();
requestDraw();
});
orgChartEl.addEventListener("click", (event) => {
const item = event.target.closest(".org-person[data-person-id]");
if (!item) return;
const personId = item.getAttribute("data-person-id");
activePersonId = personId === activePersonId ? null : personId;
persistActivePerson();
renderPeopleList();
requestDraw();
});
window.addEventListener("resize", resize);
renderPeopleList();
resize();
</script>
</body>
</html>

View File

@@ -0,0 +1,932 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>center chair people map 7f</title>
<style>
:root {
--ink: #152330;
--muted: #627286;
--paper: rgba(255,255,255,0.86);
--line: rgba(21,35,48,0.1);
--accent: #0f766e;
--bg: #edf2f6;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "IBM Plex Sans KR", "Pretendard", sans-serif;
color: var(--ink);
background:
radial-gradient(circle at top left, rgba(15,118,110,0.11), transparent 22%),
linear-gradient(180deg, #f5f8fb 0%, #e8eef3 100%);
}
.page {
min-height: 100vh;
padding: 0;
}
.shell {
min-height: 100vh;
}
.panel {
border-radius: 0;
border: none;
background: transparent;
backdrop-filter: none;
box-shadow: none;
}
.actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
button {
border: none;
border-radius: 999px;
padding: 10px 14px;
font: inherit;
font-weight: 700;
cursor: pointer;
color: white;
background: linear-gradient(135deg, #0f766e, #115e59);
box-shadow: 0 10px 22px rgba(15,118,110,0.18);
}
button.alt {
color: var(--ink);
background: rgba(255,255,255,0.9);
border: 1px solid var(--line);
box-shadow: none;
}
.viewer {
position: relative;
overflow: hidden;
min-height: 100vh;
}
.viewer-head {
position: absolute;
top: 16px;
left: 16px;
right: 16px;
z-index: 2;
display: flex;
justify-content: space-between;
gap: 12px;
pointer-events: none;
}
.chip {
padding: 10px 12px;
border-radius: 16px;
background: rgba(255,255,255,0.82);
border: 1px solid rgba(255,255,255,0.94);
color: var(--muted);
font-size: 13px;
font-weight: 700;
box-shadow: 0 8px 24px rgba(21,35,48,0.08);
}
.viewer-actions {
position: absolute;
left: 16px;
top: 64px;
z-index: 2;
display: flex;
gap: 8px;
}
.mapper {
position: absolute;
top: 76px;
left: 50%;
transform: translateX(-50%);
width: min(94vw, 1320px);
max-height: min(56vh, 560px);
overflow: hidden;
z-index: 4;
border-radius: 20px;
background: rgba(234, 239, 247, 0.95);
border: 1px solid rgba(101, 119, 146, 0.22);
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.2);
display: flex;
flex-direction: column;
backdrop-filter: blur(6px);
}
.hidden-off {
display: none !important;
}
.mapper-head {
padding: 10px 14px;
border-bottom: 1px solid rgba(101,119,146,0.18);
font-size: 12px;
color: #51607a;
font-weight: 700;
line-height: 1.35;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
background: rgba(255,255,255,0.6);
}
.mapper-head strong {
display: block;
color: #17243b;
font-size: 20px;
margin-bottom: 2px;
}
.mapper-head .alt {
padding: 8px 10px;
font-size: 12px;
white-space: nowrap;
}
.org-chart {
margin: 0;
padding: 14px;
overflow: auto;
display: grid;
gap: 12px;
}
.org-top {
margin: 0 auto;
width: min(100%, 420px);
border-radius: 14px;
overflow: hidden;
border: 1px solid rgba(67, 84, 118, 0.25);
background: #fff;
}
.org-top-title {
background: #1e2f4d;
color: #fff;
text-align: center;
font-size: 34px;
font-weight: 800;
line-height: 1.1;
padding: 16px 12px;
letter-spacing: -0.03em;
}
.org-top-members {
padding: 10px;
display: grid;
gap: 6px;
background: rgba(255,255,255,0.95);
}
.org-teams {
display: grid;
grid-template-columns: repeat(7, minmax(160px, 1fr));
gap: 10px;
align-items: start;
}
.org-team {
border: 1px solid rgba(110, 126, 152, 0.25);
border-radius: 10px;
overflow: hidden;
background: rgba(255,255,255,0.95);
min-width: 0;
}
.org-team h4 {
margin: 0;
padding: 9px 10px;
font-size: 14px;
color: #21324e;
font-weight: 800;
border-bottom: 1px solid rgba(110, 126, 152, 0.2);
background: rgba(240, 245, 252, 0.96);
}
.org-members {
padding: 7px;
display: grid;
gap: 6px;
}
.org-person {
border: 1px solid rgba(116, 133, 161, 0.25);
background: rgba(255,255,255,0.95);
border-radius: 8px;
padding: 6px 8px;
cursor: pointer;
transition: background 120ms ease, border-color 120ms ease;
min-width: 0;
}
.org-person.active {
border-color: rgba(15,118,110,0.6);
background: rgba(15,118,110,0.11);
}
.org-person.assigned {
border-color: rgba(37,99,235,0.5);
background: rgba(37,99,235,0.1);
}
.org-person strong {
display: block;
font-size: 13px;
line-height: 1.3;
color: #15233a;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.org-person small {
display: block;
color: #5a6a86;
font-size: 11px;
line-height: 1.25;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 980px) {
.mapper {
top: 72px;
width: min(96vw, 920px);
max-height: 58vh;
}
.viewer-actions {
top: 64px;
left: 12px;
right: 12px;
flex-wrap: wrap;
}
.mapper-head strong {
font-size: 16px;
}
.org-top-title {
font-size: 24px;
}
.org-teams {
grid-template-columns: repeat(3, minmax(150px, 1fr));
}
}
canvas {
width: 100%;
height: 100%;
display: block;
cursor: grab;
}
canvas.dragging { cursor: grabbing; }
.tooltip {
position: absolute;
min-width: 170px;
padding: 12px 14px;
border-radius: 16px;
background: rgba(17,24,39,0.94);
color: white;
pointer-events: none;
opacity: 0;
transform: translate(12px, 12px);
transition: opacity 120ms ease;
z-index: 3;
}
.tooltip.visible { opacity: 1; }
.tooltip strong { display: block; margin-bottom: 6px; font-size: 14px; }
.tooltip div { font-size: 12px; line-height: 1.45; color: rgba(255,255,255,0.82); }
</style>
</head>
<body>
<div class="page">
<div class="shell">
<main class="panel viewer">
<div class="viewer-head">
<div class="chip" id="scale-chip"></div>
<div class="chip" id="hover-chip">chair hover: none</div>
</div>
<div class="viewer-actions">
<button type="button" id="fit-btn">전체 맞춤</button>
<button type="button" class="alt" id="clear-btn">선택 지우기</button>
</div>
<aside class="mapper hidden-off">
<div class="mapper-head">
<div id="mapper-status">
<strong>조직 현황</strong>
<span>선택 인원 없음</span>
</div>
<button type="button" class="alt" id="clear-assign-btn">매칭 초기화</button>
</div>
<div class="org-chart" id="org-chart"></div>
</aside>
<canvas id="canvas"></canvas>
<div class="tooltip" id="tooltip"></div>
</main>
</div>
</div>
<script src="./center_chair_people_payload_7f.js"></script>
<script>
const DATA = window.CHAIR_MAP_DATA;
function decodeSegments(base64) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
return new Int32Array(bytes.buffer);
}
const bgTileRanges = DATA.bgTileRanges;
const bgSegValues = decodeSegments(DATA.bgSegsB64);
const chairSegValues = decodeSegments(DATA.chairSegsB64);
const chairs = DATA.chairs.map(([key, name, kind, start, count]) => ({
key, name, kind, start, count
}));
const meta = DATA.meta;
const world = meta.headerBounds;
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
const tooltip = document.getElementById("tooltip");
const scaleChip = document.getElementById("scale-chip");
const hoverChip = document.getElementById("hover-chip");
const STORAGE_KEY = "ptc-chair-selection";
const PEOPLE_STORAGE_KEY = "ptc-chair-people";
const ASSIGN_STORAGE_KEY = "ptc-chair-assignments";
const ACTIVE_PERSON_STORAGE_KEY = "ptc-chair-active-person";
const clearAssignBtn = document.getElementById("clear-assign-btn");
const orgChartEl = document.getElementById("org-chart");
const mapperStatus = document.getElementById("mapper-status");
// Prevent stale auto-highlights from previous sessions.
localStorage.removeItem(STORAGE_KEY);
localStorage.removeItem(ACTIVE_PERSON_STORAGE_KEY);
localStorage.removeItem(ASSIGN_STORAGE_KEY);
const placed = new Set();
let people = JSON.parse(localStorage.getItem(PEOPLE_STORAGE_KEY) || "[]");
let chairAssignments = {};
let activePersonId = null;
const ORG_TEMPLATE = {
top: {
name: "총괄기획실",
count: 53,
members: [
{ name: "장종찬", dept: "총괄기획실", title: "기획실장" },
{ name: "김원식", dept: "총괄기획실", title: "전무이사" },
],
},
teams: [
{ name: "경영기획팀", count: 6, members: ["김우진", "임민정", "국혜린", "최선아", "김윤재", "이미영"] },
{ name: "인재성장팀", count: 5, members: ["조태희", "최근혜", "류원준", "주안기", "정성호"] },
{ name: "ERP 기획팀", count: 5, members: ["류호성", "문형식", "최요제", "황대일", "이채봉"] },
{ name: "디자인기획팀", count: 17, members: ["신혜영", "정은혜", "김태식", "최예은", "채선영", "최영환", "윤봄이", "이예진", "허유나", "마희연", "김수현", "박지영", "권순호", "정두휘", "김정석", "정지윤", "양숙영"] },
{ name: "기술기획팀", count: 11, members: ["김원기", "홍아름", "이경민", "김혜인", "황동환", "최찬호", "이태훈", "김신지", "조찬영", "김용연", "한치영"] },
{ name: "협업증진팀", count: 3, members: ["성형일", "박주한", "한승민"] },
{ name: "솔루션통합팀", count: 4, members: ["권혁진", "염승호", "윤준수", "김지영"] },
],
};
const chairGeometry = chairs.map((chair) => {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
const path = new Path2D();
const hitSegments = new Float32Array(chair.count * 4);
let segCursor = 0;
for (let i = chair.start; i < chair.start + chair.count; i += 1) {
const offset = i * 4;
const x1 = chairSegValues[offset] / 10;
const y1 = chairSegValues[offset + 1] / 10;
const x2 = chairSegValues[offset + 2] / 10;
const y2 = chairSegValues[offset + 3] / 10;
path.moveTo(x1, y1);
path.lineTo(x2, y2);
hitSegments[segCursor] = x1;
hitSegments[segCursor + 1] = y1;
hitSegments[segCursor + 2] = x2;
hitSegments[segCursor + 3] = y2;
segCursor += 4;
minX = Math.min(minX, x1, x2);
minY = Math.min(minY, y1, y2);
maxX = Math.max(maxX, x1, x2);
maxY = Math.max(maxY, y1, y2);
}
return {
...chair,
minX,
minY,
maxX,
maxY,
area: Math.max(1, (maxX - minX) * (maxY - minY)),
path,
hitSegments,
};
});
function renumberChairKeys(chairItems) {
if (!chairItems.length) return;
const heights = chairItems
.map((chair) => Math.max(1, chair.maxY - chair.minY))
.sort((a, b) => a - b);
const medianHeight = heights[Math.floor(heights.length / 2)] || 1;
const rowTolerance = Math.max(40, medianHeight * 0.9);
const sorted = [...chairItems].sort((a, b) => {
const ay = (a.minY + a.maxY) * 0.5;
const by = (b.minY + b.maxY) * 0.5;
if (Math.abs(by - ay) > rowTolerance) return by - ay; // top -> bottom
const ax = (a.minX + a.maxX) * 0.5;
const bx = (b.minX + b.maxX) * 0.5;
return ax - bx; // left -> right
});
sorted.forEach((chair, index) => {
chair.key = String(index + 1);
chair.seatNo = index + 1;
});
}
renumberChairKeys(chairGeometry);
const PICK_GRID_SIZE = 1800;
const chairPickGrid = new Map();
function pickGridKey(gx, gy) {
return `${gx},${gy}`;
}
chairGeometry.forEach((chair, index) => {
const minGX = Math.floor(chair.minX / PICK_GRID_SIZE);
const maxGX = Math.floor(chair.maxX / PICK_GRID_SIZE);
const minGY = Math.floor(chair.minY / PICK_GRID_SIZE);
const maxGY = Math.floor(chair.maxY / PICK_GRID_SIZE);
for (let gx = minGX; gx <= maxGX; gx += 1) {
for (let gy = minGY; gy <= maxGY; gy += 1) {
const key = pickGridKey(gx, gy);
if (!chairPickGrid.has(key)) chairPickGrid.set(key, []);
chairPickGrid.get(key).push(index);
}
}
});
const camera = { scale: 1, offsetX: 0, offsetY: 0 };
let pixelRatio = window.devicePixelRatio || 1;
let pointer = { x: 0, y: 0 };
let dragging = false;
let dragStart = null;
let hovered = null;
let rafPending = false;
function normalizePeople(raw) {
return raw
.map((person, index) => {
if (!person || !person.name) return null;
return {
id: person.id || `person-${index + 1}`,
name: String(person.name).trim(),
dept: String(person.dept || "").trim(),
title: String(person.title || "").trim(),
};
})
.filter(Boolean);
}
function createTemplatePeople() {
const generated = [];
let seq = 1;
ORG_TEMPLATE.top.members.forEach((member) => {
generated.push({
id: `org-${seq++}`,
name: member.name,
dept: member.dept,
title: member.title,
});
});
ORG_TEMPLATE.teams.forEach((team) => {
team.members.forEach((name) => {
generated.push({
id: `org-${seq++}`,
name,
dept: team.name,
title: "선임",
});
});
});
return generated;
}
people = normalizePeople(people);
const templateReady = people.some((person) => person.name === "장종찬" && person.dept === "총괄기획실");
if (!templateReady) {
people = createTemplatePeople();
localStorage.setItem(PEOPLE_STORAGE_KEY, JSON.stringify(people));
}
const chairKeySet = new Set(chairGeometry.map((chair) => chair.key));
chairAssignments = Object.fromEntries(
Object.entries(chairAssignments).filter(([chairKey, personId]) => (
chairKeySet.has(chairKey) && people.some((person) => person.id === personId)
))
);
if (activePersonId && !people.some((person) => person.id === activePersonId)) activePersonId = null;
function persistPeople() {
localStorage.setItem(PEOPLE_STORAGE_KEY, JSON.stringify(people));
}
function persistAssignments() {
localStorage.setItem(ASSIGN_STORAGE_KEY, JSON.stringify(chairAssignments));
}
function persistActivePerson() {
if (!activePersonId) localStorage.removeItem(ACTIVE_PERSON_STORAGE_KEY);
else localStorage.setItem(ACTIVE_PERSON_STORAGE_KEY, activePersonId);
}
function assignmentCount() {
return Object.keys(chairAssignments).length;
}
function getPersonById(id) {
return people.find((person) => person.id === id) || null;
}
function getChairByPerson(personId) {
for (const [chairKey, assignedPersonId] of Object.entries(chairAssignments)) {
if (assignedPersonId === personId) return chairKey;
}
return null;
}
function renderPeopleList() {
const activePerson = getPersonById(activePersonId);
const countText = `${assignmentCount()} / ${people.length} 매칭`;
mapperStatus.innerHTML = `<strong>조직 현황</strong><span>${activePerson ? `${activePerson.name} 선택됨` : "선택 인원 없음"} · ${countText}</span>`;
const findPerson = (dept, name) => people.find((person) => person.dept === dept && person.name === name) || null;
const personCard = (person, roleText) => {
if (!person) return "";
const chairKey = getChairByPerson(person.id);
const assignedClass = chairKey ? " assigned" : "";
const activeClass = person.id === activePersonId ? " active" : "";
return `
<article class="org-person${assignedClass}${activeClass}" data-person-id="${person.id}">
<strong>${person.name}</strong>
<small>${person.title || roleText || "-"}</small>
<small>${chairKey ? `좌석 ${chairKey}` : "좌석 미지정"}</small>
</article>
`;
};
const topHtml = ORG_TEMPLATE.top.members
.map((member) => personCard(findPerson(member.dept, member.name), member.title))
.join("");
const teamsHtml = ORG_TEMPLATE.teams.map((team) => {
const membersHtml = team.members
.map((name) => personCard(findPerson(team.name, name), "선임"))
.join("");
return `
<section class="org-team">
<h4>${team.name} (${team.count})</h4>
<div class="org-members">${membersHtml}</div>
</section>
`;
}).join("");
orgChartEl.innerHTML = `
<section class="org-top">
<div class="org-top-title">${ORG_TEMPLATE.top.name} (${ORG_TEMPLATE.top.count})</div>
<div class="org-top-members">${topHtml}</div>
</section>
<section class="org-teams">${teamsHtml}</section>
`;
}
function worldToScreen(x, y) {
return {
x: x * camera.scale + camera.offsetX,
y: (world.maxY - y + world.minY) * camera.scale + camera.offsetY,
};
}
function screenToWorld(x, y) {
return {
x: (x - camera.offsetX) / camera.scale,
y: world.maxY + world.minY - (y - camera.offsetY) / camera.scale,
};
}
function resize() {
pixelRatio = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = Math.round(rect.width * pixelRatio);
canvas.height = Math.round(rect.height * pixelRatio);
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
fit();
}
function fit() {
const rect = canvas.getBoundingClientRect();
const width = world.maxX - world.minX;
const height = world.maxY - world.minY;
const pad = 36;
const scaleX = (rect.width - pad * 2) / width;
const scaleY = (rect.height - pad * 2) / height;
camera.scale = Math.min(scaleX, scaleY);
camera.offsetX = pad - world.minX * camera.scale + (rect.width - pad * 2 - width * camera.scale) / 2;
camera.offsetY = pad - world.minY * camera.scale + (rect.height - pad * 2 - height * camera.scale) / 2;
requestDraw();
}
function drawGrid(width, height) {
ctx.save();
ctx.strokeStyle = "rgba(21,35,48,0.05)";
ctx.lineWidth = 1;
for (let x = 120; x < width; x += 120) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
ctx.stroke();
}
for (let y = 120; y < height; y += 120) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(width, y);
ctx.stroke();
}
ctx.restore();
}
function pickChair(screenX, screenY) {
const threshold = 12;
const pointerWorld = screenToWorld(screenX, screenY);
const thresholdWorld = threshold / camera.scale;
const thresholdWorldSq = thresholdWorld * thresholdWorld;
const minGX = Math.floor((pointerWorld.x - thresholdWorld) / PICK_GRID_SIZE);
const maxGX = Math.floor((pointerWorld.x + thresholdWorld) / PICK_GRID_SIZE);
const minGY = Math.floor((pointerWorld.y - thresholdWorld) / PICK_GRID_SIZE);
const maxGY = Math.floor((pointerWorld.y + thresholdWorld) / PICK_GRID_SIZE);
const candidateIndexes = [];
const seen = new Set();
for (let gx = minGX; gx <= maxGX; gx += 1) {
for (let gy = minGY; gy <= maxGY; gy += 1) {
const candidates = chairPickGrid.get(pickGridKey(gx, gy));
if (!candidates) continue;
for (const index of candidates) {
if (seen.has(index)) continue;
seen.add(index);
candidateIndexes.push(index);
}
}
}
let best = null;
for (const index of candidateIndexes) {
const chair = chairGeometry[index];
if (
pointerWorld.x < chair.minX - thresholdWorld ||
pointerWorld.x > chair.maxX + thresholdWorld ||
pointerWorld.y < chair.minY - thresholdWorld ||
pointerWorld.y > chair.maxY + thresholdWorld
) continue;
let distSq = Infinity;
for (let i = 0; i < chair.hitSegments.length; i += 4) {
const x1 = chair.hitSegments[i];
const y1 = chair.hitSegments[i + 1];
const x2 = chair.hitSegments[i + 2];
const y2 = chair.hitSegments[i + 3];
const dx = x2 - x1;
const dy = y2 - y1;
const len2 = dx * dx + dy * dy;
let segDistSq;
if (len2 === 0) {
const px = pointerWorld.x - x1;
const py = pointerWorld.y - y1;
segDistSq = px * px + py * py;
} else {
let t = ((pointerWorld.x - x1) * dx + (pointerWorld.y - y1) * dy) / len2;
t = Math.max(0, Math.min(1, t));
const lx = x1 + t * dx;
const ly = y1 + t * dy;
const px = pointerWorld.x - lx;
const py = pointerWorld.y - ly;
segDistSq = px * px + py * py;
}
if (segDistSq < distSq) distSq = segDistSq;
if (distSq <= thresholdWorldSq * 0.3) break;
}
if (distSq > thresholdWorldSq) continue;
const dist = Math.sqrt(distSq) * camera.scale;
if (!best) {
best = { chair, dist };
continue;
}
const distGap = dist - best.dist;
if (distGap < -0.75) {
best = { chair, dist };
continue;
}
if (Math.abs(distGap) <= 2) {
const areaGap = chair.area - best.chair.area;
if (areaGap < -1) {
best = { chair, dist };
continue;
}
if (
Math.abs(areaGap) <= 1 &&
chair.kind === "block" &&
best.chair.kind !== "block"
) {
best = { chair, dist };
}
}
}
return best ? best.chair : null;
}
function renderTooltip() {
if (!hovered) {
tooltip.classList.remove("visible");
hoverChip.textContent = "chair hover: none";
return;
}
hoverChip.textContent = `chair hover: ${hovered.name}`;
tooltip.innerHTML = `
<strong>${hovered.name}</strong>
<div>chair key: ${hovered.key}</div>
<div>${placed.has(hovered.key) ? "선택됨" : "클릭하면 선택"}</div>
<div>${chairAssignments[hovered.key] ? `배치: ${(getPersonById(chairAssignments[hovered.key]) || { name: "알수없음" }).name}` : "배치 인원 없음"}</div>
`;
tooltip.style.left = `${pointer.x + 14}px`;
tooltip.style.top = `${pointer.y + 14}px`;
tooltip.classList.add("visible");
}
function requestDraw() {
if (rafPending) return;
rafPending = true;
window.requestAnimationFrame(() => {
rafPending = false;
draw();
});
}
function applyWorldTransform() {
ctx.setTransform(
pixelRatio * camera.scale,
0,
0,
-pixelRatio * camera.scale,
pixelRatio * camera.offsetX,
pixelRatio * ((world.maxY + world.minY) * camera.scale + camera.offsetY)
);
}
function draw() {
const rect = canvas.getBoundingClientRect();
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
ctx.clearRect(0, 0, rect.width, rect.height);
drawGrid(rect.width, rect.height);
const viewA = screenToWorld(0, rect.height);
const viewB = screenToWorld(rect.width, 0);
const viewMinX = Math.min(viewA.x, viewB.x);
const viewMaxX = Math.max(viewA.x, viewB.x);
const viewMinY = Math.min(viewA.y, viewB.y);
const viewMaxY = Math.max(viewA.y, viewB.y);
ctx.save();
applyWorldTransform();
ctx.strokeStyle = "rgba(100, 116, 139, 0.28)";
ctx.lineWidth = 1 / camera.scale;
const tileSize = meta.backgroundTileSize;
const tileMinX = Math.floor(viewMinX / tileSize);
const tileMaxX = Math.floor(viewMaxX / tileSize);
const tileMinY = Math.floor(viewMinY / tileSize);
const tileMaxY = Math.floor(viewMaxY / tileSize);
for (let tx = tileMinX; tx <= tileMaxX; tx += 1) {
for (let ty = tileMinY; ty <= tileMaxY; ty += 1) {
const range = bgTileRanges[`${tx},${ty}`];
if (!range) continue;
const start = range[0];
const count = range[1];
for (let i = start; i < start + count; i += 1) {
const offset = i * 4;
const x1 = bgSegValues[offset] / 10;
const y1 = bgSegValues[offset + 1] / 10;
const x2 = bgSegValues[offset + 2] / 10;
const y2 = bgSegValues[offset + 3] / 10;
if (
Math.max(x1, x2) < viewMinX ||
Math.min(x1, x2) > viewMaxX ||
Math.max(y1, y2) < viewMinY ||
Math.min(y1, y2) > viewMaxY
) continue;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
}
}
}
ctx.restore();
hovered = dragging ? null : pickChair(pointer.x, pointer.y);
ctx.save();
applyWorldTransform();
ctx.lineWidth = 1.45 / camera.scale;
ctx.lineCap = "round";
ctx.lineJoin = "round";
for (const chair of chairGeometry) {
if (chair.maxX < viewMinX || chair.minX > viewMaxX || chair.maxY < viewMinY || chair.minY > viewMaxY) continue;
const active = hovered && hovered.key === chair.key;
const selected = placed.has(chair.key);
const assignedPersonId = chairAssignments[chair.key];
const activePersonChair = activePersonId && assignedPersonId === activePersonId;
const assigned = Boolean(assignedPersonId);
const baseWidth = chair.kind === "block" ? 1.45 : 1.35;
ctx.strokeStyle = activePersonChair
? "rgba(234, 179, 8, 1)"
: assigned
? "rgba(37, 99, 235, 0.98)"
: selected
? "rgba(220, 38, 38, 0.98)"
: active
? "rgba(15, 118, 110, 0.98)"
: chair.kind === "group"
? "rgba(16, 134, 149, 0.74)"
: "rgba(21, 149, 142, 0.8)";
ctx.lineWidth = (activePersonChair ? 2.8 : assigned ? 2.4 : selected ? 2.6 : active ? 2.1 : baseWidth) / camera.scale;
ctx.stroke(chair.path);
}
ctx.restore();
scaleChip.textContent = `scale ${camera.scale.toFixed(4)}x`;
renderTooltip();
}
function persistPlaced() {
localStorage.setItem(STORAGE_KEY, JSON.stringify([...placed]));
}
canvas.addEventListener("pointerdown", (event) => {
dragging = true;
dragStart = { x: event.clientX, y: event.clientY, offsetX: camera.offsetX, offsetY: camera.offsetY };
canvas.classList.add("dragging");
});
window.addEventListener("pointerup", (event) => {
if (dragging && dragStart) {
const move = Math.hypot(event.clientX - dragStart.x, event.clientY - dragStart.y);
if (move < 4) {
const rect = canvas.getBoundingClientRect();
const picked = pickChair(event.clientX - rect.left, event.clientY - rect.top);
if (picked) {
if (placed.has(picked.key)) placed.delete(picked.key);
else placed.add(picked.key);
persistPlaced();
if (activePersonId) {
const currentChair = getChairByPerson(activePersonId);
if (chairAssignments[picked.key] === activePersonId) {
delete chairAssignments[picked.key];
} else {
if (currentChair && currentChair !== picked.key) delete chairAssignments[currentChair];
chairAssignments[picked.key] = activePersonId;
}
persistAssignments();
renderPeopleList();
}
}
}
}
dragging = false;
dragStart = null;
canvas.classList.remove("dragging");
requestDraw();
});
window.addEventListener("pointermove", (event) => {
const rect = canvas.getBoundingClientRect();
pointer = { x: event.clientX - rect.left, y: event.clientY - rect.top };
if (dragging && dragStart) {
camera.offsetX = dragStart.offsetX + (event.clientX - dragStart.x);
camera.offsetY = dragStart.offsetY + (event.clientY - dragStart.y);
}
requestDraw();
});
canvas.addEventListener("wheel", (event) => {
event.preventDefault();
const rect = canvas.getBoundingClientRect();
const mx = event.clientX - rect.left;
const my = event.clientY - rect.top;
const before = screenToWorld(mx, my);
const factor = event.deltaY < 0 ? 1.08 : 0.92;
camera.scale = Math.max(0.002, Math.min(2, camera.scale * factor));
const after = worldToScreen(before.x, before.y);
camera.offsetX += mx - after.x;
camera.offsetY += my - after.y;
requestDraw();
}, { passive: false });
document.getElementById("fit-btn").addEventListener("click", fit);
document.getElementById("clear-btn").addEventListener("click", () => {
placed.clear();
persistPlaced();
requestDraw();
});
clearAssignBtn.addEventListener("click", () => {
chairAssignments = {};
persistAssignments();
renderPeopleList();
requestDraw();
});
orgChartEl.addEventListener("click", (event) => {
const item = event.target.closest(".org-person[data-person-id]");
if (!item) return;
const personId = item.getAttribute("data-person-id");
activePersonId = personId === activePersonId ? null : personId;
persistActivePerson();
renderPeopleList();
requestDraw();
});
window.addEventListener("resize", resize);
renderPeopleList();
resize();
</script>
</body>
</html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

154
scripts/sync_prod_db_to_dev.sh Executable file
View File

@@ -0,0 +1,154 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PROD_DIR="${ROOT_DIR}"
DEV_DIR="${DEV_DIR:-/tmp/mh-dashboard-organization-dev}"
SCOPE="${1:-minimal}"
if [[ ! -f "${PROD_DIR}/docker-compose.yml" ]]; then
echo "Production workspace not found: ${PROD_DIR}" >&2
exit 1
fi
if [[ ! -f "${DEV_DIR}/docker-compose.yml" ]]; then
echo "Development workspace not found: ${DEV_DIR}" >&2
echo "Set DEV_DIR=/path/to/dev-copy if the dev workspace moved." >&2
exit 1
fi
case "${SCOPE}" in
minimal)
TABLES=(
member_aliases
member_overrides
member_retirements
members
seat_maps
seat_slots
)
;;
full)
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
member_aliases
member_overrides
member_retirements
members
seat_maps
seat_slots
)
;;
*)
echo "Usage: $0 [minimal|full]" >&2
exit 1
;;
esac
PROD_COMPOSE=(docker compose --project-directory "${PROD_DIR}")
DEV_COMPOSE=(docker compose --project-directory "${DEV_DIR}")
require_service() {
local dir="$1"
shift
(cd "${dir}" && "$@") >/dev/null
}
echo "[1/6] Checking source and target stacks"
require_service "${PROD_DIR}" "${PROD_COMPOSE[@]}" ps
require_service "${DEV_DIR}" "${DEV_COMPOSE[@]}" ps
echo "[2/6] Ensuring db containers are reachable"
(cd "${PROD_DIR}" && "${PROD_COMPOSE[@]}" exec -T db pg_isready -U "${POSTGRES_USER:-orgapp}" -d "${POSTGRES_DB:-orgdb}") >/dev/null
(cd "${DEV_DIR}" && "${DEV_COMPOSE[@]}" exec -T db pg_isready -U "${POSTGRES_USER:-orgapp}" -d "${POSTGRES_DB:-orgdb}") >/dev/null
WORK_DIR="$(mktemp -d)"
cleanup() {
rm -rf "${WORK_DIR}"
}
trap cleanup EXIT
DUMP_FILE="${WORK_DIR}/prod_to_dev_${SCOPE}.sql"
TRUNCATE_FILE="${WORK_DIR}/truncate_${SCOPE}.sql"
SEAT_POSITIONS_FILE="${WORK_DIR}/seat_positions.csv"
echo "[3/6] Building truncate script for ${SCOPE} scope"
{
echo "BEGIN;"
echo "SET session_replication_role = replica;"
printf 'TRUNCATE TABLE %s RESTART IDENTITY CASCADE;\n' "$(IFS=,; echo "${TABLES[*]}")"
echo "SET session_replication_role = DEFAULT;"
echo "COMMIT;"
} > "${TRUNCATE_FILE}"
echo "[4/6] Dumping ${SCOPE} data from 8080 source DB"
TABLE_ARGS=()
for table in "${TABLES[@]}"; do
TABLE_ARGS+=(-t "public.${table}")
done
(cd "${PROD_DIR}" && "${PROD_COMPOSE[@]}" exec -T db \
pg_dump -U "${POSTGRES_USER:-orgapp}" -d "${POSTGRES_DB:-orgdb}" \
--data-only --column-inserts --disable-triggers --no-owner --no-privileges \
"${TABLE_ARGS[@]}") > "${DUMP_FILE}"
echo "[4.5/6] Exporting seat_positions in portable format"
(cd "${PROD_DIR}" && "${PROD_COMPOSE[@]}" exec -T db \
psql -At -F ',' -U "${POSTGRES_USER:-orgapp}" -d "${POSTGRES_DB:-orgdb}" \
-c "COPY (
SELECT member_id, seat_map_id, seat_slot_id, row_index, col_index, seat_label, updated_at
FROM public.seat_positions
ORDER BY member_id
) TO STDOUT WITH CSV") > "${SEAT_POSITIONS_FILE}"
echo "[5/6] Truncating target tables in 8081 dev DB"
(cd "${DEV_DIR}" && "${DEV_COMPOSE[@]}" exec -T db \
psql -q -v ON_ERROR_STOP=1 -U "${POSTGRES_USER:-orgapp}" -d "${POSTGRES_DB:-orgdb}") < "${TRUNCATE_FILE}"
echo "[6/6] Restoring dumped data into 8081 dev DB"
(cd "${DEV_DIR}" && "${DEV_COMPOSE[@]}" exec -T db \
psql -q -v ON_ERROR_STOP=1 -U "${POSTGRES_USER:-orgapp}" -d "${POSTGRES_DB:-orgdb}") < "${DUMP_FILE}"
echo "[6.5/6] Restoring portable seat_positions and rebuilding auth users"
(cd "${DEV_DIR}" && "${DEV_COMPOSE[@]}" exec -T db \
psql -q -v ON_ERROR_STOP=1 -U "${POSTGRES_USER:-orgapp}" -d "${POSTGRES_DB:-orgdb}" \
-c "DELETE FROM public.seat_positions")
(cd "${DEV_DIR}" && "${DEV_COMPOSE[@]}" exec -T db \
psql -q -v ON_ERROR_STOP=1 -U "${POSTGRES_USER:-orgapp}" -d "${POSTGRES_DB:-orgdb}" \
-c "COPY public.seat_positions (member_id, seat_map_id, seat_slot_id, row_index, col_index, seat_label, updated_at) FROM STDIN WITH CSV") < "${SEAT_POSITIONS_FILE}"
(cd "${DEV_DIR}" && "${DEV_COMPOSE[@]}" exec -T backend python - <<'PY'
from backend.app.main import get_conn, sync_auth_users_from_members
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute("UPDATE members SET seat_label = ''")
cur.execute(
"""
UPDATE members AS m
SET seat_label = sp.seat_label
FROM seat_positions AS sp
WHERE sp.member_id = m.id
"""
)
sync_auth_users_from_members(cur)
conn.commit()
print("members, seat labels, and auth users synced")
PY
)
echo
echo "Sync complete."
echo "Source: ${PROD_DIR} (8080)"
echo "Target: ${DEV_DIR} (8081)"
echo "Scope : ${SCOPE}"