diff --git a/backend/app/db.py b/backend/app/db.py
index 174194d..da2b11a 100755
--- a/backend/app/db.py
+++ b/backend/app/db.py
@@ -12,6 +12,7 @@ SCHEMA_SQL = """
CREATE TABLE IF NOT EXISTS members (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
+ employee_id TEXT,
company TEXT,
rank TEXT,
role TEXT,
@@ -31,24 +32,131 @@ CREATE TABLE IF NOT EXISTS members (
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-CREATE TABLE IF NOT EXISTS seat_positions (
- member_id INTEGER PRIMARY KEY REFERENCES members(id) ON DELETE CASCADE,
- x INTEGER NOT NULL DEFAULT 0,
- y INTEGER NOT NULL DEFAULT 0,
- floor_label TEXT,
+CREATE TABLE IF NOT EXISTS seat_maps (
+ id SERIAL PRIMARY KEY,
+ name TEXT NOT NULL,
+ image_url TEXT NOT NULL,
+ source_type TEXT NOT NULL DEFAULT 'image',
+ source_url TEXT,
+ preview_svg TEXT,
+ view_box_min_x DOUBLE PRECISION,
+ view_box_min_y DOUBLE PRECISION,
+ view_box_width DOUBLE PRECISION,
+ view_box_height DOUBLE PRECISION,
+ image_width INTEGER,
+ image_height INTEGER,
+ grid_rows INTEGER NOT NULL,
+ grid_cols INTEGER NOT NULL,
+ cell_gap INTEGER NOT NULL DEFAULT 0,
+ is_active BOOLEAN NOT NULL DEFAULT FALSE,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-CREATE TABLE IF NOT EXISTS snapshots (
+CREATE TABLE IF NOT EXISTS seat_positions (
+ member_id INTEGER PRIMARY KEY REFERENCES members(id) ON DELETE CASCADE,
+ seat_map_id INTEGER REFERENCES seat_maps(id) ON DELETE CASCADE,
+ seat_slot_id INTEGER,
+ row_index INTEGER NOT NULL DEFAULT 0,
+ col_index INTEGER NOT NULL DEFAULT 0,
+ seat_label TEXT,
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE TABLE IF NOT EXISTS seat_slots (
id SERIAL PRIMARY KEY,
- snapshot_month TEXT NOT NULL,
- file_path TEXT NOT NULL,
- created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+ seat_map_id INTEGER NOT NULL REFERENCES seat_maps(id) ON DELETE CASCADE,
+ slot_key TEXT NOT NULL,
+ label TEXT NOT NULL,
+ x DOUBLE PRECISION NOT NULL,
+ y DOUBLE PRECISION NOT NULL,
+ rotation DOUBLE PRECISION NOT NULL DEFAULT 0,
+ layer_name TEXT NOT NULL DEFAULT 'chair',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ UNIQUE (seat_map_id, slot_key)
);
"""
MIGRATION_SQL = """
+ALTER TABLE members ADD COLUMN IF NOT EXISTS employee_id TEXT;
ALTER TABLE members ADD COLUMN IF NOT EXISTS sort_order INTEGER NOT NULL DEFAULT 0;
+ALTER TABLE seat_positions ADD COLUMN IF NOT EXISTS seat_map_id INTEGER REFERENCES seat_maps(id) ON DELETE CASCADE;
+ALTER TABLE seat_positions ADD COLUMN IF NOT EXISTS seat_slot_id INTEGER;
+ALTER TABLE seat_positions ADD COLUMN IF NOT EXISTS row_index INTEGER NOT NULL DEFAULT 0;
+ALTER TABLE seat_positions ADD COLUMN IF NOT EXISTS col_index INTEGER NOT NULL DEFAULT 0;
+ALTER TABLE seat_positions ADD COLUMN IF NOT EXISTS seat_label TEXT;
+ALTER TABLE seat_positions ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
+ALTER TABLE seat_maps ADD COLUMN IF NOT EXISTS source_type TEXT NOT NULL DEFAULT 'image';
+ALTER TABLE seat_maps ADD COLUMN IF NOT EXISTS source_url TEXT;
+ALTER TABLE seat_maps ADD COLUMN IF NOT EXISTS preview_svg TEXT;
+ALTER TABLE seat_maps ADD COLUMN IF NOT EXISTS view_box_min_x DOUBLE PRECISION;
+ALTER TABLE seat_maps ADD COLUMN IF NOT EXISTS view_box_min_y DOUBLE PRECISION;
+ALTER TABLE seat_maps ADD COLUMN IF NOT EXISTS view_box_width DOUBLE PRECISION;
+ALTER TABLE seat_maps ADD COLUMN IF NOT EXISTS view_box_height DOUBLE PRECISION;
+ALTER TABLE seat_maps ADD COLUMN IF NOT EXISTS image_width INTEGER;
+ALTER TABLE seat_maps ADD COLUMN IF NOT EXISTS image_height INTEGER;
+ALTER TABLE seat_maps ADD COLUMN IF NOT EXISTS cell_gap INTEGER NOT NULL DEFAULT 0;
+ALTER TABLE seat_maps ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT FALSE;
+ALTER TABLE seat_maps ALTER COLUMN image_url DROP NOT NULL;
+
+CREATE TABLE IF NOT EXISTS seat_slots (
+ id SERIAL PRIMARY KEY,
+ seat_map_id INTEGER NOT NULL REFERENCES seat_maps(id) ON DELETE CASCADE,
+ slot_key TEXT NOT NULL,
+ label TEXT NOT NULL,
+ x DOUBLE PRECISION NOT NULL,
+ y DOUBLE PRECISION NOT NULL,
+ rotation DOUBLE PRECISION NOT NULL DEFAULT 0,
+ layer_name TEXT NOT NULL DEFAULT 'chair',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ UNIQUE (seat_map_id, slot_key)
+);
+
+DO $$
+BEGIN
+ IF EXISTS (
+ SELECT 1
+ FROM information_schema.columns
+ WHERE table_name = 'seat_positions' AND column_name = 'x'
+ ) THEN
+ EXECUTE 'UPDATE seat_positions SET row_index = COALESCE(y, row_index, 0), col_index = COALESCE(x, col_index, 0) WHERE seat_map_id IS NULL';
+ END IF;
+END $$;
+
+DO $$
+BEGIN
+ IF EXISTS (
+ SELECT 1
+ FROM information_schema.columns
+ WHERE table_name = 'seat_positions' AND column_name = 'floor_label'
+ ) THEN
+ EXECUTE 'UPDATE seat_positions SET seat_label = COALESCE(seat_label, floor_label) WHERE seat_label IS NULL';
+ END IF;
+END $$;
+
+CREATE UNIQUE INDEX IF NOT EXISTS seat_positions_map_cell_idx
+ON seat_positions (seat_map_id, row_index, col_index)
+WHERE seat_map_id IS NOT NULL;
+
+CREATE UNIQUE INDEX IF NOT EXISTS seat_positions_slot_idx
+ON seat_positions (seat_slot_id)
+WHERE seat_slot_id IS NOT NULL;
+
+DO $$
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1
+ FROM information_schema.table_constraints
+ WHERE constraint_name = 'seat_positions_seat_slot_id_fkey'
+ AND table_name = 'seat_positions'
+ ) THEN
+ ALTER TABLE seat_positions
+ ADD CONSTRAINT seat_positions_seat_slot_id_fkey
+ FOREIGN KEY (seat_slot_id) REFERENCES seat_slots(id) ON DELETE CASCADE;
+ END IF;
+END $$;
"""
diff --git a/backend/app/main.py b/backend/app/main.py
index abd15cb..3cc5795 100755
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -37,6 +37,7 @@ LEGACY_STATIC_DIR = LEGACY_DIR / "static"
class MemberPayload(BaseModel):
id: int | None = None
name: str = Field(min_length=1)
+ employee_id: str = ""
company: str = ""
rank: str = ""
role: str = ""
@@ -91,6 +92,8 @@ class SeatLayoutPayload(BaseModel):
LEGACY_HEADER_MAP = {
"이름": "name",
"name": "name",
+ "tag": "employee_id",
+ "employee_id": "employee_id",
"소속회사": "company",
"co": "company",
"company": "company",
@@ -131,6 +134,7 @@ LEGACY_HEADER_MAP = {
def serialize_member_payload(item: MemberPayload, sort_order: int) -> tuple[object, ...]:
return (
item.name.strip(),
+ item.employee_id.strip(),
item.company.strip(),
item.rank.strip(),
item.role.strip(),
@@ -154,7 +158,7 @@ def fetch_members() -> list[dict[str, object]]:
with conn.cursor() as cur:
cur.execute(
"""
- SELECT id, name, company, rank, role, department, grp, division, team, cell,
+ SELECT id, name, employee_id, company, rank, role, department, grp, division, team, cell,
work_status, work_time, phone, email, seat_label, photo_url,
sort_order, created_at, updated_at
FROM members
@@ -235,6 +239,48 @@ def compute_slot_label(index: int) -> str:
return f"CHAIR-{index + 1:03d}"
+def is_chair_layer(layer_name: str) -> bool:
+ raw = layer_name.strip().lower()
+ compact = raw.replace("-", "").replace("_", "").replace(" ", "")
+ return raw in {"chair", "_chair", "-chair"} or compact.endswith("chair")
+
+
+def inspect_dxf_header(file_path: Path) -> tuple[str, str]:
+ with file_path.open("rb") as source:
+ header = source.read(128)
+ header_text = header.decode("latin-1", errors="ignore").replace("\x00", "")
+ preview = header[:32].hex(" ")
+
+ if header_text.startswith("AutoCAD Binary DXF"):
+ return ("binary_dxf", preview)
+ if header_text.startswith("0\nSECTION") or header_text.startswith("0\r\nSECTION"):
+ return ("ascii_dxf", preview)
+ if header.startswith(b"AC10"):
+ return ("dwg_or_dwg_like", preview)
+ return ("unknown", preview)
+
+
+def iter_render_entities(entity: ezdxf.entities.DXFGraphic, inherited_layer: str | None = None, depth: int = 0) -> list[ezdxf.entities.DXFGraphic]:
+ if depth > 6:
+ return []
+ entity_type = entity.dxftype()
+ current_layer = inherited_layer or entity.dxf.layer
+ if entity_type == "INSERT":
+ expanded: list[ezdxf.entities.DXFGraphic] = []
+ try:
+ for child in entity.virtual_entities():
+ child_layer = child.dxf.layer
+ if child_layer == "0":
+ child.dxf.layer = current_layer
+ expanded.extend(iter_render_entities(child, inherited_layer=current_layer, depth=depth + 1))
+ except Exception:
+ return []
+ return expanded
+ if inherited_layer and entity.dxf.layer == "0":
+ entity.dxf.layer = inherited_layer
+ return [entity]
+
+
def get_entity_points(entity: ezdxf.entities.DXFGraphic) -> list[tuple[float, float]]:
entity_type = entity.dxftype()
if entity_type == "LINE":
@@ -263,6 +309,24 @@ def get_entity_points(entity: ezdxf.entities.DXFGraphic) -> list[tuple[float, fl
if entity_type == "POINT":
location = entity.dxf.location
return [(float(location.x), float(location.y))]
+ if entity_type == "SPLINE":
+ try:
+ return [(float(point[0]), float(point[1])) for point in entity.flattening(2)]
+ except Exception:
+ return []
+ if entity_type == "ELLIPSE":
+ try:
+ return [(float(point[0]), float(point[1])) for point in entity.flattening(2)]
+ except Exception:
+ center = entity.dxf.center
+ major_axis = entity.dxf.major_axis
+ ratio = float(entity.dxf.ratio)
+ radius_x = math.hypot(float(major_axis.x), float(major_axis.y))
+ radius_y = radius_x * ratio
+ return [
+ (float(center.x - radius_x), float(center.y - radius_y)),
+ (float(center.x + radius_x), float(center.y + radius_y)),
+ ]
if entity_type == "INSERT":
insert = entity.dxf.insert
return [(float(insert.x), float(insert.y))]
@@ -280,23 +344,84 @@ def get_entity_center(entity: ezdxf.entities.DXFGraphic) -> tuple[float, float]
return ((min_x + max_x) / 2.0, (min_y + max_y) / 2.0)
-def line_svg(points: list[tuple[float, float]]) -> str:
+def get_entity_bounds(entity: ezdxf.entities.DXFGraphic) -> tuple[float, float, float, float] | None:
+ points = get_entity_points(entity)
+ if not points:
+ return None
+ min_x = min(point[0] for point in points)
+ max_x = max(point[0] for point in points)
+ min_y = min(point[1] for point in points)
+ max_y = max(point[1] for point in points)
+ return (min_x, min_y, max_x, max_y)
+
+
+def compute_bounds_from_points(points: list[tuple[float, float]]) -> tuple[float, float, float, float]:
+ min_x = min(point[0] for point in points)
+ max_x = max(point[0] for point in points)
+ min_y = min(point[1] for point in points)
+ max_y = max(point[1] for point in points)
+ return (min_x, min_y, max(max_x - min_x, 1.0), max(max_y - min_y, 1.0))
+
+
+def percentile(values: list[float], ratio: float) -> float:
+ if not values:
+ return 0.0
+ ordered = sorted(values)
+ index = max(0, min(len(ordered) - 1, round((len(ordered) - 1) * ratio)))
+ return float(ordered[index])
+
+
+def compute_focus_bounds(slot_points: list[tuple[float, float]]) -> tuple[float, float, float, float]:
+ x_values = [point[0] for point in slot_points]
+ y_values = [point[1] for point in slot_points]
+ min_x = percentile(x_values, 0.02)
+ max_x = percentile(x_values, 0.98)
+ min_y = percentile(y_values, 0.02)
+ max_y = percentile(y_values, 0.98)
+ width = max(max_x - min_x, 1.0)
+ height = max(max_y - min_y, 1.0)
+ pad_x = max(width * 0.08, 500.0)
+ pad_y = max(height * 0.08, 500.0)
+ return (min_x - pad_x, min_y - pad_y, max_x + pad_x, max_y + pad_y)
+
+
+def bounds_intersect(bounds: tuple[float, float, float, float], focus_bounds: tuple[float, float, float, float]) -> bool:
+ min_x, min_y, max_x, max_y = bounds
+ focus_min_x, focus_min_y, focus_max_x, focus_max_y = focus_bounds
+ return not (
+ max_x < focus_min_x
+ or min_x > focus_max_x
+ or max_y < focus_min_y
+ or min_y > focus_max_y
+ )
+
+
+def line_svg(points: list[tuple[float, float]], css_class: str = "seatmap-dxf-entity") -> str:
if len(points) < 2:
return ""
coordinates = " ".join(f"{x:.2f},{-y:.2f}" for x, y in points)
- return f'
${(member._path || []).map((path) => path.name).join(' > ')}