backup: save fixed office seatmap snapshot
This commit is contained in:
508
backend/app/center_chair_viewer_template.html
Normal file
508
backend/app/center_chair_viewer_template.html
Normal file
File diff suppressed because one or more lines are too long
@@ -1,18 +1,22 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
import csv
|
||||
import base64
|
||||
from datetime import datetime
|
||||
from io import BytesIO, StringIO
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import struct
|
||||
import uuid
|
||||
|
||||
import ezdxf
|
||||
from ezdxf import recover
|
||||
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.responses import FileResponse, HTMLResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from openpyxl import load_workbook
|
||||
from pydantic import BaseModel, Field
|
||||
@@ -32,6 +36,10 @@ app.add_middleware(
|
||||
)
|
||||
|
||||
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):
|
||||
@@ -239,6 +247,112 @@ def fetch_active_seat_map() -> dict[str, object] | None:
|
||||
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:
|
||||
quotient = row_index
|
||||
row_label = ""
|
||||
@@ -255,6 +369,63 @@ def compute_slot_label(index: int) -> str:
|
||||
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:
|
||||
raw = layer_name.strip().lower()
|
||||
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:
|
||||
document = ezdxf.readfile(file_path)
|
||||
return ezdxf.readfile(file_path)
|
||||
except OSError:
|
||||
try:
|
||||
document, _ = recover.readfile(file_path)
|
||||
return document
|
||||
except Exception as exc:
|
||||
kind, preview = inspect_dxf_header(file_path)
|
||||
if kind == "binary_dxf":
|
||||
@@ -542,6 +714,61 @@ def parse_dxf_layout(file_path: Path) -> tuple[dict[str, object], list[dict[str,
|
||||
status_code=400,
|
||||
detail=f"업로드한 파일 형식을 판별하지 못했습니다. 확장자만 dxf인 파일일 수 있습니다. 헤더={preview}",
|
||||
) 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()
|
||||
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] = []
|
||||
@@ -615,6 +842,73 @@ def parse_dxf_layout(file_path: Path) -> tuple[dict[str, object], list[dict[str,
|
||||
"image_height": None,
|
||||
"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
|
||||
|
||||
|
||||
@@ -660,14 +954,101 @@ def fetch_seat_layout(seat_map_id: int) -> dict[str, object]:
|
||||
(seat_map_id,),
|
||||
)
|
||||
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 {
|
||||
"seat_map": seat_map,
|
||||
"members": members,
|
||||
"slots": slots,
|
||||
"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]]:
|
||||
seat_map = fetch_seat_map(seat_map_id)
|
||||
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] = []
|
||||
occupied_cells: set[tuple[int, 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:
|
||||
if is_dxf:
|
||||
if requires_slot:
|
||||
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:
|
||||
raise HTTPException(status_code=400, detail="같은 좌석에 둘 이상의 구성원을 배치할 수 없습니다.")
|
||||
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:
|
||||
raise HTTPException(status_code=400, detail=f"존재하지 않는 구성원 ID가 포함되어 있습니다: {missing_ids}")
|
||||
|
||||
if is_dxf:
|
||||
if requires_slot:
|
||||
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))
|
||||
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:
|
||||
seat_label = item.seat_label.strip() or (
|
||||
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)
|
||||
)
|
||||
cur.execute(
|
||||
@@ -739,7 +1120,7 @@ def save_seat_layout(seat_map_id: int, payload: SeatLayoutPayload) -> list[dict[
|
||||
(
|
||||
item.member_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.col_index,
|
||||
seat_label,
|
||||
@@ -1076,7 +1457,7 @@ async def create_dxf_seat_map(file: UploadFile = File(...), name: str = Form(...
|
||||
|
||||
@app.get("/api/seat-maps/active")
|
||||
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:
|
||||
raise HTTPException(status_code=404, detail="Active seat map not found.")
|
||||
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)
|
||||
|
||||
|
||||
@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")
|
||||
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)}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -31,6 +31,7 @@ const seatMapFormGap = document.getElementById("seatmap-form-gap");
|
||||
const seatMapFormImage = document.getElementById("seatmap-form-image");
|
||||
const seatMapSearch = document.getElementById("seatmap-search");
|
||||
const seatMapUnassigned = document.getElementById("seatmap-unassigned");
|
||||
const APP_BASE_URL = String(window.__MH_BASE_URL || "").replace(/\/$/, "");
|
||||
|
||||
const viewLabels = {
|
||||
ledger: "사업관리대장",
|
||||
@@ -60,6 +61,16 @@ const seatMapState = {
|
||||
panStartY: 0,
|
||||
panScrollLeft: 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";
|
||||
@@ -101,6 +112,14 @@ function escapeHtml(value) {
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
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) {
|
||||
return items.map((item) => ({
|
||||
member_id: Number(item.member_id),
|
||||
@@ -149,7 +168,7 @@ function resetSeatMapDraft() {
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -157,6 +176,261 @@ function setSeatMapZoom(nextZoom) {
|
||||
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() {
|
||||
return new Map((seatMapState.slots || []).map((slot) => [Number(slot.id), slot]));
|
||||
}
|
||||
@@ -278,7 +552,7 @@ function renderUnassignedMemberCard(member, draggable) {
|
||||
function renderSeatMapBoard() {
|
||||
if (!seatMapBoard || !seatMapState.seatMap) return;
|
||||
|
||||
if (seatMapState.seatMap.source_type === "dxf") {
|
||||
if (seatMapState.seatMap.source_type === "dxf" || seatMapState.seatMap.source_type === "fixed_html") {
|
||||
renderDxfSeatMapBoard();
|
||||
return;
|
||||
}
|
||||
@@ -315,43 +589,77 @@ function renderSeatMapBoard() {
|
||||
|
||||
function renderDxfSeatMapBoard() {
|
||||
if (!seatMapBoard || !seatMapState.seatMap) return;
|
||||
|
||||
const memberMap = getMemberMap();
|
||||
const placementMap = getSlotPlacementMap();
|
||||
const slots = Array.isArray(seatMapState.slots) ? seatMapState.slots : [];
|
||||
const editable = seatMapState.editMode && isAdmin();
|
||||
const minX = Number(seatMapState.seatMap.view_box_min_x || 0);
|
||||
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 viewerData = seatMapState.seatMap.viewer_data;
|
||||
if (!viewerData) {
|
||||
seatMapBoard.innerHTML = `<div class="seatmap-empty-card"><strong>DXF 뷰어 데이터를 준비하지 못했습니다.</strong></div>`;
|
||||
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("");
|
||||
|
||||
const viewerUrl = resolveAppUrl(`/api/seat-maps/${seatMapState.seatMap.id}/viewer`);
|
||||
seatMapBoard.innerHTML = `
|
||||
<div class="seatmap-dxf-canvas">
|
||||
<div class="seatmap-dxf-stage" style="transform: scale(${seatMapState.zoom}); --seatmap-zoom:${seatMapState.zoom};">
|
||||
<div class="seatmap-dxf-preview">${previewSvg}</div>
|
||||
<div class="seatmap-dxf-slots">${slotHtml}</div>
|
||||
</div>
|
||||
<div class="seatmap-dxf-frame-shell">
|
||||
<iframe
|
||||
id="seatmap-dxf-frame"
|
||||
class="seatmap-dxf-frame"
|
||||
src="${escapeHtml(viewerUrl)}"
|
||||
title="${escapeHtml(seatMapState.seatMap.name || "DXF Viewer")}"
|
||||
loading="eager"
|
||||
referrerpolicy="same-origin"
|
||||
></iframe>
|
||||
</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() {
|
||||
@@ -413,6 +721,7 @@ function syncSeatMapSettingsForm() {
|
||||
function renderSeatMap() {
|
||||
const hasSeatMap = Boolean(seatMapState.seatMap);
|
||||
const admin = isAdmin();
|
||||
const fixedViewerMap = seatMapState.seatMap?.source_type === "fixed_html";
|
||||
|
||||
if (seatMapName) {
|
||||
seatMapName.textContent = hasSeatMap ? seatMapState.seatMap.name : "자리배치도";
|
||||
@@ -422,7 +731,7 @@ function renderSeatMap() {
|
||||
seatMapStatus.dataset.tone = seatMapState.statusTone;
|
||||
}
|
||||
if (seatMapSettingsPanel) {
|
||||
seatMapSettingsPanel.classList.toggle("hidden", !admin);
|
||||
seatMapSettingsPanel.classList.toggle("hidden", !admin || fixedViewerMap);
|
||||
}
|
||||
if (seatMapSaveBtn) {
|
||||
seatMapSaveBtn.hidden = !admin || !hasSeatMap;
|
||||
@@ -461,7 +770,7 @@ function handleEmbeddedNavigationMessage(event) {
|
||||
}
|
||||
|
||||
async function fetchJson(url, options) {
|
||||
const response = await fetch(url, options);
|
||||
const response = await fetch(resolveAppUrl(url), options);
|
||||
let payload = null;
|
||||
try {
|
||||
payload = await response.json();
|
||||
@@ -487,11 +796,15 @@ async function loadSeatMapData(force = false) {
|
||||
const activePayload = await fetchJson("/api/seat-maps/active");
|
||||
const activeSeatMap = activePayload.item;
|
||||
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.slots = Array.isArray(layoutPayload.slots) ? layoutPayload.slots : [];
|
||||
seatMapState.placements = clonePlacements(layoutPayload.placements || []);
|
||||
seatMapState.zoom = 1;
|
||||
seatMapState.hoveredSlotId = null;
|
||||
seatMapState.editMode = isAdmin();
|
||||
resetSeatMapDraft();
|
||||
seatMapState.loaded = true;
|
||||
@@ -504,6 +817,7 @@ async function loadSeatMapData(force = false) {
|
||||
seatMapState.slots = [];
|
||||
seatMapState.placements = [];
|
||||
seatMapState.zoom = 1;
|
||||
seatMapState.hoveredSlotId = null;
|
||||
seatMapState.editMode = isAdmin();
|
||||
resetSeatMapDraft();
|
||||
seatMapState.loaded = true;
|
||||
@@ -615,8 +929,20 @@ function handleSeatMapCellDrop(event) {
|
||||
if (!memberId) return;
|
||||
if (seatMapState.seatMap?.source_type === "dxf") {
|
||||
const slot = event.target.closest(".seatmap-slot");
|
||||
if (!slot) return;
|
||||
if (slot) {
|
||||
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 {
|
||||
const cell = event.target.closest(".seatmap-cell");
|
||||
if (!cell) return;
|
||||
@@ -665,7 +991,7 @@ function setActiveView(view) {
|
||||
|
||||
if (isOrganization && previousView !== "organization" && organizationFrame) {
|
||||
const frameSrc = organizationFrame.dataset.src || organizationFrame.src;
|
||||
organizationFrame.src = frameSrc;
|
||||
organizationFrame.src = resolveAppUrl(frameSrc);
|
||||
}
|
||||
if (isSeatMap) {
|
||||
loadSeatMapData();
|
||||
@@ -784,13 +1110,17 @@ if (seatMapBoard) {
|
||||
seatMapBoard.addEventListener("wheel", (event) => {
|
||||
if (seatMapState.seatMap?.source_type !== "dxf") return;
|
||||
event.preventDefault();
|
||||
const delta = event.deltaY < 0 ? 0.1 : -0.1;
|
||||
setSeatMapZoom(seatMapState.zoom + delta);
|
||||
zoomDxfSeatMapAtPoint(event.clientX, event.clientY, event.deltaY < 0 ? 1.08 : 0.92);
|
||||
}, { passive: false });
|
||||
seatMapBoard.addEventListener("click", (event) => {
|
||||
const fitButton = event.target.closest("[data-seatmap-action='fit']");
|
||||
if (!fitButton) return;
|
||||
fitDxfSeatMapBoard();
|
||||
});
|
||||
seatMapBoard.addEventListener("dragover", (event) => {
|
||||
if (!seatMapState.editMode) return;
|
||||
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");
|
||||
if (!target) return;
|
||||
event.preventDefault();
|
||||
@@ -802,7 +1132,9 @@ if (seatMapBoard) {
|
||||
if (seatMapBoardWrap) {
|
||||
seatMapBoardWrap.addEventListener("mousedown", (event) => {
|
||||
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();
|
||||
seatMapState.panning = true;
|
||||
seatMapState.panStartX = event.clientX;
|
||||
@@ -811,6 +1143,21 @@ if (seatMapBoardWrap) {
|
||||
seatMapState.panScrollTop = seatMapBoardWrap.scrollTop;
|
||||
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) => {
|
||||
@@ -858,3 +1205,12 @@ window.addEventListener("message", handleEmbeddedNavigationMessage);
|
||||
|
||||
setActiveView(currentView);
|
||||
renderAuth();
|
||||
|
||||
window.addEventListener("resize", () => {
|
||||
if (seatMapState.seatMap?.source_type !== "dxf" || currentView !== "seatmap") return;
|
||||
requestAnimationFrame(() => {
|
||||
if (seatMapState.zoom === 1) {
|
||||
centerSeatMapBoard();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -94,13 +94,13 @@
|
||||
<aside class="seatmap-sidebar">
|
||||
<section id="seatmap-settings-panel" class="seatmap-panel hidden">
|
||||
<div class="seatmap-panel-head">
|
||||
<h4>배치도 설정</h4>
|
||||
<p>DXF 파일의 chair 레이어를 좌석 위치로 사용합니다.</p>
|
||||
<h4>도면 설정</h4>
|
||||
<p>현재는 기술개발센터 고정 도면을 사용합니다.</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>
|
||||
<span>도면 이름</span>
|
||||
<input id="seatmap-form-name" name="name" type="text" placeholder="예: 기술개발센터" required>
|
||||
</label>
|
||||
<div>
|
||||
<span>DXF 파일</span>
|
||||
|
||||
@@ -477,9 +477,10 @@
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
border-radius: 24px;
|
||||
background: #fff;
|
||||
background: #ffffff;
|
||||
padding: 0;
|
||||
overscroll-behavior: contain;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.seatmap-board-wrap.is-panning {
|
||||
@@ -493,45 +494,117 @@
|
||||
|
||||
.seatmap-dxf-canvas {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-width: 100%;
|
||||
min-height: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 72px 24px 24px;
|
||||
border-radius: 24px;
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
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 {
|
||||
position: relative;
|
||||
transform-origin: center center;
|
||||
transition: transform 0.12s ease-out;
|
||||
transform-origin: top left;
|
||||
transition: width 0.12s ease-out, height 0.12s ease-out;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.seatmap-dxf-preview {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
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 {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
height: 100%;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.seatmap-preview-svg .seatmap-dxf-entity {
|
||||
stroke: #000 !important;
|
||||
stroke: rgba(21, 35, 48, 0.16) !important;
|
||||
stroke-opacity: 1 !important;
|
||||
stroke-width: 12 !important;
|
||||
stroke-width: 4 !important;
|
||||
}
|
||||
|
||||
.seatmap-preview-svg .seatmap-dxf-chair-entity {
|
||||
stroke: #2563eb !important;
|
||||
stroke: rgba(15, 118, 110, 0.96) !important;
|
||||
stroke-opacity: 1 !important;
|
||||
stroke-width: 6 !important;
|
||||
stroke-width: 5.5 !important;
|
||||
}
|
||||
|
||||
.seatmap-preview-svg rect {
|
||||
@@ -547,19 +620,19 @@
|
||||
.seatmap-slot {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 30px;
|
||||
min-height: 30px;
|
||||
width: 28px;
|
||||
min-height: 28px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
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 {
|
||||
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.4);
|
||||
background: rgba(37, 99, 235, 0.12);
|
||||
transform: translate(-50%, -50%) scale(1.02);
|
||||
box-shadow: 0 0 0 6px rgba(15, 118, 110, 0.18);
|
||||
background: rgba(15, 118, 110, 0.08);
|
||||
transform: translate(-50%, -50%) scale(1.06);
|
||||
}
|
||||
|
||||
.seatmap-slot.occupied {
|
||||
@@ -569,7 +642,31 @@
|
||||
}
|
||||
|
||||
.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 {
|
||||
@@ -968,6 +1065,19 @@
|
||||
align-items: flex-start;
|
||||
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) {
|
||||
|
||||
@@ -262,7 +262,7 @@ body {
|
||||
background: white;
|
||||
width: 100%;
|
||||
max-width: 650px;
|
||||
padding: 35px;
|
||||
padding: 24px 24px 20px;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||
position: relative;
|
||||
@@ -318,6 +318,262 @@ body {
|
||||
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 {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
|
||||
@@ -5,6 +5,7 @@ let editingMembers = [];
|
||||
let collapsedUnits = new Set();
|
||||
let isListMode = false;
|
||||
let emptyStateMessage = '서버에 조직 데이터가 없습니다. 상단의 업로드 버튼으로 초기 데이터를 넣어주세요.';
|
||||
let photoPreviewObjectUrl = null;
|
||||
|
||||
const levelOrder = ['부서', '그룹', '디비전', '팀', '셀'];
|
||||
const dropdownFields = ['소속회사', '직급', '직책', ...levelOrder];
|
||||
@@ -31,6 +32,26 @@ function cloneMembers(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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function resetPhotoPreviewObjectUrl() {
|
||||
if (photoPreviewObjectUrl) {
|
||||
URL.revokeObjectURL(photoPreviewObjectUrl);
|
||||
photoPreviewObjectUrl = null;
|
||||
}
|
||||
}
|
||||
|
||||
function toLegacyMember(item) {
|
||||
return rebuildMemberPath({
|
||||
_id: String(item.id),
|
||||
@@ -94,6 +115,17 @@ async function apiFetch(url, options = {}) {
|
||||
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) {
|
||||
members = items.map(toLegacyMember);
|
||||
if (selectedDept !== '전체' && !members.some((member) => member['부서'] === selectedDept)) {
|
||||
@@ -779,6 +811,66 @@ function toggleFlexibleTime(value) {
|
||||
document.getElementById('flexible-time-area').classList.toggle('hidden', value !== '유연근무제');
|
||||
}
|
||||
|
||||
function updatePhotoPreview(src, fallbackName) {
|
||||
const preview = document.getElementById('m-photo-preview');
|
||||
if (!preview) {
|
||||
return;
|
||||
}
|
||||
preview.src = src || getPhotoPlaceholder(fallbackName);
|
||||
}
|
||||
|
||||
function syncPhotoPreviewFromUrl() {
|
||||
const name = document.getElementById('m-name')?.value?.trim() || '';
|
||||
const url = document.getElementById('m-photo-hidden')?.value?.trim() || '';
|
||||
updatePhotoPreview(url, name);
|
||||
}
|
||||
|
||||
function handlePhotoFileChange(event) {
|
||||
const file = event.target.files?.[0];
|
||||
const fileName = document.getElementById('m-photo-file-name');
|
||||
const name = document.getElementById('m-name')?.value?.trim() || '';
|
||||
resetPhotoPreviewObjectUrl();
|
||||
|
||||
if (!file) {
|
||||
if (fileName) {
|
||||
fileName.textContent = '선택된 파일 없음';
|
||||
}
|
||||
syncPhotoPreviewFromUrl();
|
||||
return;
|
||||
}
|
||||
|
||||
if (fileName) {
|
||||
fileName.textContent = file.name;
|
||||
}
|
||||
photoPreviewObjectUrl = URL.createObjectURL(file);
|
||||
updatePhotoPreview(photoPreviewObjectUrl, name);
|
||||
}
|
||||
|
||||
function renderSeatPreviewCard(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) {
|
||||
const isBasic = tab === 'basic';
|
||||
document.getElementById('modal-sec-basic').classList.toggle('hidden', !isBasic);
|
||||
@@ -804,30 +896,31 @@ function openModal(id) {
|
||||
fieldsArea.className = 'flex flex-col items-center gap-6 py-4';
|
||||
fieldsArea.style.maxHeight = 'none';
|
||||
fieldsArea.innerHTML = `
|
||||
<div class="member-detail-top-row">
|
||||
<div class="relative w-32 h-32 rounded-full overflow-hidden border-4 border-indigo-100 shadow-lg">
|
||||
<img src="${member['사진'] || 'https://via.placeholder.com/120?text=Profile'}" class="w-full h-full object-cover">
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="member-detail-summary">
|
||||
<div>
|
||||
<h2 class="text-2xl font-black text-slate-800">${member['이름'] || ''}</h2>
|
||||
<p class="text-indigo-600 font-bold">${member['직급'] || '-'} / ${member['직책'] || '팀원'}</p>
|
||||
<p class="text-slate-400 text-xs mt-1 font-medium">${(member._path || []).map((path) => path.name).join(' > ')}</p>
|
||||
</div>
|
||||
<div class="w-full grid grid-cols-2 gap-3 mt-4">
|
||||
<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 class="member-inline-info-grid">
|
||||
<div class="member-inline-info-card">
|
||||
<label>전화번호</label>
|
||||
<strong>${member['전화번호'] || '정보 없음'}</strong>
|
||||
</div>
|
||||
<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 class="member-inline-info-card">
|
||||
<label>이메일</label>
|
||||
<strong>${member['이메일'] || '정보 없음'}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-slate-50 p-4 rounded-2xl border border-slate-100 col-span-2">
|
||||
<label class="text-[10px] text-slate-400 font-bold block mb-1">사무실 위치</label>
|
||||
<span class="text-sm font-black text-slate-700">${member['자리위치'] || '정보 없음'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full mt-2">
|
||||
${renderSeatPreviewCard(member['자리위치'] || '')}
|
||||
</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>';
|
||||
modal.style.display = 'flex';
|
||||
@@ -836,11 +929,11 @@ function openModal(id) {
|
||||
|
||||
document.getElementById('modal-title').innerText = id ? '구성원 정보 수정' : '신규 구성원 추가';
|
||||
fieldsArea.className = 'flex flex-col w-full';
|
||||
fieldsArea.style.maxHeight = '75vh';
|
||||
fieldsArea.style.overflowY = 'auto';
|
||||
fieldsArea.style.maxHeight = 'none';
|
||||
fieldsArea.style.overflowY = 'visible';
|
||||
|
||||
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) => {
|
||||
const uniqueValues = Array.from(new Set(sourceValues.map((item) => item[field]).filter(Boolean))).sort();
|
||||
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-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 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 || ''}">
|
||||
<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>
|
||||
<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>
|
||||
<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-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="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>
|
||||
<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>
|
||||
<input type="hidden" id="m-photo-hidden" value="${member['사진'] || ''}">
|
||||
<input type="hidden" id="m-seat-hidden" value="${member['자리위치'] || ''}">
|
||||
<div class="col-span-2 member-basic-top-row">
|
||||
<div class="member-photo-field">
|
||||
<label class="text-[11px] font-black text-slate-600 block">프로필 사진</label>
|
||||
<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>
|
||||
${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>` : '';
|
||||
footer.innerHTML = `
|
||||
${deleteBtn}
|
||||
@@ -908,6 +1038,7 @@ function openModal(id) {
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
resetPhotoPreviewObjectUrl();
|
||||
document.getElementById('modal').style.display = 'none';
|
||||
document.getElementById('modal-fields').className = 'grid grid-cols-2 gap-x-8 gap-y-5';
|
||||
document.getElementById('modal-fields').style.maxHeight = 'none';
|
||||
@@ -946,8 +1077,12 @@ async function saveMember() {
|
||||
member['근무시간'] = document.getElementById('m-worktime').value;
|
||||
member['전화번호'] = document.getElementById('m-phone').value.trim();
|
||||
member['이메일'] = document.getElementById('m-email').value.trim();
|
||||
member['자리위치'] = document.getElementById('m-seat').value.trim();
|
||||
member['사진'] = document.getElementById('m-photo').value.trim();
|
||||
member['자리위치'] = document.getElementById('m-seat-hidden').value.trim();
|
||||
member['사진'] = document.getElementById('m-photo-hidden').value.trim();
|
||||
const photoFile = document.getElementById('m-photo-file')?.files?.[0];
|
||||
if (photoFile) {
|
||||
member['사진'] = await uploadProfilePhoto(photoFile, member['이름']);
|
||||
}
|
||||
if (member['근무시간'] === '유연근무제') {
|
||||
member['유연근무_시작'] = document.getElementById('m-work-start').value;
|
||||
member['유연근무_종료'] = document.getElementById('m-work-end').value;
|
||||
|
||||
147
standalone-dashboard-localhost.html
Normal file
147
standalone-dashboard-localhost.html
Normal 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>
|
||||
Reference in New Issue
Block a user