From 8ac6aa6b72348eadfc86eeca70e605d04f055853 Mon Sep 17 00:00:00 2001 From: hyunho Date: Wed, 25 Mar 2026 17:34:37 +0900 Subject: [PATCH] =?UTF-8?q?=EC=82=AC=EB=B2=88=20=ED=95=84=EB=93=9C=20?= =?UTF-8?q?=EB=B0=8F=20=EC=83=81=EC=84=B8=20=EC=A0=95=EB=B3=B4=20=EB=B0=98?= =?UTF-8?q?=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/db.py | 126 ++++++++++++++-- backend/app/main.py | 267 ++++++++++++++++++++++++++-------- legacy/static/organization.js | 25 +++- 3 files changed, 347 insertions(+), 71 deletions(-) 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'' - - -def circle_svg(center_x: float, center_y: float, radius: float, stroke: str = "#cbd5e1", fill: str = "none") -> str: return ( - f'' + f'' + ) + + +def circle_svg( + center_x: float, + center_y: float, + radius: float, + stroke: str = "#475569", + fill: str = "none", + css_class: str = "seatmap-dxf-entity", +) -> str: + return ( + f'' ) def build_dxf_preview_svg( entities: list[ezdxf.entities.DXFGraphic], - chair_slots: list[dict[str, object]], bounds: tuple[float, float, float, float], ) -> str: min_x, min_y, width, height = bounds @@ -304,17 +429,25 @@ def build_dxf_preview_svg( svg_parts: list[str] = [] for entity in entities: - layer_name = entity.dxf.layer.lower() - if layer_name == "chair": - continue + layer_name = entity.dxf.layer + is_chair = is_chair_layer(layer_name) + css_class = "seatmap-dxf-chair-entity" if is_chair else "seatmap-dxf-entity" entity_type = entity.dxftype() - if entity_type in {"LINE", "LWPOLYLINE", "POLYLINE"}: - svg = line_svg(get_entity_points(entity)) + if entity_type in {"LINE", "LWPOLYLINE", "POLYLINE", "SPLINE", "ELLIPSE"}: + svg = line_svg(get_entity_points(entity), css_class=css_class) if svg: svg_parts.append(svg) elif entity_type == "CIRCLE": center = entity.dxf.center - svg_parts.append(circle_svg(float(center.x), float(center.y), float(entity.dxf.radius))) + svg_parts.append( + circle_svg( + float(center.x), + float(center.y), + float(entity.dxf.radius), + fill="none", + css_class=css_class, + ) + ) elif entity_type == "ARC": center = entity.dxf.center radius = float(entity.dxf.radius) @@ -326,25 +459,14 @@ def build_dxf_preview_svg( end_y = float(center.y) + radius * math.sin(end_angle) large_arc = 1 if abs(float(entity.dxf.end_angle) - float(entity.dxf.start_angle)) > 180 else 0 svg_parts.append( - f'' + f'' ) - for slot in chair_slots: - svg_parts.append( - circle_svg( - float(slot["x"]), - float(slot["y"]), - 10, - stroke="#0f766e", - fill="rgba(45, 212, 191, 0.22)", - ) - ) - view_box = f"{min_x:.2f} {-max_y:.2f} {max(width, 1.0):.2f} {max(height, 1.0):.2f}" return ( f'' - '' + '' + "".join(svg_parts) + "" ) @@ -357,42 +479,44 @@ def parse_dxf_layout(file_path: Path) -> tuple[dict[str, object], list[dict[str, try: document, _ = recover.readfile(file_path) except Exception as exc: - with file_path.open("rb") as source: - header = source.read(64) - header_text = header.decode("latin-1", errors="ignore") - if header.startswith(b"AC10") or "AutoCAD Binary DXF" in header_text: + kind, preview = inspect_dxf_header(file_path) + if kind == "binary_dxf": raise HTTPException( status_code=400, - detail="DXF 파일을 해석하지 못했습니다. binary DXF 또는 손상된 DXF일 수 있습니다. 가능하면 ASCII DXF로 다시 저장해 업로드하세요.", + detail=f"Binary DXF로 보이지만 해석에 실패했습니다. 가능하면 ASCII DXF로 다시 저장해 업로드하세요. 헤더={preview}", + ) from exc + if kind == "dwg_or_dwg_like": + raise HTTPException( + status_code=400, + detail=f"업로드한 파일은 DWG 계열 헤더(AC10xx)로 보입니다. DWG가 아니라 ASCII DXF로 다시 저장해 업로드하세요. 헤더={preview}", + ) from exc + if kind == "ascii_dxf": + raise HTTPException( + status_code=400, + detail=f"ASCII DXF로 보이지만 구조를 해석하지 못했습니다. 도면을 다른 DXF 버전으로 다시 저장해보세요. 헤더={preview}", ) from exc raise HTTPException( status_code=400, - detail="업로드한 파일이 DXF 형식으로 읽히지 않습니다. DWG 파일이거나 확장자만 dxf로 바뀐 파일일 수 있습니다.", + detail=f"업로드한 파일 형식을 판별하지 못했습니다. 확장자만 dxf인 파일일 수 있습니다. 헤더={preview}", ) from exc modelspace = document.modelspace() - all_entities = [entity for entity in modelspace if entity.dxftype() in {"LINE", "LWPOLYLINE", "POLYLINE", "CIRCLE", "ARC", "POINT", "INSERT"}] - points: list[tuple[float, float]] = [] + 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] = [] + for entity in base_entities: + all_entities.extend(iter_render_entities(entity)) chair_entities: list[ezdxf.entities.DXFGraphic] = [] + chair_points: list[tuple[float, float]] = [] for entity in all_entities: - entity_points = get_entity_points(entity) - if entity_points: - points.extend(entity_points) - if entity.dxf.layer.lower() == "chair": + if is_chair_layer(entity.dxf.layer): chair_entities.append(entity) + chair_points.extend(get_entity_points(entity)) if not chair_entities: - raise HTTPException(status_code=400, detail="DXF 파일에서 chair 레이어를 찾지 못했습니다.") + raise HTTPException(status_code=400, detail="DXF 파일에서 chair 계열 레이어를 찾지 못했습니다.") - if not points: + if not chair_points: raise HTTPException(status_code=400, detail="DXF 좌표를 해석하지 못했습니다.") - 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) - width = max(max_x - min_x, 1.0) - height = max(max_y - min_y, 1.0) - slots: list[dict[str, object]] = [] for index, entity in enumerate(sorted(chair_entities, key=lambda item: (-(get_entity_center(item) or (0.0, 0.0))[1], (get_entity_center(item) or (0.0, 0.0))[0]))): center = get_entity_center(entity) @@ -412,7 +536,29 @@ def parse_dxf_layout(file_path: Path) -> tuple[dict[str, object], list[dict[str, if not slots: raise HTTPException(status_code=400, detail="chair 레이어에서 좌석 위치를 추출하지 못했습니다.") - preview_svg = build_dxf_preview_svg(all_entities, slots, (min_x, min_y, width, height)) + slot_points = [(float(slot["x"]), float(slot["y"])) for slot in slots] + focus_bounds = compute_focus_bounds(slot_points) + visible_entities: list[ezdxf.entities.DXFGraphic] = [] + visible_points: list[tuple[float, float]] = [] + for entity in all_entities: + entity_bounds = get_entity_bounds(entity) + if entity_bounds is None: + continue + if bounds_intersect(entity_bounds, focus_bounds): + visible_entities.append(entity) + visible_points.extend(get_entity_points(entity)) + + if not visible_entities or not visible_points: + visible_entities = all_entities + visible_points = chair_points + + focus_min_x, focus_min_y, focus_max_x, focus_max_y = focus_bounds + min_x = focus_min_x + min_y = focus_min_y + width = max(focus_max_x - focus_min_x, 1.0) + height = max(focus_max_y - focus_min_y, 1.0) + + preview_svg = build_dxf_preview_svg(visible_entities, (min_x, min_y, width, height)) metadata = { "source_type": "dxf", "view_box_min_x": round(min_x, 3), @@ -421,7 +567,7 @@ def parse_dxf_layout(file_path: Path) -> tuple[dict[str, object], list[dict[str, "view_box_height": round(height, 3), "preview_svg": preview_svg, "grid_rows": 1, - "grid_cols": max(len(slots), 1), + "grid_cols": 1, "image_width": None, "image_height": None, "cell_gap": 0, @@ -581,10 +727,10 @@ def replace_members(items: list[MemberPayload]) -> list[dict[str, object]]: cur.execute( """ INSERT INTO members ( - name, company, rank, role, department, grp, division, team, cell, + name, employee_id, company, rank, role, department, grp, division, team, cell, work_status, work_time, phone, email, seat_label, photo_url, sort_order ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) """, serialize_member_payload(item, index), ) @@ -702,11 +848,11 @@ def create_member(payload: MemberPayload) -> dict[str, object]: cur.execute( """ INSERT INTO members ( - name, company, rank, role, department, grp, division, team, cell, + name, employee_id, company, rank, role, department, grp, division, team, cell, work_status, work_time, phone, email, seat_label, photo_url, sort_order ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) - RETURNING id, name, company, rank, role, department, grp, division, team, cell, + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + RETURNING 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 """, @@ -730,6 +876,7 @@ def update_member(member_id: int, payload: MemberPayload) -> dict[str, object]: """ UPDATE members SET name = %s, + employee_id = %s, company = %s, rank = %s, role = %s, @@ -747,7 +894,7 @@ def update_member(member_id: int, payload: MemberPayload) -> dict[str, object]: sort_order = COALESCE(%s, sort_order), updated_at = NOW() WHERE id = %s - RETURNING id, name, company, rank, role, department, grp, division, team, cell, + RETURNING 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 """, @@ -821,8 +968,6 @@ async def create_dxf_seat_map(file: UploadFile = File(...), name: str = Form(... try: metadata, slots = parse_dxf_layout(target) except Exception: - if target.exists(): - target.unlink(missing_ok=True) raise payload = SeatMapPayload( @@ -838,7 +983,7 @@ async def create_dxf_seat_map(file: UploadFile = File(...), name: str = Form(... image_width=None, image_height=None, grid_rows=1, - grid_cols=max(len(slots), 1), + grid_cols=1, cell_gap=0, is_active=True, ) diff --git a/legacy/static/organization.js b/legacy/static/organization.js index ce22ffe..681eddd 100644 --- a/legacy/static/organization.js +++ b/legacy/static/organization.js @@ -36,6 +36,7 @@ function toLegacyMember(item) { _id: String(item.id), id: item.id, 이름: item.name || '', + 사번: item.employee_id || '', 소속회사: item.company || '', 직급: item.rank || '', 직책: item.role || '', @@ -57,6 +58,7 @@ function toLegacyMember(item) { function toApiMember(member, sortOrder) { return { name: member['이름'] || '', + employee_id: member['사번'] || '', company: member['소속회사'] || '', rank: member['직급'] || '', role: member['직책'] || '', @@ -381,6 +383,12 @@ function updateStatsTable() { const columns = Object.keys(rankGroups); const stats = {}; + const companyLabelHtml = (company) => ` + + + ${company} + + `; companies.forEach((company) => { stats[company] = {}; @@ -418,7 +426,7 @@ function updateStatsTable() { let grandTotal = 0; companies.forEach((company) => { - html += `${company}${columns.map((column) => { + html += `${companyLabelHtml(company)}${columns.map((column) => { colSums[column] += stats[company][column]; return `${stats[company][column] || '-'}`; }).join('')}${stats[company]._total}`; @@ -515,6 +523,7 @@ function updateFabMenu() { let html = ''; html += ''; if (isAdmin) { + html += ''; html += ''; html += ''; html += ''; @@ -522,6 +531,14 @@ function updateFabMenu() { menu.innerHTML = html; } +function openSeatMapView(event) { + event.stopPropagation(); + document.getElementById('fab-container').classList.remove('active'); + if (window.parent && window.parent !== window) { + window.parent.postMessage({ type: 'open-seatmap' }, '*'); + } +} + function triggerUpload(event) { if (event) { event.stopPropagation(); @@ -796,6 +813,10 @@ function openModal(id) {

${(member._path || []).map((path) => path.name).join(' > ')}

+
+ + ${member['사번'] || '정보 없음'} +
@@ -872,6 +893,7 @@ function openModal(id) {