backup: save fixed office seatmap snapshot

This commit is contained in:
hyunho
2026-03-26 09:42:25 +09:00
parent e62a6a5458
commit 6f5e61ca1a
15 changed files with 2042 additions and 224 deletions

BIN
1.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 395 KiB

BIN
2.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 419 KiB

BIN
3.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 259 KiB

BIN
5.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 250 KiB

BIN
6.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 530 KiB

BIN
7.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 KiB

File diff suppressed because one or more lines are too long

View File

@@ -1,18 +1,22 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime
from pathlib import Path
import csv import csv
import base64
from datetime import datetime
from io import BytesIO, StringIO from io import BytesIO, StringIO
import json
import math import math
from pathlib import Path
import re
import shutil import shutil
import struct
import uuid import uuid
import ezdxf import ezdxf
from ezdxf import recover from ezdxf import recover
from fastapi import FastAPI, File, Form, HTTPException, UploadFile from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse from fastapi.responses import FileResponse, HTMLResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from openpyxl import load_workbook from openpyxl import load_workbook
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -32,6 +36,10 @@ app.add_middleware(
) )
LEGACY_STATIC_DIR = LEGACY_DIR / "static" LEGACY_STATIC_DIR = LEGACY_DIR / "static"
FIXED_OFFICE_SOURCE_KEY = "technical-development-center"
FIXED_OFFICE_NAME = "기술개발센터"
FIXED_OFFICE_TEMPLATE_PATH = Path(__file__).with_name("center_chair_viewer_template.html")
_fixed_office_cache: dict[str, object] | None = None
class MemberPayload(BaseModel): class MemberPayload(BaseModel):
@@ -239,6 +247,112 @@ 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]:
template = parse_fixed_office_template()
slots = template["slots"]
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT id
FROM seat_maps
WHERE source_type = 'fixed_html'
AND source_url = %s
LIMIT 1
""",
(FIXED_OFFICE_SOURCE_KEY,),
)
row = cur.fetchone()
if activate:
cur.execute("UPDATE seat_maps SET is_active = FALSE, updated_at = NOW() WHERE is_active = TRUE")
if row is None:
cur.execute(
"""
INSERT INTO seat_maps (
name, image_url, source_type, source_url, preview_svg,
view_box_min_x, view_box_min_y, view_box_width, view_box_height,
image_width, image_height, grid_rows, grid_cols, cell_gap, is_active
)
VALUES (%s, '', 'fixed_html', %s, '', NULL, NULL, NULL, NULL, NULL, NULL, 1, 1, 0, %s)
RETURNING id
""",
(FIXED_OFFICE_NAME, FIXED_OFFICE_SOURCE_KEY, activate),
)
seat_map_id = int(cur.fetchone()["id"])
else:
seat_map_id = int(row["id"])
cur.execute(
"""
UPDATE seat_maps
SET name = %s,
source_type = 'fixed_html',
source_url = %s,
image_url = '',
preview_svg = '',
grid_rows = 1,
grid_cols = 1,
cell_gap = 0,
is_active = %s,
updated_at = NOW()
WHERE id = %s
""",
(FIXED_OFFICE_NAME, FIXED_OFFICE_SOURCE_KEY, activate, seat_map_id),
)
cur.execute("SELECT id, slot_key FROM seat_slots WHERE seat_map_id = %s", (seat_map_id,))
existing_slots = {str(item["slot_key"]): int(item["id"]) for item in cur.fetchall()}
incoming_keys = {str(slot["slot_key"]) for slot in slots}
for slot in slots:
slot_key = str(slot["slot_key"])
if slot_key in existing_slots:
cur.execute(
"""
UPDATE seat_slots
SET label = %s, x = %s, y = %s, rotation = %s, layer_name = %s, updated_at = NOW()
WHERE seat_map_id = %s AND slot_key = %s
""",
(
slot["label"],
slot["x"],
slot["y"],
slot["rotation"],
slot["layer_name"],
seat_map_id,
slot_key,
),
)
else:
cur.execute(
"""
INSERT INTO seat_slots (seat_map_id, slot_key, label, x, y, rotation, layer_name)
VALUES (%s, %s, %s, %s, %s, %s, %s)
""",
(
seat_map_id,
slot_key,
slot["label"],
slot["x"],
slot["y"],
slot["rotation"],
slot["layer_name"],
),
)
if existing_slots:
stale_keys = [key for key in existing_slots if key not in incoming_keys]
if stale_keys:
cur.execute(
"DELETE FROM seat_slots WHERE seat_map_id = %s AND slot_key = ANY(%s)",
(seat_map_id, stale_keys),
)
conn.commit()
seat_map = fetch_seat_map(seat_map_id)
if seat_map is None:
raise HTTPException(status_code=500, detail="Fixed office seat map initialization failed.")
return seat_map
def compute_seat_label(row_index: int, col_index: int) -> str: def compute_seat_label(row_index: int, col_index: int) -> str:
quotient = row_index quotient = row_index
row_label = "" row_label = ""
@@ -255,6 +369,63 @@ def compute_slot_label(index: int) -> str:
return f"CHAIR-{index + 1:03d}" return f"CHAIR-{index + 1:03d}"
def decode_segment_values(raw_base64: str) -> list[int]:
decoded = base64.b64decode(raw_base64.encode("ascii"))
if not decoded:
return []
return [item[0] for item in struct.iter_unpack("<i", decoded)]
def parse_fixed_office_template() -> dict[str, object]:
global _fixed_office_cache
if _fixed_office_cache is not None:
return _fixed_office_cache
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")
match = re.search(r"const DATA = (\{.*?\});\n\s*function decodeSegments", html, flags=re.S)
if not match:
raise HTTPException(status_code=500, detail="Fixed office viewer data not found.")
data = json.loads(match.group(1))
chair_values = decode_segment_values(str(data["chairSegsB64"]))
slots: list[dict[str, object]] = []
for index, chair in enumerate(data["chairs"]):
slot_key, name, _kind, start, count = chair
min_x = math.inf
min_y = math.inf
max_x = -math.inf
max_y = -math.inf
start_index = int(start)
end_index = start_index + int(count)
for item_index in range(start_index, end_index):
offset = item_index * 4
x1 = chair_values[offset] / 10
y1 = chair_values[offset + 1] / 10
x2 = chair_values[offset + 2] / 10
y2 = chair_values[offset + 3] / 10
min_x = min(min_x, x1, x2)
min_y = min(min_y, y1, y2)
max_x = max(max_x, x1, x2)
max_y = max(max_y, y1, y2)
slots.append(
{
"slot_key": str(slot_key),
"label": str(slot_key),
"x": round((min_x + max_x) / 2, 3),
"y": round((min_y + max_y) / 2, 3),
"rotation": 0.0,
"layer_name": str(name),
}
)
_fixed_office_cache = {
"html": html,
"data": data,
"slots": slots,
}
return _fixed_office_cache
def is_chair_layer(layer_name: str) -> bool: def is_chair_layer(layer_name: str) -> bool:
raw = layer_name.strip().lower() raw = layer_name.strip().lower()
compact = raw.replace("-", "").replace("_", "").replace(" ", "") compact = raw.replace("-", "").replace("_", "").replace(" ", "")
@@ -515,12 +686,13 @@ def build_dxf_preview_svg(
) )
def parse_dxf_layout(file_path: Path) -> tuple[dict[str, object], list[dict[str, object]]]: def load_dxf_document(file_path: Path) -> ezdxf.document.Drawing:
try: try:
document = ezdxf.readfile(file_path) return ezdxf.readfile(file_path)
except OSError: except OSError:
try: try:
document, _ = recover.readfile(file_path) document, _ = recover.readfile(file_path)
return document
except Exception as exc: except Exception as exc:
kind, preview = inspect_dxf_header(file_path) kind, preview = inspect_dxf_header(file_path)
if kind == "binary_dxf": if kind == "binary_dxf":
@@ -538,10 +710,65 @@ def parse_dxf_layout(file_path: Path) -> tuple[dict[str, object], list[dict[str,
status_code=400, status_code=400,
detail=f"ASCII DXF로 보이지만 구조를 해석하지 못했습니다. 도면을 다른 DXF 버전으로 다시 저장해보세요. 헤더={preview}", detail=f"ASCII DXF로 보이지만 구조를 해석하지 못했습니다. 도면을 다른 DXF 버전으로 다시 저장해보세요. 헤더={preview}",
) from exc ) from exc
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail=f"업로드한 파일 형식을 판별하지 못했습니다. 확장자만 dxf인 파일일 수 있습니다. 헤더={preview}", detail=f"업로드한 파일 형식을 판별하지 못했습니다. 확장자만 dxf인 파일일 수 있습니다. 헤더={preview}",
) from exc ) from exc
def entity_to_segments(entity: ezdxf.entities.DXFGraphic, arc_steps: int = 24) -> list[tuple[float, float, float, float]]:
entity_type = entity.dxftype()
points = get_entity_points(entity)
if entity_type in {"LINE", "LWPOLYLINE", "POLYLINE", "SPLINE", "ELLIPSE"} and len(points) >= 2:
return [
(float(left[0]), float(left[1]), float(right[0]), float(right[1]))
for left, right in zip(points[:-1], points[1:])
]
if entity_type == "CIRCLE":
center = entity.dxf.center
radius = float(entity.dxf.radius)
samples = []
for index in range(arc_steps + 1):
angle = (math.tau * index) / arc_steps
samples.append(
(
float(center.x) + radius * math.cos(angle),
float(center.y) + radius * math.sin(angle),
)
)
return [
(float(left[0]), float(left[1]), float(right[0]), float(right[1]))
for left, right in zip(samples[:-1], samples[1:])
]
if entity_type == "ARC":
center = entity.dxf.center
radius = float(entity.dxf.radius)
start_angle = math.radians(float(entity.dxf.start_angle))
end_angle = math.radians(float(entity.dxf.end_angle))
if end_angle <= start_angle:
end_angle += math.tau
samples = []
for index in range(arc_steps + 1):
ratio = index / arc_steps
angle = start_angle + (end_angle - start_angle) * ratio
samples.append(
(
float(center.x) + radius * math.cos(angle),
float(center.y) + radius * math.sin(angle),
)
)
return [
(float(left[0]), float(left[1]), float(right[0]), float(right[1]))
for left, right in zip(samples[:-1], samples[1:])
]
return []
def build_dxf_artifacts(file_path: Path) -> tuple[dict[str, object], list[dict[str, object]], dict[str, object]]:
document = load_dxf_document(file_path)
modelspace = document.modelspace() modelspace = document.modelspace()
base_entities = [entity for entity in modelspace if entity.dxftype() in {"LINE", "LWPOLYLINE", "POLYLINE", "CIRCLE", "ARC", "INSERT", "SPLINE", "ELLIPSE"}] base_entities = [entity for entity in modelspace if entity.dxftype() in {"LINE", "LWPOLYLINE", "POLYLINE", "CIRCLE", "ARC", "INSERT", "SPLINE", "ELLIPSE"}]
all_entities: list[ezdxf.entities.DXFGraphic] = [] all_entities: list[ezdxf.entities.DXFGraphic] = []
@@ -615,6 +842,73 @@ def parse_dxf_layout(file_path: Path) -> tuple[dict[str, object], list[dict[str,
"image_height": None, "image_height": None,
"cell_gap": 0, "cell_gap": 0,
} }
slot_map = {str(slot["slot_key"]): slot for slot in slots}
chair_segments: list[list[float]] = []
chair_items: list[dict[str, object]] = []
background_segments: list[list[float]] = []
for entity in visible_entities:
segments = entity_to_segments(entity)
if not segments:
continue
if is_chair_layer(entity.dxf.layer):
slot = slot_map.get(str(entity.dxf.handle))
if not slot:
continue
start_index = len(chair_segments)
min_seg_x = math.inf
min_seg_y = math.inf
max_seg_x = -math.inf
max_seg_y = -math.inf
for x1, y1, x2, y2 in segments:
chair_segments.append([round(x1, 3), round(y1, 3), round(x2, 3), round(y2, 3)])
min_seg_x = min(min_seg_x, x1, x2)
min_seg_y = min(min_seg_y, y1, y2)
max_seg_x = max(max_seg_x, x1, x2)
max_seg_y = max(max_seg_y, y1, y2)
chair_items.append(
{
"key": str(slot["slot_key"]),
"label": slot["label"],
"kind": "chair",
"start": start_index,
"count": len(segments),
"min_x": round(min_seg_x, 3),
"min_y": round(min_seg_y, 3),
"max_x": round(max_seg_x, 3),
"max_y": round(max_seg_y, 3),
}
)
continue
for x1, y1, x2, y2 in segments:
background_segments.append([round(x1, 3), round(y1, 3), round(x2, 3), round(y2, 3)])
viewer_data = {
"meta": {
"background_segment_count": len(background_segments),
"chair_count": len(chair_items),
"chair_segment_count": len(chair_segments),
"world": {
"min_x": round(min_x, 3),
"min_y": round(min_y, 3),
"max_x": round(min_x + width, 3),
"max_y": round(min_y + height, 3),
"width": round(width, 3),
"height": round(height, 3),
},
},
"background_segments": background_segments,
"chair_segments": chair_segments,
"chairs": chair_items,
}
return metadata, slots, viewer_data
def parse_dxf_layout(file_path: Path) -> tuple[dict[str, object], list[dict[str, object]]]:
metadata, slots, _viewer_data = build_dxf_artifacts(file_path)
return metadata, slots return metadata, slots
@@ -660,14 +954,101 @@ def fetch_seat_layout(seat_map_id: int) -> dict[str, object]:
(seat_map_id,), (seat_map_id,),
) )
placements = cur.fetchall() placements = cur.fetchall()
viewer_data: dict[str, object] | None = None
if seat_map["source_type"] == "fixed_html" and seat_map.get("source_url") == FIXED_OFFICE_SOURCE_KEY:
template = parse_fixed_office_template()
viewer_data = {
"meta": {
"chair_count": len(template["slots"]),
"office": FIXED_OFFICE_NAME,
}
}
elif seat_map["source_type"] == "dxf" and seat_map.get("source_url"):
filename = Path(str(seat_map["source_url"])).name
source_path = UPLOAD_DIR / filename
if source_path.exists():
try:
_metadata, _slots, viewer_data = build_dxf_artifacts(source_path)
except Exception:
viewer_data = None
return { return {
"seat_map": seat_map, "seat_map": seat_map,
"members": members, "members": members,
"slots": slots, "slots": slots,
"placements": placements, "placements": placements,
"viewer_data": viewer_data,
} }
def build_center_chair_viewer_html(layout: dict[str, object]) -> str:
slot_key_by_id = {
int(slot["id"]): str(slot["slot_key"])
for slot in layout.get("slots", [])
if slot.get("id") is not None and slot.get("slot_key") is not None
}
placed_keys: list[str] = []
for placement in layout.get("placements", []):
slot_id = placement.get("seat_slot_id")
if slot_id is None:
continue
slot_key = slot_key_by_id.get(int(slot_id))
if slot_key:
placed_keys.append(slot_key)
seat_map = layout.get("seat_map") or {}
placed_literal = json.dumps(sorted(set(placed_keys)), ensure_ascii=False, separators=(",", ":"))
if seat_map.get("source_type") == "fixed_html":
html = parse_fixed_office_template()["html"]
else:
viewer_data = layout.get("viewer_data")
if not isinstance(viewer_data, dict):
raise HTTPException(status_code=404, detail="DXF viewer data not found.")
template_path = Path(__file__).with_name("center_chair_viewer_template.html")
if not template_path.exists():
raise HTTPException(status_code=500, detail="Viewer template not found.")
html = template_path.read_text(encoding="utf-8")
data_literal = json.dumps(viewer_data, ensure_ascii=False, separators=(",", ":"))
html = re.sub(
r"const DATA = .*?;\n\s*function decodeSegments",
f"const DATA = {data_literal};\n function decodeSegments",
html,
count=1,
flags=re.S,
)
html = html.replace(
'const STORAGE_KEY = "ptc-chair-selection";\n const placed = new Set(JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]"));',
f"const STORAGE_KEY = null;\n const placed = new Set({placed_literal});",
1,
)
html = html.replace(
"function persistPlaced() {\n localStorage.setItem(STORAGE_KEY, JSON.stringify([...placed]));\n }",
"function persistPlaced() {\n return;\n }",
1,
)
bridge_script = """
<script>
window.__mhSeatmap = {
getCanvas() { return document.getElementById("canvas"); },
pickChairAt(x, y) { return typeof pickChair === "function" ? pickChair(x, y) : null; },
setPlaced(keys) {
placed.clear();
(keys || []).forEach((key) => placed.add(String(key)));
if (typeof requestDraw === "function") requestDraw();
}
};
window.addEventListener("message", (event) => {
const data = event.data;
if (!data || typeof data !== "object") return;
if (data.type === "seatmap-set-placed") {
window.__mhSeatmap.setPlaced(Array.isArray(data.keys) ? data.keys : []);
}
});
</script>
"""
html = html.replace("</body>", f"{bridge_script}\n</body>", 1)
return html
def save_seat_layout(seat_map_id: int, payload: SeatLayoutPayload) -> list[dict[str, object]]: def save_seat_layout(seat_map_id: int, payload: SeatLayoutPayload) -> list[dict[str, object]]:
seat_map = fetch_seat_map(seat_map_id) seat_map = fetch_seat_map(seat_map_id)
if seat_map is None: if seat_map is None:
@@ -676,11 +1057,11 @@ def save_seat_layout(seat_map_id: int, payload: SeatLayoutPayload) -> list[dict[
member_ids: list[int] = [] member_ids: list[int] = []
occupied_cells: set[tuple[int, int]] = set() occupied_cells: set[tuple[int, int]] = set()
occupied_slots: set[int] = set() occupied_slots: set[int] = set()
is_dxf = seat_map["source_type"] == "dxf" requires_slot = seat_map["source_type"] in {"dxf", "fixed_html"}
for item in payload.placements: for item in payload.placements:
if is_dxf: if requires_slot:
if item.seat_slot_id is None: if item.seat_slot_id is None:
raise HTTPException(status_code=400, detail="DXF 자리배치도는 seat_slot_id가 필요합니다.") raise HTTPException(status_code=400, detail="고정 도면 자리배치도는 seat_slot_id가 필요합니다.")
if item.seat_slot_id in occupied_slots: if item.seat_slot_id in occupied_slots:
raise HTTPException(status_code=400, detail="같은 좌석에 둘 이상의 구성원을 배치할 수 없습니다.") raise HTTPException(status_code=400, detail="같은 좌석에 둘 이상의 구성원을 배치할 수 없습니다.")
occupied_slots.add(item.seat_slot_id) occupied_slots.add(item.seat_slot_id)
@@ -705,7 +1086,7 @@ def save_seat_layout(seat_map_id: int, payload: SeatLayoutPayload) -> list[dict[
if missing_ids: if missing_ids:
raise HTTPException(status_code=400, detail=f"존재하지 않는 구성원 ID가 포함되어 있습니다: {missing_ids}") raise HTTPException(status_code=400, detail=f"존재하지 않는 구성원 ID가 포함되어 있습니다: {missing_ids}")
if is_dxf: if requires_slot:
slot_ids = sorted(occupied_slots) slot_ids = sorted(occupied_slots)
cur.execute("SELECT id FROM seat_slots WHERE seat_map_id = %s AND id = ANY(%s)", (seat_map_id, slot_ids)) cur.execute("SELECT id FROM seat_slots WHERE seat_map_id = %s AND id = ANY(%s)", (seat_map_id, slot_ids))
existing_slot_ids = {int(row["id"]) for row in cur.fetchall()} existing_slot_ids = {int(row["id"]) for row in cur.fetchall()}
@@ -721,7 +1102,7 @@ def save_seat_layout(seat_map_id: int, payload: SeatLayoutPayload) -> list[dict[
for item in payload.placements: for item in payload.placements:
seat_label = item.seat_label.strip() or ( seat_label = item.seat_label.strip() or (
slot_label_map.get(int(item.seat_slot_id), f"SLOT-{item.seat_slot_id}") slot_label_map.get(int(item.seat_slot_id), f"SLOT-{item.seat_slot_id}")
if is_dxf and item.seat_slot_id is not None if requires_slot and item.seat_slot_id is not None
else compute_seat_label(item.row_index, item.col_index) else compute_seat_label(item.row_index, item.col_index)
) )
cur.execute( cur.execute(
@@ -739,7 +1120,7 @@ def save_seat_layout(seat_map_id: int, payload: SeatLayoutPayload) -> list[dict[
( (
item.member_id, item.member_id,
seat_map_id, seat_map_id,
item.seat_slot_id if is_dxf else None, item.seat_slot_id if requires_slot else None,
item.row_index, item.row_index,
item.col_index, item.col_index,
seat_label, seat_label,
@@ -1076,7 +1457,7 @@ 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() -> dict[str, dict[str, object]]:
seat_map = fetch_active_seat_map() seat_map = ensure_fixed_office_seat_map(activate=True)
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}
@@ -1166,6 +1547,15 @@ def get_seat_layout(seat_map_id: int) -> dict[str, object]:
return fetch_seat_layout(seat_map_id) return fetch_seat_layout(seat_map_id)
@app.get("/api/seat-maps/{seat_map_id}/viewer")
def get_seat_map_viewer(seat_map_id: int) -> HTMLResponse:
layout = fetch_seat_layout(seat_map_id)
seat_map = layout.get("seat_map") or {}
if seat_map.get("source_type") not in {"dxf", "fixed_html"}:
raise HTTPException(status_code=400, detail="Viewer is only available for supported seat maps.")
return HTMLResponse(build_center_chair_viewer_html(layout))
@app.put("/api/seat-maps/{seat_map_id}/layout") @app.put("/api/seat-maps/{seat_map_id}/layout")
def update_seat_layout(seat_map_id: int, payload: SeatLayoutPayload) -> dict[str, list[dict[str, object]]]: def update_seat_layout(seat_map_id: int, payload: SeatLayoutPayload) -> dict[str, list[dict[str, object]]]:
return {"items": save_seat_layout(seat_map_id, payload)} return {"items": save_seat_layout(seat_map_id, payload)}

File diff suppressed because one or more lines are too long

View File

@@ -31,6 +31,7 @@ const seatMapFormGap = document.getElementById("seatmap-form-gap");
const seatMapFormImage = document.getElementById("seatmap-form-image"); const seatMapFormImage = document.getElementById("seatmap-form-image");
const seatMapSearch = document.getElementById("seatmap-search"); const seatMapSearch = document.getElementById("seatmap-search");
const seatMapUnassigned = document.getElementById("seatmap-unassigned"); const seatMapUnassigned = document.getElementById("seatmap-unassigned");
const APP_BASE_URL = String(window.__MH_BASE_URL || "").replace(/\/$/, "");
const viewLabels = { const viewLabels = {
ledger: "사업관리대장", ledger: "사업관리대장",
@@ -60,6 +61,16 @@ const seatMapState = {
panStartY: 0, panStartY: 0,
panScrollLeft: 0, panScrollLeft: 0,
panScrollTop: 0, panScrollTop: 0,
hoveredSlotId: null,
viewerOffsetX: 0,
viewerOffsetY: 0,
viewerPointerX: 0,
viewerPointerY: 0,
viewerDragging: false,
viewerDragStartX: 0,
viewerDragStartY: 0,
viewerDragOffsetX: 0,
viewerDragOffsetY: 0,
}; };
let currentView = "organization"; let currentView = "organization";
@@ -101,6 +112,14 @@ function escapeHtml(value) {
.replaceAll("'", "&#39;"); .replaceAll("'", "&#39;");
} }
function resolveAppUrl(path) {
if (!path) return path;
if (/^https?:\/\//i.test(path)) return path;
if (!APP_BASE_URL) return path;
if (path.startsWith("/")) return `${APP_BASE_URL}${path}`;
return path;
}
function clonePlacements(items) { function clonePlacements(items) {
return items.map((item) => ({ return items.map((item) => ({
member_id: Number(item.member_id), member_id: Number(item.member_id),
@@ -149,7 +168,7 @@ function resetSeatMapDraft() {
} }
function clampSeatMapZoom(nextZoom) { function clampSeatMapZoom(nextZoom) {
return Math.min(3, Math.max(0.5, Number(nextZoom.toFixed(2)))); return Math.min(4, Math.max(0.35, Number(nextZoom.toFixed(2))));
} }
function setSeatMapZoom(nextZoom) { function setSeatMapZoom(nextZoom) {
@@ -157,6 +176,261 @@ function setSeatMapZoom(nextZoom) {
renderSeatMap(); renderSeatMap();
} }
function getDxfCanvasSize() {
return {
width: Math.max(960, seatMapBoardWrap?.clientWidth || seatMapBoard?.clientWidth || 960),
height: Math.max(680, seatMapBoardWrap?.clientHeight || seatMapBoard?.clientHeight || 680),
};
}
function centerSeatMapBoard() {
fitDxfSeatMapBoard();
}
function fitDxfSeatMapBoard() {
const viewerData = seatMapState.seatMap?.viewer_data;
if (!viewerData) return;
const world = viewerData.meta?.world;
const canvas = seatMapBoard?.querySelector("#seatmap-dxf-canvas");
if (!world || !canvas) return;
const rect = canvas.getBoundingClientRect();
const pad = 36;
const scaleX = (rect.width - pad * 2) / Math.max(Number(world.width || 1), 1);
const scaleY = (rect.height - pad * 2) / Math.max(Number(world.height || 1), 1);
seatMapState.zoom = clampSeatMapZoom(Math.min(scaleX, scaleY));
seatMapState.viewerOffsetX =
pad - Number(world.min_x) * seatMapState.zoom + (rect.width - pad * 2 - Number(world.width) * seatMapState.zoom) / 2;
seatMapState.viewerOffsetY =
pad - Number(world.min_y) * seatMapState.zoom + (rect.height - pad * 2 - Number(world.height) * seatMapState.zoom) / 2;
drawDxfCanvasViewer();
}
function zoomDxfSeatMapAtPoint(clientX, clientY, factor) {
const viewerData = seatMapState.seatMap?.viewer_data;
const world = viewerData?.meta?.world;
const canvas = seatMapBoard?.querySelector("#seatmap-dxf-canvas");
if (!viewerData || !world || !canvas) return;
const rect = canvas.getBoundingClientRect();
const mx = clientX - rect.left;
const my = clientY - rect.top;
const before = screenToWorld(mx, my, world);
const nextZoom = clampSeatMapZoom(seatMapState.zoom * factor);
if (nextZoom === seatMapState.zoom) return;
seatMapState.zoom = nextZoom;
const after = worldToScreen(before.x, before.y, world);
seatMapState.viewerOffsetX += mx - after.x;
seatMapState.viewerOffsetY += my - after.y;
drawDxfCanvasViewer();
}
function getHoveredSeatMapSlotMeta() {
if (seatMapState.hoveredSlotId == null) return null;
const slot = getSeatSlotMap().get(Number(seatMapState.hoveredSlotId));
if (!slot) return null;
const placement = getSlotPlacementMap().get(Number(slot.id));
const member = placement ? getMemberMap().get(Number(placement.member_id)) : null;
return {
label: slot.label || `SLOT-${slot.id}`,
memberName: member?.name || "",
};
}
function updateSeatMapViewerHoverChip() {
const chip = seatMapBoard?.querySelector("[data-seatmap-chip='hover']");
if (!chip) return;
const hoveredMeta = getHoveredSeatMapSlotMeta();
chip.textContent = hoveredMeta
? `hover ${hoveredMeta.label}${hoveredMeta.memberName ? ` · ${hoveredMeta.memberName}` : ""}`
: "hover none";
}
function worldToScreen(x, y, world) {
return {
x: x * seatMapState.zoom + seatMapState.viewerOffsetX,
y: (Number(world.max_y) - y + Number(world.min_y)) * seatMapState.zoom + seatMapState.viewerOffsetY,
};
}
function screenToWorld(x, y, world) {
return {
x: (x - seatMapState.viewerOffsetX) / seatMapState.zoom,
y: Number(world.max_y) + Number(world.min_y) - (y - seatMapState.viewerOffsetY) / seatMapState.zoom,
};
}
function pickViewerChair(screenX, screenY, viewerData) {
const world = viewerData.meta.world;
const threshold = 12;
let best = null;
for (const chair of viewerData.chairs || []) {
const min = worldToScreen(Number(chair.min_x), Number(chair.max_y), world);
const max = worldToScreen(Number(chair.max_x), Number(chair.min_y), world);
const left = Math.min(min.x, max.x) - threshold;
const right = Math.max(min.x, max.x) + threshold;
const top = Math.min(min.y, max.y) - threshold;
const bottom = Math.max(min.y, max.y) + threshold;
if (screenX < left || screenX > right || screenY < top || screenY > bottom) continue;
let dist = Infinity;
for (let index = Number(chair.start); index < Number(chair.start) + Number(chair.count); index += 1) {
const segment = viewerData.chair_segments[index];
if (!segment) continue;
const a = worldToScreen(Number(segment[0]), Number(segment[1]), world);
const b = worldToScreen(Number(segment[2]), Number(segment[3]), world);
const dx = b.x - a.x;
const dy = b.y - a.y;
const len2 = dx * dx + dy * dy;
let segDist;
if (len2 === 0) {
segDist = Math.hypot(screenX - a.x, screenY - a.y);
} else {
let t = ((screenX - a.x) * dx + (screenY - a.y) * dy) / len2;
t = Math.max(0, Math.min(1, t));
const px = a.x + t * dx;
const py = a.y + t * dy;
segDist = Math.hypot(screenX - px, screenY - py);
}
if (segDist < dist) dist = segDist;
if (dist <= threshold) break;
}
if (dist > threshold) continue;
if (!best || dist < best.dist) {
best = { chair, dist };
}
}
return best?.chair || null;
}
function drawViewerSegments(ctx, viewerData) {
const world = viewerData.meta.world;
const bgSegments = viewerData.background_segments || [];
const chairSegments = viewerData.chair_segments || [];
const placementMap = getSlotPlacementMap();
const slotByKey = new Map((seatMapState.slots || []).map((slot) => [String(slot.slot_key), slot]));
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
ctx.save();
ctx.strokeStyle = "rgba(21,35,48,0.10)";
ctx.lineWidth = 1;
for (let index = 0; index < bgSegments.length; index += 1) {
const segment = bgSegments[index];
const a = worldToScreen(Number(segment[0]), Number(segment[1]), world);
const b = worldToScreen(Number(segment[2]), Number(segment[3]), world);
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.stroke();
}
ctx.restore();
for (const chair of viewerData.chairs || []) {
const slot = slotByKey.get(String(chair.key));
const occupied = slot ? placementMap.has(Number(slot.id)) : false;
const hovered = slot && Number(slot.id) === seatMapState.hoveredSlotId;
ctx.save();
ctx.strokeStyle = occupied
? "rgba(220, 38, 38, 0.98)"
: hovered
? "rgba(15, 118, 110, 0.98)"
: "rgba(15, 118, 110, 0.82)";
ctx.lineWidth = occupied ? 2.8 : hovered ? 2.2 : 1.5;
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.beginPath();
for (let index = Number(chair.start); index < Number(chair.start) + Number(chair.count); index += 1) {
const segment = chairSegments[index];
if (!segment) continue;
const a = worldToScreen(Number(segment[0]), Number(segment[1]), world);
const b = worldToScreen(Number(segment[2]), Number(segment[3]), world);
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
}
ctx.stroke();
ctx.restore();
}
}
function drawDxfCanvasViewer() {
const canvas = seatMapBoard?.querySelector("#seatmap-dxf-canvas");
const viewerData = seatMapState.seatMap?.viewer_data;
if (!canvas || !viewerData) return;
const ctx = canvas.getContext("2d");
const 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);
drawViewerSegments(ctx, viewerData);
updateSeatMapViewerHoverChip();
}
function setupDxfCanvasViewer() {
const canvas = seatMapBoard?.querySelector("#seatmap-dxf-canvas");
const viewerData = seatMapState.seatMap?.viewer_data;
if (!canvas || !viewerData) return;
canvas.addEventListener("pointerdown", (event) => {
seatMapState.viewerDragging = true;
seatMapState.viewerDragStartX = event.clientX;
seatMapState.viewerDragStartY = event.clientY;
seatMapState.viewerDragOffsetX = seatMapState.viewerOffsetX;
seatMapState.viewerDragOffsetY = seatMapState.viewerOffsetY;
canvas.classList.add("dragging");
});
canvas.addEventListener("pointermove", (event) => {
const rect = canvas.getBoundingClientRect();
seatMapState.viewerPointerX = event.clientX - rect.left;
seatMapState.viewerPointerY = event.clientY - rect.top;
if (seatMapState.viewerDragging) {
seatMapState.viewerOffsetX = seatMapState.viewerDragOffsetX + (event.clientX - seatMapState.viewerDragStartX);
seatMapState.viewerOffsetY = seatMapState.viewerDragOffsetY + (event.clientY - seatMapState.viewerDragStartY);
drawDxfCanvasViewer();
return;
}
const chair = pickViewerChair(seatMapState.viewerPointerX, seatMapState.viewerPointerY, viewerData);
const slot = chair ? (seatMapState.slots || []).find((item) => String(item.slot_key) === String(chair.key)) : null;
const nextSlotId = slot ? Number(slot.id) : null;
if (nextSlotId !== seatMapState.hoveredSlotId) {
seatMapState.hoveredSlotId = nextSlotId;
drawDxfCanvasViewer();
}
});
canvas.addEventListener("pointerleave", () => {
seatMapState.hoveredSlotId = null;
drawDxfCanvasViewer();
});
canvas.addEventListener("pointerup", () => {
if (!seatMapState.viewerDragging) return;
seatMapState.viewerDragging = false;
canvas.classList.remove("dragging");
});
canvas.addEventListener("click", (event) => {
const rect = canvas.getBoundingClientRect();
const chair = pickViewerChair(event.clientX - rect.left, event.clientY - rect.top, viewerData);
const slot = chair ? (seatMapState.slots || []).find((item) => String(item.slot_key) === String(chair.key)) : null;
seatMapState.hoveredSlotId = slot ? Number(slot.id) : null;
drawDxfCanvasViewer();
});
canvas.addEventListener("wheel", (event) => {
event.preventDefault();
zoomDxfSeatMapAtPoint(event.clientX, event.clientY, event.deltaY < 0 ? 1.08 : 0.92);
}, { passive: false });
fitDxfSeatMapBoard();
}
function getSeatSlotMap() { function getSeatSlotMap() {
return new Map((seatMapState.slots || []).map((slot) => [Number(slot.id), slot])); return new Map((seatMapState.slots || []).map((slot) => [Number(slot.id), slot]));
} }
@@ -278,7 +552,7 @@ function renderUnassignedMemberCard(member, draggable) {
function renderSeatMapBoard() { function renderSeatMapBoard() {
if (!seatMapBoard || !seatMapState.seatMap) return; if (!seatMapBoard || !seatMapState.seatMap) return;
if (seatMapState.seatMap.source_type === "dxf") { if (seatMapState.seatMap.source_type === "dxf" || seatMapState.seatMap.source_type === "fixed_html") {
renderDxfSeatMapBoard(); renderDxfSeatMapBoard();
return; return;
} }
@@ -315,43 +589,77 @@ function renderSeatMapBoard() {
function renderDxfSeatMapBoard() { function renderDxfSeatMapBoard() {
if (!seatMapBoard || !seatMapState.seatMap) return; if (!seatMapBoard || !seatMapState.seatMap) return;
const viewerData = seatMapState.seatMap.viewer_data;
const memberMap = getMemberMap(); if (!viewerData) {
const placementMap = getSlotPlacementMap(); seatMapBoard.innerHTML = `<div class="seatmap-empty-card"><strong>DXF 뷰어 데이터를 준비하지 못했습니다.</strong></div>`;
const slots = Array.isArray(seatMapState.slots) ? seatMapState.slots : []; return;
const editable = seatMapState.editMode && isAdmin(); }
const minX = Number(seatMapState.seatMap.view_box_min_x || 0); const viewerUrl = resolveAppUrl(`/api/seat-maps/${seatMapState.seatMap.id}/viewer`);
const minY = Number(seatMapState.seatMap.view_box_min_y || 0);
const width = Number(seatMapState.seatMap.view_box_width || 1);
const height = Number(seatMapState.seatMap.view_box_height || 1);
const previewSvg = seatMapState.seatMap.preview_svg || "";
const slotHtml = slots
.map((slot) => {
const slotId = Number(slot.id);
const placement = placementMap.get(slotId);
const member = placement ? memberMap.get(Number(placement.member_id)) : null;
if (!member && !editable) {
return "";
}
const left = ((Number(slot.x) - minX) / width) * 100;
const top = (1 - (Number(slot.y) - minY) / height) * 100;
return `
<div class="seatmap-slot${placement ? " occupied" : ""}${editable ? " editable" : ""}${!member ? " empty" : ""}" data-slot-id="${slotId}" style="left:${left}%; top:${top}%;">
${member ? renderMemberCard(member, editable) : ""}
</div>
`;
})
.join("");
seatMapBoard.innerHTML = ` seatMapBoard.innerHTML = `
<div class="seatmap-dxf-canvas"> <div class="seatmap-dxf-frame-shell">
<div class="seatmap-dxf-stage" style="transform: scale(${seatMapState.zoom}); --seatmap-zoom:${seatMapState.zoom};"> <iframe
<div class="seatmap-dxf-preview">${previewSvg}</div> id="seatmap-dxf-frame"
<div class="seatmap-dxf-slots">${slotHtml}</div> class="seatmap-dxf-frame"
</div> src="${escapeHtml(viewerUrl)}"
title="${escapeHtml(seatMapState.seatMap.name || "DXF Viewer")}"
loading="eager"
referrerpolicy="same-origin"
></iframe>
</div> </div>
`; `;
setupSeatMapViewerFrame();
}
function getDraftPlacedSlotKeys() {
const slotMap = getSeatSlotMap();
return (seatMapState.draftPlacements || [])
.map((placement) => slotMap.get(Number(placement.seat_slot_id))?.slot_key)
.filter(Boolean)
.map((value) => String(value));
}
function syncSeatMapViewerFrame() {
const frame = seatMapBoard?.querySelector("#seatmap-dxf-frame");
if (!frame?.contentWindow) return;
frame.contentWindow.postMessage(
{ type: "seatmap-set-placed", keys: getDraftPlacedSlotKeys() },
window.location.origin,
);
}
function setupSeatMapViewerFrame() {
const frame = seatMapBoard?.querySelector("#seatmap-dxf-frame");
if (!frame) return;
frame.addEventListener("load", () => {
syncSeatMapViewerFrame();
if (!seatMapState.editMode) return;
const frameWindow = frame.contentWindow;
const frameDocument = frame.contentDocument;
const canvas = frameDocument?.getElementById("canvas");
if (!frameWindow || !frameDocument || !canvas || !frameWindow.__mhSeatmap) return;
canvas.addEventListener("dragover", (event) => {
event.preventDefault();
event.dataTransfer.dropEffect = "move";
});
canvas.addEventListener("drop", (event) => {
event.preventDefault();
const memberId = getDraggedMemberId(event);
if (!memberId) return;
const rect = canvas.getBoundingClientRect();
const picked = frameWindow.__mhSeatmap.pickChairAt(
event.clientX - rect.left,
event.clientY - rect.top,
);
if (!picked?.key) return;
const matchedSlot = (seatMapState.slots || []).find((item) => String(item.slot_key) === String(picked.key));
if (!matchedSlot) return;
upsertDraftPlacementForSlot(memberId, Number(matchedSlot.id));
renderSeatMap();
});
}, { once: true });
} }
function renderUnassignedMembers() { function renderUnassignedMembers() {
@@ -413,6 +721,7 @@ function syncSeatMapSettingsForm() {
function renderSeatMap() { function renderSeatMap() {
const hasSeatMap = Boolean(seatMapState.seatMap); const hasSeatMap = Boolean(seatMapState.seatMap);
const admin = isAdmin(); const admin = isAdmin();
const fixedViewerMap = seatMapState.seatMap?.source_type === "fixed_html";
if (seatMapName) { if (seatMapName) {
seatMapName.textContent = hasSeatMap ? seatMapState.seatMap.name : "자리배치도"; seatMapName.textContent = hasSeatMap ? seatMapState.seatMap.name : "자리배치도";
@@ -422,7 +731,7 @@ function renderSeatMap() {
seatMapStatus.dataset.tone = seatMapState.statusTone; seatMapStatus.dataset.tone = seatMapState.statusTone;
} }
if (seatMapSettingsPanel) { if (seatMapSettingsPanel) {
seatMapSettingsPanel.classList.toggle("hidden", !admin); seatMapSettingsPanel.classList.toggle("hidden", !admin || fixedViewerMap);
} }
if (seatMapSaveBtn) { if (seatMapSaveBtn) {
seatMapSaveBtn.hidden = !admin || !hasSeatMap; seatMapSaveBtn.hidden = !admin || !hasSeatMap;
@@ -461,7 +770,7 @@ function handleEmbeddedNavigationMessage(event) {
} }
async function fetchJson(url, options) { async function fetchJson(url, options) {
const response = await fetch(url, options); const response = await fetch(resolveAppUrl(url), options);
let payload = null; let payload = null;
try { try {
payload = await response.json(); payload = await response.json();
@@ -487,11 +796,15 @@ async function loadSeatMapData(force = false) {
const activePayload = await fetchJson("/api/seat-maps/active"); 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 = layoutPayload.seat_map; seatMapState.seatMap = {
...(layoutPayload.seat_map || {}),
viewer_data: layoutPayload.viewer_data || null,
};
seatMapState.members = Array.isArray(layoutPayload.members) ? layoutPayload.members : []; seatMapState.members = Array.isArray(layoutPayload.members) ? layoutPayload.members : [];
seatMapState.slots = Array.isArray(layoutPayload.slots) ? layoutPayload.slots : []; seatMapState.slots = Array.isArray(layoutPayload.slots) ? layoutPayload.slots : [];
seatMapState.placements = clonePlacements(layoutPayload.placements || []); seatMapState.placements = clonePlacements(layoutPayload.placements || []);
seatMapState.zoom = 1; seatMapState.zoom = 1;
seatMapState.hoveredSlotId = null;
seatMapState.editMode = isAdmin(); seatMapState.editMode = isAdmin();
resetSeatMapDraft(); resetSeatMapDraft();
seatMapState.loaded = true; seatMapState.loaded = true;
@@ -504,6 +817,7 @@ async function loadSeatMapData(force = false) {
seatMapState.slots = []; seatMapState.slots = [];
seatMapState.placements = []; seatMapState.placements = [];
seatMapState.zoom = 1; seatMapState.zoom = 1;
seatMapState.hoveredSlotId = null;
seatMapState.editMode = isAdmin(); seatMapState.editMode = isAdmin();
resetSeatMapDraft(); resetSeatMapDraft();
seatMapState.loaded = true; seatMapState.loaded = true;
@@ -615,8 +929,20 @@ function handleSeatMapCellDrop(event) {
if (!memberId) return; if (!memberId) return;
if (seatMapState.seatMap?.source_type === "dxf") { if (seatMapState.seatMap?.source_type === "dxf") {
const slot = event.target.closest(".seatmap-slot"); const slot = event.target.closest(".seatmap-slot");
if (!slot) return; if (slot) {
upsertDraftPlacementForSlot(memberId, Number(slot.dataset.slotId)); upsertDraftPlacementForSlot(memberId, Number(slot.dataset.slotId));
renderSeatMap();
return;
}
const viewerData = seatMapState.seatMap.viewer_data;
const canvas = seatMapBoard?.querySelector("#seatmap-dxf-canvas");
if (!viewerData || !canvas) return;
const rect = canvas.getBoundingClientRect();
const chair = pickViewerChair(event.clientX - rect.left, event.clientY - rect.top, viewerData);
if (!chair) return;
const matchedSlot = (seatMapState.slots || []).find((item) => String(item.slot_key) === String(chair.key));
if (!matchedSlot) return;
upsertDraftPlacementForSlot(memberId, Number(matchedSlot.id));
} else { } else {
const cell = event.target.closest(".seatmap-cell"); const cell = event.target.closest(".seatmap-cell");
if (!cell) return; if (!cell) return;
@@ -665,7 +991,7 @@ function setActiveView(view) {
if (isOrganization && previousView !== "organization" && organizationFrame) { if (isOrganization && previousView !== "organization" && organizationFrame) {
const frameSrc = organizationFrame.dataset.src || organizationFrame.src; const frameSrc = organizationFrame.dataset.src || organizationFrame.src;
organizationFrame.src = frameSrc; organizationFrame.src = resolveAppUrl(frameSrc);
} }
if (isSeatMap) { if (isSeatMap) {
loadSeatMapData(); loadSeatMapData();
@@ -784,13 +1110,17 @@ if (seatMapBoard) {
seatMapBoard.addEventListener("wheel", (event) => { seatMapBoard.addEventListener("wheel", (event) => {
if (seatMapState.seatMap?.source_type !== "dxf") return; if (seatMapState.seatMap?.source_type !== "dxf") return;
event.preventDefault(); event.preventDefault();
const delta = event.deltaY < 0 ? 0.1 : -0.1; zoomDxfSeatMapAtPoint(event.clientX, event.clientY, event.deltaY < 0 ? 1.08 : 0.92);
setSeatMapZoom(seatMapState.zoom + delta);
}, { passive: false }); }, { passive: false });
seatMapBoard.addEventListener("click", (event) => {
const fitButton = event.target.closest("[data-seatmap-action='fit']");
if (!fitButton) return;
fitDxfSeatMapBoard();
});
seatMapBoard.addEventListener("dragover", (event) => { seatMapBoard.addEventListener("dragover", (event) => {
if (!seatMapState.editMode) return; if (!seatMapState.editMode) return;
const target = seatMapState.seatMap?.source_type === "dxf" const target = seatMapState.seatMap?.source_type === "dxf"
? event.target.closest(".seatmap-slot") ? (event.target.closest(".seatmap-slot") || event.target.closest("#seatmap-dxf-canvas"))
: event.target.closest(".seatmap-cell"); : event.target.closest(".seatmap-cell");
if (!target) return; if (!target) return;
event.preventDefault(); event.preventDefault();
@@ -802,7 +1132,9 @@ if (seatMapBoard) {
if (seatMapBoardWrap) { if (seatMapBoardWrap) {
seatMapBoardWrap.addEventListener("mousedown", (event) => { seatMapBoardWrap.addEventListener("mousedown", (event) => {
if (seatMapState.seatMap?.source_type !== "dxf") return; if (seatMapState.seatMap?.source_type !== "dxf") return;
if (event.button !== 1) return; if (seatMapBoard?.querySelector("#seatmap-dxf-canvas")) return;
if (event.button !== 0) return;
if (event.target.closest(".seatmap-member-card, button, input, label")) return;
event.preventDefault(); event.preventDefault();
seatMapState.panning = true; seatMapState.panning = true;
seatMapState.panStartX = event.clientX; seatMapState.panStartX = event.clientX;
@@ -811,6 +1143,21 @@ if (seatMapBoardWrap) {
seatMapState.panScrollTop = seatMapBoardWrap.scrollTop; seatMapState.panScrollTop = seatMapBoardWrap.scrollTop;
seatMapBoardWrap.classList.add("is-panning"); seatMapBoardWrap.classList.add("is-panning");
}); });
seatMapBoardWrap.addEventListener("mouseleave", () => {
if (seatMapBoard?.querySelector("#seatmap-dxf-canvas")) return;
if (seatMapState.hoveredSlotId == null) return;
seatMapState.hoveredSlotId = null;
updateSeatMapViewerHoverChip();
});
seatMapBoardWrap.addEventListener("mousemove", (event) => {
if (seatMapState.seatMap?.source_type !== "dxf") return;
if (seatMapBoard?.querySelector("#seatmap-dxf-canvas")) return;
const slot = event.target.closest(".seatmap-slot");
const nextSlotId = slot ? Number(slot.dataset.slotId) : null;
if (nextSlotId === seatMapState.hoveredSlotId) return;
seatMapState.hoveredSlotId = nextSlotId;
updateSeatMapViewerHoverChip();
});
} }
document.addEventListener("mousemove", (event) => { document.addEventListener("mousemove", (event) => {
@@ -858,3 +1205,12 @@ window.addEventListener("message", handleEmbeddedNavigationMessage);
setActiveView(currentView); setActiveView(currentView);
renderAuth(); renderAuth();
window.addEventListener("resize", () => {
if (seatMapState.seatMap?.source_type !== "dxf" || currentView !== "seatmap") return;
requestAnimationFrame(() => {
if (seatMapState.zoom === 1) {
centerSeatMapBoard();
}
});
});

View File

@@ -94,13 +94,13 @@
<aside class="seatmap-sidebar"> <aside class="seatmap-sidebar">
<section id="seatmap-settings-panel" class="seatmap-panel hidden"> <section id="seatmap-settings-panel" class="seatmap-panel hidden">
<div class="seatmap-panel-head"> <div class="seatmap-panel-head">
<h4>배치도 설정</h4> <h4> 설정</h4>
<p>DXF 파일의 chair 레이어를 좌석 위치로 사용합니다.</p> <p>현재는 기술개발센터 고정 도면을 사용합니다.</p>
</div> </div>
<form id="seatmap-settings-form" class="seatmap-form"> <form id="seatmap-settings-form" class="seatmap-form">
<label> <label>
<span>배치도 이름</span> <span> 이름</span>
<input id="seatmap-form-name" name="name" type="text" placeholder="예: 본사 3층" required> <input id="seatmap-form-name" name="name" type="text" placeholder="예: 기술개발센터" required>
</label> </label>
<div> <div>
<span>DXF 파일</span> <span>DXF 파일</span>

View File

@@ -477,9 +477,10 @@
height: 100%; height: 100%;
overflow: auto; overflow: auto;
border-radius: 24px; border-radius: 24px;
background: #fff; background: #ffffff;
padding: 0; padding: 0;
overscroll-behavior: contain; overscroll-behavior: contain;
cursor: grab;
} }
.seatmap-board-wrap.is-panning { .seatmap-board-wrap.is-panning {
@@ -493,45 +494,117 @@
.seatmap-dxf-canvas { .seatmap-dxf-canvas {
position: relative; position: relative;
width: 100%; min-width: 100%;
min-height: 100%; min-height: 100%;
margin: 0 auto; padding: 72px 24px 24px;
border-radius: 24px; border-radius: 24px;
overflow: hidden; overflow: visible;
box-shadow: none; box-shadow: none;
background: #fff; background: #ffffff;
}
.seatmap-dxf-frame-shell {
width: 100%;
height: 100%;
min-height: 720px;
background: #ffffff;
}
.seatmap-dxf-frame {
display: block;
width: 100%;
height: 100%;
min-height: 720px;
border: 0;
background: #ffffff;
}
.seatmap-dxf-viewer-head {
position: sticky;
top: 16px;
z-index: 6;
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
margin: 0 0 18px;
pointer-events: none;
}
.seatmap-viewer-chip,
.seatmap-viewer-fit {
pointer-events: auto;
display: inline-flex;
align-items: center;
min-height: 40px;
padding: 10px 14px;
border-radius: 16px;
border: 1px solid rgba(21, 35, 48, 0.1);
background: rgba(255, 255, 255, 0.96);
color: #627286;
font-size: 13px;
font-weight: 800;
box-shadow: 0 8px 24px rgba(21, 35, 48, 0.08);
}
.seatmap-viewer-fit {
border: none;
color: #ffffff;
background: linear-gradient(135deg, #0f766e, #115e59);
box-shadow: 0 10px 24px rgba(15, 118, 110, 0.22);
}
.seatmap-dxf-canvas-surface {
display: block;
width: 100%;
height: 680px;
border-radius: 24px;
background: #ffffff;
border: 1px solid rgba(21, 35, 48, 0.08);
box-shadow: 0 22px 48px rgba(21, 35, 48, 0.06);
cursor: grab;
}
.seatmap-dxf-canvas-surface.dragging {
cursor: grabbing;
} }
.seatmap-dxf-stage { .seatmap-dxf-stage {
position: relative; position: relative;
transform-origin: center center; transform-origin: top left;
transition: transform 0.12s ease-out; transition: width 0.12s ease-out, height 0.12s ease-out;
margin: 0 auto;
} }
.seatmap-dxf-preview { .seatmap-dxf-preview {
position: relative; position: relative;
z-index: 1; z-index: 1;
line-height: 0; line-height: 0;
filter: contrast(1.9) saturate(1.1) brightness(0.88); filter: none;
border-radius: 24px;
overflow: hidden;
border: 1px solid rgba(21, 35, 48, 0.08);
background: #ffffff;
box-shadow: 0 22px 48px rgba(21, 35, 48, 0.06);
} }
.seatmap-preview-svg { .seatmap-preview-svg {
display: block; display: block;
width: 100%; width: 100%;
height: auto; height: 100%;
background: #fff; background: #fff;
} }
.seatmap-preview-svg .seatmap-dxf-entity { .seatmap-preview-svg .seatmap-dxf-entity {
stroke: #000 !important; stroke: rgba(21, 35, 48, 0.16) !important;
stroke-opacity: 1 !important; stroke-opacity: 1 !important;
stroke-width: 12 !important; stroke-width: 4 !important;
} }
.seatmap-preview-svg .seatmap-dxf-chair-entity { .seatmap-preview-svg .seatmap-dxf-chair-entity {
stroke: #2563eb !important; stroke: rgba(15, 118, 110, 0.96) !important;
stroke-opacity: 1 !important; stroke-opacity: 1 !important;
stroke-width: 6 !important; stroke-width: 5.5 !important;
} }
.seatmap-preview-svg rect { .seatmap-preview-svg rect {
@@ -547,19 +620,19 @@
.seatmap-slot { .seatmap-slot {
position: absolute; position: absolute;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
width: 30px; width: 28px;
min-height: 30px; min-height: 28px;
border: 0; border: 0;
border-radius: 999px; border-radius: 999px;
background: transparent; background: transparent;
pointer-events: auto; pointer-events: auto;
transition: box-shadow 0.18s ease, background 0.18s ease, transform 0.18s ease; transition: box-shadow 0.18s ease, background 0.18s ease, transform 0.18s ease, opacity 0.18s ease;
} }
.seatmap-slot.editable:hover { .seatmap-slot.editable:hover {
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.4); box-shadow: 0 0 0 6px rgba(15, 118, 110, 0.18);
background: rgba(37, 99, 235, 0.12); background: rgba(15, 118, 110, 0.08);
transform: translate(-50%, -50%) scale(1.02); transform: translate(-50%, -50%) scale(1.06);
} }
.seatmap-slot.occupied { .seatmap-slot.occupied {
@@ -569,7 +642,31 @@
} }
.seatmap-slot.empty { .seatmap-slot.empty {
opacity: 0.14; opacity: 0.72;
}
.seatmap-slot[data-slot-id]::after {
content: "";
position: absolute;
inset: 50% auto auto 50%;
width: 10px;
height: 10px;
transform: translate(-50%, -50%);
border-radius: 999px;
background: rgba(15, 118, 110, 0.28);
border: 1px solid rgba(15, 118, 110, 0.55);
}
.seatmap-slot.occupied::after {
width: 14px;
height: 14px;
background: rgba(220, 38, 38, 0.3);
border-color: rgba(220, 38, 38, 0.72);
}
.seatmap-slot:hover::after {
width: 16px;
height: 16px;
} }
.seatmap-canvas { .seatmap-canvas {
@@ -968,6 +1065,19 @@
align-items: flex-start; align-items: flex-start;
flex-direction: column; flex-direction: column;
} }
.seatmap-dxf-canvas {
padding: 68px 16px 16px;
}
.seatmap-dxf-canvas-surface {
height: 620px;
}
.seatmap-dxf-frame-shell,
.seatmap-dxf-frame {
min-height: 620px;
}
} }
@media (max-width: 720px) { @media (max-width: 720px) {

View File

@@ -262,7 +262,7 @@ body {
background: white; background: white;
width: 100%; width: 100%;
max-width: 650px; max-width: 650px;
padding: 35px; padding: 24px 24px 20px;
border-radius: 20px; border-radius: 20px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
position: relative; position: relative;
@@ -318,6 +318,262 @@ body {
max-width: 1200px; max-width: 1200px;
} }
.member-photo-field {
display: flex;
flex-direction: column;
gap: 8px;
height: 100%;
}
.member-basic-top-row {
display: grid;
grid-template-columns: 220px minmax(0, 1fr);
gap: 12px;
align-items: stretch;
}
.member-detail-top-row {
width: 100%;
display: grid;
grid-template-columns: 140px minmax(0, 1fr);
gap: 20px;
align-items: start;
}
.member-detail-summary {
display: flex;
flex-direction: column;
gap: 12px;
min-width: 0;
}
.member-name-field {
min-width: 0;
}
.member-inline-info-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
margin-top: 10px;
}
.member-inline-info-grid-edit {
margin-top: 10px;
}
.member-inline-info-card {
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px;
border: 1px solid #e2e8f0;
border-radius: 12px;
background: #f8fafc;
}
.member-inline-info-card label {
display: block;
font-size: 10px;
font-weight: 900;
color: #64748b;
}
.member-inline-info-card strong {
display: block;
font-size: 13px;
font-weight: 900;
color: #1e293b;
word-break: break-word;
}
.member-inline-info-card-full {
grid-column: 1 / -1;
}
.modal-form-grid {
align-items: start;
}
.modal-form-grid > .col-span-1,
.modal-form-grid > .col-span-2 {
min-width: 0;
}
.member-photo-upload-card {
display: flex;
gap: 16px;
align-items: center;
justify-content: center;
padding: 12px;
border: 1px solid #e2e8f0;
border-radius: 16px;
background: #f8fafc;
height: 100%;
min-height: 100%;
box-sizing: border-box;
}
.member-photo-upload-card-compact {
flex-direction: column;
align-items: center;
text-align: center;
justify-content: space-between;
}
.member-photo-preview-wrap {
flex: 0 0 auto;
}
.member-photo-preview {
width: 84px;
height: 84px;
border-radius: 9999px;
object-fit: cover;
border: 3px solid #e0e7ff;
background: #ffffff;
}
.member-photo-upload-controls {
min-width: 0;
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
}
.member-photo-file-label {
display: inline-flex;
align-items: center;
justify-content: center;
width: fit-content;
padding: 10px 14px;
border-radius: 10px;
background: #4f46e5;
color: #ffffff;
font-size: 12px;
font-weight: 800;
cursor: pointer;
}
.member-photo-file-label input {
display: none;
}
.member-photo-file-name {
font-size: 11px;
color: #334155;
word-break: break-all;
}
.seat-preview-card {
border: 1px solid #e2e8f0;
border-radius: 18px;
background: linear-gradient(180deg, #f8fafc 0%, #eef2ff 100%);
overflow: hidden;
}
.seat-preview-head {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 12px 14px 8px;
align-items: flex-start;
}
.seat-preview-head strong {
display: block;
font-size: 13px;
font-weight: 900;
color: #1e293b;
}
.seat-preview-head p {
margin: 4px 0 0;
font-size: 11px;
line-height: 1.5;
color: #64748b;
font-weight: 700;
}
.seat-preview-badge {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
padding: 6px 10px;
border-radius: 9999px;
background: #dbeafe;
color: #1d4ed8;
font-size: 11px;
font-weight: 900;
}
.seat-preview-badge-muted {
background: #e2e8f0;
color: #64748b;
}
.seat-preview-canvas {
margin: 0 14px 14px;
min-height: 220px;
border-radius: 16px;
border: 1px dashed #94a3b8;
background:
linear-gradient(135deg, rgba(255,255,255,0.9), rgba(224,231,255,0.95)),
repeating-linear-gradient(
0deg,
rgba(148,163,184,0.12),
rgba(148,163,184,0.12) 1px,
transparent 1px,
transparent 24px
),
repeating-linear-gradient(
90deg,
rgba(148,163,184,0.12),
rgba(148,163,184,0.12) 1px,
transparent 1px,
transparent 24px
);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.seat-preview-placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
color: #475569;
font-size: 12px;
font-weight: 900;
}
.seat-preview-placeholder-icon {
width: 52px;
height: 52px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 9999px;
background: rgba(79, 70, 229, 0.1);
color: #4338ca;
font-size: 24px;
}
@media (max-width: 720px) {
.member-basic-top-row {
grid-template-columns: 1fr;
}
.member-detail-top-row,
.member-inline-info-grid {
grid-template-columns: 1fr;
}
}
.list-table { .list-table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;

View File

@@ -5,6 +5,7 @@ let editingMembers = [];
let collapsedUnits = new Set(); let collapsedUnits = new Set();
let isListMode = false; let isListMode = false;
let emptyStateMessage = '서버에 조직 데이터가 없습니다. 상단의 업로드 버튼으로 초기 데이터를 넣어주세요.'; let emptyStateMessage = '서버에 조직 데이터가 없습니다. 상단의 업로드 버튼으로 초기 데이터를 넣어주세요.';
let photoPreviewObjectUrl = null;
const levelOrder = ['부서', '그룹', '디비전', '팀', '셀']; const levelOrder = ['부서', '그룹', '디비전', '팀', '셀'];
const dropdownFields = ['소속회사', '직급', '직책', ...levelOrder]; const dropdownFields = ['소속회사', '직급', '직책', ...levelOrder];
@@ -31,6 +32,26 @@ function cloneMembers(items) {
return JSON.parse(JSON.stringify(items)); return JSON.parse(JSON.stringify(items));
} }
function getPhotoPlaceholder(name = '') {
return `https://via.placeholder.com/160?text=${encodeURIComponent(name || 'Profile')}`;
}
function escapeHtml(value) {
return String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function resetPhotoPreviewObjectUrl() {
if (photoPreviewObjectUrl) {
URL.revokeObjectURL(photoPreviewObjectUrl);
photoPreviewObjectUrl = null;
}
}
function toLegacyMember(item) { function toLegacyMember(item) {
return rebuildMemberPath({ return rebuildMemberPath({
_id: String(item.id), _id: String(item.id),
@@ -94,6 +115,17 @@ async function apiFetch(url, options = {}) {
return payload; return payload;
} }
async function uploadProfilePhoto(file, memberName) {
const formData = new FormData();
formData.append('file', file);
formData.append('member_name', memberName || '');
const payload = await apiFetch('/api/uploads/profile-photo', {
method: 'POST',
body: formData,
});
return payload.url || '';
}
function setMembers(items) { function setMembers(items) {
members = items.map(toLegacyMember); members = items.map(toLegacyMember);
if (selectedDept !== '전체' && !members.some((member) => member['부서'] === selectedDept)) { if (selectedDept !== '전체' && !members.some((member) => member['부서'] === selectedDept)) {
@@ -779,6 +811,66 @@ function toggleFlexibleTime(value) {
document.getElementById('flexible-time-area').classList.toggle('hidden', value !== '유연근무제'); document.getElementById('flexible-time-area').classList.toggle('hidden', value !== '유연근무제');
} }
function updatePhotoPreview(src, fallbackName) {
const preview = document.getElementById('m-photo-preview');
if (!preview) {
return;
}
preview.src = src || getPhotoPlaceholder(fallbackName);
}
function syncPhotoPreviewFromUrl() {
const name = document.getElementById('m-name')?.value?.trim() || '';
const url = document.getElementById('m-photo-hidden')?.value?.trim() || '';
updatePhotoPreview(url, name);
}
function handlePhotoFileChange(event) {
const file = event.target.files?.[0];
const fileName = document.getElementById('m-photo-file-name');
const name = document.getElementById('m-name')?.value?.trim() || '';
resetPhotoPreviewObjectUrl();
if (!file) {
if (fileName) {
fileName.textContent = '선택된 파일 없음';
}
syncPhotoPreviewFromUrl();
return;
}
if (fileName) {
fileName.textContent = file.name;
}
photoPreviewObjectUrl = URL.createObjectURL(file);
updatePhotoPreview(photoPreviewObjectUrl, name);
}
function renderSeatPreviewCard(seatLabel) {
const safeLabel = escapeHtml(seatLabel || '');
const badge = safeLabel
? `<span class="seat-preview-badge">${safeLabel}</span>`
: '<span class="seat-preview-badge seat-preview-badge-muted">미배치</span>';
return `
<div class="seat-preview-card">
<div class="seat-preview-head">
<div>
<strong>재석위치</strong>
<p>향후 해당 인원의 좌석 영역을 크롭해 표시하고, 스크롤 확대/축소를 지원할 예정입니다.</p>
</div>
${badge}
</div>
<div class="seat-preview-canvas">
<div class="seat-preview-placeholder">
<span class="seat-preview-placeholder-icon">⌖</span>
<span>좌석 이미지 연동 예정</span>
</div>
</div>
</div>
`;
}
function switchModalTab(tab) { function switchModalTab(tab) {
const isBasic = tab === 'basic'; const isBasic = tab === 'basic';
document.getElementById('modal-sec-basic').classList.toggle('hidden', !isBasic); document.getElementById('modal-sec-basic').classList.toggle('hidden', !isBasic);
@@ -804,29 +896,30 @@ function openModal(id) {
fieldsArea.className = 'flex flex-col items-center gap-6 py-4'; fieldsArea.className = 'flex flex-col items-center gap-6 py-4';
fieldsArea.style.maxHeight = 'none'; fieldsArea.style.maxHeight = 'none';
fieldsArea.innerHTML = ` fieldsArea.innerHTML = `
<div class="relative w-32 h-32 rounded-full overflow-hidden border-4 border-indigo-100 shadow-lg"> <div class="member-detail-top-row">
<img src="${member['사진'] || 'https://via.placeholder.com/120?text=Profile'}" class="w-full h-full object-cover"> <div class="relative w-32 h-32 rounded-full overflow-hidden border-4 border-indigo-100 shadow-lg">
</div> <img src="${member['사진'] || 'https://via.placeholder.com/120?text=Profile'}" class="w-full h-full object-cover">
<div class="text-center"> </div>
<h2 class="text-2xl font-black text-slate-800">${member['이름'] || ''}</h2> <div class="member-detail-summary">
<p class="text-indigo-600 font-bold">${member['직급'] || '-'} / ${member['직책'] || '팀원'}</p> <div>
<p class="text-slate-400 text-xs mt-1 font-medium">${(member._path || []).map((path) => path.name).join(' > ')}</p> <h2 class="text-2xl font-black text-slate-800">${member['이름'] || ''}</h2>
</div> <p class="text-indigo-600 font-bold">${member['직급'] || '-'} / ${member['직책'] || '팀원'}</p>
<div class="w-full grid grid-cols-2 gap-3 mt-4"> <p class="text-slate-400 text-xs mt-1 font-medium">${(member._path || []).map((path) => path.name).join(' > ')}</p>
<div class="bg-indigo-50 p-4 rounded-2xl border border-indigo-100 col-span-2 flex items-center gap-4">
<div class="flex-1">
<label class="text-[10px] text-indigo-400 font-bold block mb-1">연락처</label>
<span class="text-sm font-black text-indigo-700">${member['전화번호'] || '정보 없음'}</span>
</div> </div>
<div class="flex-1"> <div class="member-inline-info-grid">
<label class="text-[10px] text-indigo-400 font-bold block mb-1">이메일</label> <div class="member-inline-info-card">
<span class="text-sm font-black text-indigo-700">${member['이메일'] || '정보 없음'}</span> <label>전화번호</label>
<strong>${member['전화번호'] || '정보 없음'}</strong>
</div>
<div class="member-inline-info-card">
<label>이메일</label>
<strong>${member['이메일'] || '정보 없음'}</strong>
</div>
</div> </div>
</div> </div>
<div class="bg-slate-50 p-4 rounded-2xl border border-slate-100 col-span-2"> </div>
<label class="text-[10px] text-slate-400 font-bold block mb-1">사무실 위치</label> <div class="w-full mt-2">
<span class="text-sm font-black text-slate-700">${member['자리위치'] || '정보 없음'}</span> ${renderSeatPreviewCard(member['자리위치'] || '')}
</div>
</div> </div>
`; `;
footer.innerHTML = '<button onclick="closeModal()" class="w-full bg-slate-800 text-white py-4 rounded-xl font-bold text-sm shadow-lg">닫기</button>'; footer.innerHTML = '<button onclick="closeModal()" class="w-full bg-slate-800 text-white py-4 rounded-xl font-bold text-sm shadow-lg">닫기</button>';
@@ -836,11 +929,11 @@ function openModal(id) {
document.getElementById('modal-title').innerText = id ? '구성원 정보 수정' : '신규 구성원 추가'; document.getElementById('modal-title').innerText = id ? '구성원 정보 수정' : '신규 구성원 추가';
fieldsArea.className = 'flex flex-col w-full'; fieldsArea.className = 'flex flex-col w-full';
fieldsArea.style.maxHeight = '75vh'; fieldsArea.style.maxHeight = 'none';
fieldsArea.style.overflowY = 'auto'; fieldsArea.style.overflowY = 'visible';
const sourceValues = isListMode ? editingMembers : members; const sourceValues = isListMode ? editingMembers : members;
let orgFields = '<div id="modal-sec-org" class="hidden grid grid-cols-2 gap-4">'; let orgFields = '<div id="modal-sec-org" class="hidden grid grid-cols-2 gap-3 modal-form-grid">';
dropdownFields.forEach((field) => { dropdownFields.forEach((field) => {
const uniqueValues = Array.from(new Set(sourceValues.map((item) => item[field]).filter(Boolean))).sort(); const uniqueValues = Array.from(new Set(sourceValues.map((item) => item[field]).filter(Boolean))).sort();
const currentValue = member[field] || ''; const currentValue = member[field] || '';
@@ -886,18 +979,55 @@ function openModal(id) {
<button id="modal-tab-basic" onclick="switchModalTab('basic')" class="flex-1 py-3 font-bold border-b-2 border-indigo-600 text-indigo-600 text-sm transition-all">기본 정보</button> <button id="modal-tab-basic" onclick="switchModalTab('basic')" class="flex-1 py-3 font-bold border-b-2 border-indigo-600 text-indigo-600 text-sm transition-all">기본 정보</button>
<button id="modal-tab-org" onclick="switchModalTab('org')" class="flex-1 py-3 font-bold border-b-2 border-transparent text-slate-400 text-sm transition-all">조직 및 근무</button> <button id="modal-tab-org" onclick="switchModalTab('org')" class="flex-1 py-3 font-bold border-b-2 border-transparent text-slate-400 text-sm transition-all">조직 및 근무</button>
</div> </div>
<div id="modal-sec-basic" class="grid grid-cols-2 gap-4"> <div id="modal-sec-basic" class="grid grid-cols-2 gap-3 modal-form-grid">
<input type="hidden" id="m-id" value="${id || ''}"> <input type="hidden" id="m-id" value="${id || ''}">
<div class="col-span-2"><label class="text-[11px] font-black text-slate-600 block">이름 (필수)</label><input id="m-name" value="${member['이름'] || ''}" class="w-full bg-slate-50 p-3 rounded-xl border font-bold text-sm outline-none"></div> <input type="hidden" id="m-photo-hidden" value="${member['사진'] || ''}">
<div class="col-span-2"><label class="text-[11px] font-black text-slate-600 block">사번</label><input id="m-employee-id" value="${member['사번'] || ''}" class="w-full bg-white p-3 rounded-xl border font-bold text-sm outline-none"></div> <input type="hidden" id="m-seat-hidden" value="${member['자리위치'] || ''}">
<div class="col-span-1"><label class="text-[11px] font-black text-slate-600 block">전화번호</label><input id="m-phone" value="${member['전화번호'] || ''}" class="w-full bg-white p-3 rounded-xl border font-bold text-sm outline-none"></div> <div class="col-span-2 member-basic-top-row">
<div class="col-span-1"><label class="text-[11px] font-black text-slate-600 block">이메일</label><input id="m-email" value="${member['이메일'] || ''}" class="w-full bg-white p-3 rounded-xl border font-bold text-sm outline-none"></div> <div class="member-photo-field">
<div class="col-span-2"><label class="text-[11px] font-black text-slate-600 block">자리 위치</label><input id="m-seat" value="${member['자리위치'] || ''}" class="w-full bg-white p-3 rounded-xl border font-bold text-sm outline-none"></div> <label class="text-[11px] font-black text-slate-600 block">프로필 사진</label>
<div class="col-span-2"><label class="text-[11px] font-black text-slate-600 block">사진 URL</label><input id="m-photo" value="${member['사진'] || ''}" class="w-full bg-white p-3 rounded-xl border font-bold text-sm outline-none text-xs"></div> <div class="member-photo-upload-card member-photo-upload-card-compact">
<div class="member-photo-preview-wrap">
<img id="m-photo-preview" src="${member['사진'] || getPhotoPlaceholder(member['이름'] || '')}" alt="프로필 미리보기" class="member-photo-preview">
</div>
<div class="member-photo-upload-controls">
<label class="member-photo-file-label" for="m-photo-file">
<input id="m-photo-file" type="file" accept="image/png,image/jpeg,image/webp,image/gif" onchange="handlePhotoFileChange(event)">
<span>사진 파일 선택</span>
</label>
<strong id="m-photo-file-name" class="member-photo-file-name">선택된 파일 없음</strong>
</div>
</div>
</div>
<div class="member-name-field">
<label class="text-[11px] font-black text-slate-600 block">이름 (필수)</label>
<input id="m-name" value="${member['이름'] || ''}" oninput="syncPhotoPreviewFromUrl()" class="w-full bg-slate-50 p-3 rounded-xl border font-bold text-sm outline-none">
<div class="member-inline-info-grid member-inline-info-grid-edit">
<div class="member-inline-info-card">
<label>사번</label>
<input id="m-employee-id" value="${member['사번'] || ''}" class="w-full bg-white p-3 rounded-xl border font-bold text-sm outline-none">
</div>
<div class="member-inline-info-card">
<label>전화번호</label>
<input id="m-phone" value="${member['전화번호'] || ''}" class="w-full bg-white p-3 rounded-xl border font-bold text-sm outline-none">
</div>
<div class="member-inline-info-card member-inline-info-card-full">
<label>이메일</label>
<input id="m-email" value="${member['이메일'] || ''}" class="w-full bg-white p-3 rounded-xl border font-bold text-sm outline-none">
</div>
</div>
</div>
</div>
<div class="col-span-2">
<label class="text-[11px] font-black text-slate-600 block mb-2">자리 위치</label>
${renderSeatPreviewCard(member['자리위치'] || '')}
</div>
</div> </div>
${orgFields} ${orgFields}
`; `;
resetPhotoPreviewObjectUrl();
const deleteBtn = id ? `<button onclick="deleteMember('${id}')" class="bg-red-50 text-red-600 py-3.5 px-6 rounded-xl font-bold text-sm border border-red-100 hover:bg-red-100 transition-colors">삭제</button>` : ''; const deleteBtn = id ? `<button onclick="deleteMember('${id}')" class="bg-red-50 text-red-600 py-3.5 px-6 rounded-xl font-bold text-sm border border-red-100 hover:bg-red-100 transition-colors">삭제</button>` : '';
footer.innerHTML = ` footer.innerHTML = `
${deleteBtn} ${deleteBtn}
@@ -908,6 +1038,7 @@ function openModal(id) {
} }
function closeModal() { function closeModal() {
resetPhotoPreviewObjectUrl();
document.getElementById('modal').style.display = 'none'; document.getElementById('modal').style.display = 'none';
document.getElementById('modal-fields').className = 'grid grid-cols-2 gap-x-8 gap-y-5'; document.getElementById('modal-fields').className = 'grid grid-cols-2 gap-x-8 gap-y-5';
document.getElementById('modal-fields').style.maxHeight = 'none'; document.getElementById('modal-fields').style.maxHeight = 'none';
@@ -946,8 +1077,12 @@ async function saveMember() {
member['근무시간'] = document.getElementById('m-worktime').value; member['근무시간'] = document.getElementById('m-worktime').value;
member['전화번호'] = document.getElementById('m-phone').value.trim(); member['전화번호'] = document.getElementById('m-phone').value.trim();
member['이메일'] = document.getElementById('m-email').value.trim(); member['이메일'] = document.getElementById('m-email').value.trim();
member['자리위치'] = document.getElementById('m-seat').value.trim(); member['자리위치'] = document.getElementById('m-seat-hidden').value.trim();
member['사진'] = document.getElementById('m-photo').value.trim(); member['사진'] = document.getElementById('m-photo-hidden').value.trim();
const photoFile = document.getElementById('m-photo-file')?.files?.[0];
if (photoFile) {
member['사진'] = await uploadProfilePhoto(photoFile, member['이름']);
}
if (member['근무시간'] === '유연근무제') { if (member['근무시간'] === '유연근무제') {
member['유연근무_시작'] = document.getElementById('m-work-start').value; member['유연근무_시작'] = document.getElementById('m-work-start').value;
member['유연근무_종료'] = document.getElementById('m-work-end').value; member['유연근무_종료'] = document.getElementById('m-work-end').value;

View File

@@ -0,0 +1,147 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MH 조직현황 대시보드 Standalone</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Pretendard:wght@400;600;700;900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="http://localhost:8080/legacy/static/common.css">
<link rel="stylesheet" href="http://localhost:8080/styles.css?v=20260326-standalone">
</head>
<body>
<section id="login-panel" class="login-screen">
<div class="login-backdrop">
<form id="login-form" class="login-card">
<div class="login-brand">
<p class="eyebrow">GPD/TDC</p>
<h1>MH Dash Board</h1>
</div>
<div class="login-form-wrap">
<label>
<span>사번</span>
<input name="username" type="text" placeholder="사번 입력" required>
</label>
<label>
<span>비번</span>
<input name="password" type="password" placeholder="비밀번호 입력" required>
</label>
<button type="submit">로그인</button>
<p id="login-message" class="helper-text"></p>
</div>
</form>
</div>
</section>
<section id="dashboard-panel" class="dashboard-shell hidden">
<header class="dashboard-header">
<div class="brand-block">
<p class="eyebrow">MH Dashboard</p>
<h2 id="current-view-title">조직 현황</h2>
</div>
<div class="header-center">
<button class="nav-pill" type="button" data-view="ledger">사업관리대장</button>
<button class="nav-pill" type="button" data-view="project">프로젝트별 분석</button>
<button class="nav-pill" type="button" data-view="team">팀/개인별 분석</button>
<button class="nav-pill active" type="button" data-view="organization">조직 현황</button>
</div>
<div class="header-actions">
<button id="user-badge" class="ghost-button ghost-button-soft user-chip" type="button"></button>
<div id="user-popover" class="user-popover hidden"></div>
<button id="logout-btn" class="ghost-button icon-button" type="button" title="로그아웃" aria-label="로그아웃">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
<polyline points="16 17 21 12 16 7"></polyline>
<line x1="21" y1="12" x2="9" y2="12"></line>
</svg>
</button>
</div>
</header>
<main class="dashboard-main">
<section id="organization-stage" class="main-stage">
<div class="stage-frame">
<iframe
id="organization-frame"
src="http://localhost:8080/legacy/organization?v=20260326-standalone"
data-src="http://localhost:8080/legacy/organization?v=20260326-standalone"
title="조직도 메인 화면"></iframe>
</div>
</section>
<section id="seatmap-stage" class="main-stage" hidden>
<div class="seatmap-layout">
<div class="seatmap-topbar">
<div>
<p class="eyebrow">Seat Layout</p>
<h3 id="seatmap-name">자리배치도</h3>
</div>
<div class="seatmap-actions">
<button id="seatmap-save-btn" class="ghost-button" type="button" hidden disabled>저장</button>
<button id="seatmap-cancel-btn" class="ghost-button ghost-button-soft" type="button" hidden>취소</button>
</div>
</div>
<p id="seatmap-status" class="seatmap-status" role="status"></p>
<div class="seatmap-content">
<div class="seatmap-board-panel">
<div id="seatmap-empty" class="seatmap-empty hidden"></div>
<div id="seatmap-board-wrap" class="seatmap-board-wrap hidden">
<div id="seatmap-board" class="seatmap-board"></div>
</div>
</div>
<aside class="seatmap-sidebar">
<section id="seatmap-settings-panel" class="seatmap-panel hidden">
<div class="seatmap-panel-head">
<h4>배치도 설정</h4>
<p>DXF 파일의 chair 레이어를 좌석 위치로 사용합니다.</p>
</div>
<form id="seatmap-settings-form" class="seatmap-form">
<label>
<span>배치도 이름</span>
<input id="seatmap-form-name" name="name" type="text" placeholder="예: 본사 3층" required>
</label>
<div>
<span>DXF 파일</span>
<label class="seatmap-file-input" for="seatmap-form-image">
<input id="seatmap-form-image" name="image" type="file" accept=".dxf" required>
<span class="seatmap-file-button">DXF 선택</span>
<strong id="seatmap-file-name" class="seatmap-file-name">선택된 파일 없음</strong>
</label>
</div>
<button id="seatmap-settings-submit" type="submit">DXF 업로드</button>
</form>
</section>
<section class="seatmap-panel">
<div class="seatmap-panel-head">
<h4>미배치 인원</h4>
<p>이름을 검색하고 자리배치도에 바로 드래그하세요.</p>
</div>
<label class="seatmap-search">
<span class="hidden">구성원 검색</span>
<input id="seatmap-search" type="search" placeholder="이름 또는 부서 검색">
</label>
<div id="seatmap-unassigned" class="seatmap-member-list"></div>
</section>
</aside>
</div>
</div>
</section>
<section id="empty-stage" class="main-stage" hidden>
<div class="stage-empty"></div>
</section>
</main>
</section>
<script>
window.__MH_BASE_URL = "http://localhost:8080";
</script>
<script src="http://localhost:8080/app.js?v=20260326-standalone"></script>
</body>
</html>