Add multi-office seat maps and dev/prod DB sync protocol
This commit is contained in:
@@ -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}
|
||||||
|
|||||||
182
docs/DEV_PROD_DB_PROTOCOL.md
Normal file
182
docs/DEV_PROD_DB_PROTOCOL.md
Normal 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 상태를 유지해야 하면 별도 백업 후 실행한다
|
||||||
@@ -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 기준 동기화가 필요함
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|
||||||
그리고 먼저 현재 외부 접속과 자리배치 저장이 정상인지 확인한 뒤 다음 기능 개발로 넘어간다.
|
그리고 먼저 현재 외부 접속, 자리배치 저장, 실제 로그인 동작을 확인한 뒤 다음 기능 개발로 넘어간다.
|
||||||
|
|||||||
@@ -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 || "로그인에 실패했습니다.";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -1316,8 +1316,9 @@
|
|||||||
</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="tdc">TDC</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>
|
||||||
</div>
|
</div>
|
||||||
<div id="mh-inline-filters">
|
<div id="mh-inline-filters">
|
||||||
<div id="mh-inline-search" class="relative">
|
<div id="mh-inline-search" class="relative">
|
||||||
@@ -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(['총괄', '영업']);
|
||||||
@@ -1621,10 +1625,18 @@
|
|||||||
if (scope === 'tdc') return !GPD_TEAM_DIVISIONS.has(division);
|
if (scope === 'tdc') return !GPD_TEAM_DIVISIONS.has(division);
|
||||||
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 buildScopedPeopleData = (rows) => {
|
const getTeamScope = (teamName) => {
|
||||||
const seen = new Set();
|
const normalizedTeam = String(teamName || '').trim();
|
||||||
return rows.map(r => ({
|
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 seen = new Set();
|
||||||
|
return rows.map(r => ({
|
||||||
name: String(r[columnMap.name] || '').trim(),
|
name: String(r[columnMap.name] || '').trim(),
|
||||||
team: String(r[columnMap.team] || '').trim()
|
team: String(r[columnMap.team] || '').trim()
|
||||||
})).filter(p => {
|
})).filter(p => {
|
||||||
@@ -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 {
|
||||||
@@ -2327,24 +2346,30 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// --- 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);
|
||||||
});
|
});
|
||||||
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);
|
||||||
}
|
}
|
||||||
@@ -2436,12 +2455,15 @@
|
|||||||
|
|
||||||
const teamSelect = document.getElementById('team-select');
|
const teamSelect = document.getElementById('team-select');
|
||||||
const personSelect = document.getElementById('person-select');
|
const personSelect = document.getElementById('person-select');
|
||||||
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();
|
|
||||||
allPeopleData = buildScopedPeopleData(scopedRows);
|
allTeams = [...new Set(scopedRows.map(r => String(r[columnMap.team] || '').trim()))].filter(Boolean).sort();
|
||||||
|
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,10 +2485,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
function initTeamTabAfterUpload() {
|
function initTeamTabAfterUpload() {
|
||||||
@@ -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)
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -2588,23 +2612,17 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
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 = "";
|
||||||
|
} else {
|
||||||
personSelect.value = "";
|
setScope(getTeamScope(item.dataset.team), false);
|
||||||
|
teamSelect.value = item.dataset.team;
|
||||||
} else {
|
updateFilters();
|
||||||
|
personSelect.value = item.dataset.name;
|
||||||
teamSelect.value = item.dataset.team;
|
}
|
||||||
|
|
||||||
updateFilters();
|
|
||||||
|
|
||||||
personSelect.value = item.dataset.name;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
mainSearchInput.value = "";
|
mainSearchInput.value = "";
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
931
incoming-files/seat/center_chair_people_map.html
Normal file
931
incoming-files/seat/center_chair_people_map.html
Normal 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>
|
||||||
932
incoming-files/seat/center_chair_people_map_6f.html
Normal file
932
incoming-files/seat/center_chair_people_map_6f.html
Normal 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>
|
||||||
|
|
||||||
932
incoming-files/seat/center_chair_people_map_7f.html
Normal file
932
incoming-files/seat/center_chair_people_map_7f.html
Normal 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>
|
||||||
|
|
||||||
1
incoming-files/seat/center_chair_people_payload.js
Normal file
1
incoming-files/seat/center_chair_people_payload.js
Normal file
File diff suppressed because one or more lines are too long
1
incoming-files/seat/center_chair_people_payload_6f.js
Normal file
1
incoming-files/seat/center_chair_people_payload_6f.js
Normal file
File diff suppressed because one or more lines are too long
1
incoming-files/seat/center_chair_people_payload_7f.js
Normal file
1
incoming-files/seat/center_chair_people_payload_7f.js
Normal file
File diff suppressed because one or more lines are too long
154
scripts/sync_prod_db_to_dev.sh
Executable file
154
scripts/sync_prod_db_to_dev.sh
Executable 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}"
|
||||||
Reference in New Issue
Block a user