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 __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
@@ -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("'", "'");
|
.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) {
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|||||||
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