feat: split seatmap admin and readonly flows

This commit is contained in:
hyunho
2026-03-26 11:32:33 +09:00
parent 8efb5da65f
commit 69a14fab51
7 changed files with 852 additions and 182 deletions

View File

@@ -986,7 +986,13 @@ def build_center_chair_viewer_html(layout: dict[str, object]) -> str:
for slot in layout.get("slots", [])
if slot.get("id") is not None and slot.get("slot_key") is not None
}
members_by_id = {
int(member["id"]): member
for member in layout.get("members", [])
if member.get("id") is not None
}
placed_keys: list[str] = []
assignment_items: list[dict[str, object]] = []
for placement in layout.get("placements", []):
slot_id = placement.get("seat_slot_id")
if slot_id is None:
@@ -994,9 +1000,20 @@ def build_center_chair_viewer_html(layout: dict[str, object]) -> str:
slot_key = slot_key_by_id.get(int(slot_id))
if slot_key:
placed_keys.append(slot_key)
member = members_by_id.get(int(placement.get("member_id") or 0))
if member:
assignment_items.append(
{
"key": slot_key,
"member_id": int(member["id"]),
"name": str(member.get("name") or "-"),
"rank": str(member.get("rank") or "-"),
}
)
seat_map = layout.get("seat_map") or {}
placed_literal = json.dumps(sorted(set(placed_keys)), ensure_ascii=False, separators=(",", ":"))
assignments_literal = json.dumps(assignment_items, ensure_ascii=False, separators=(",", ":"))
if seat_map.get("source_type") == "fixed_html":
html = parse_fixed_office_template()["html"]
else:
@@ -1080,6 +1097,7 @@ def build_center_chair_viewer_html(layout: dict[str, object]) -> str:
)
bridge_script = """
<style>
#fit-btn { display: none !important; }
#clear-btn { display: none !important; }
.seat-popup {
position: absolute;
@@ -1126,12 +1144,12 @@ def build_center_chair_viewer_html(layout: dict[str, object]) -> str:
}
function setViewerMode(mode) {
viewerMode = mode === "compact" ? "compact" : "default";
viewerMode = mode === "compact" || mode === "readonly" ? mode : "default";
const head = document.querySelector(".viewer-head");
const actions = document.querySelector(".viewer-actions");
if (head) head.style.display = viewerMode === "compact" ? "none" : "";
if (actions) actions.style.display = viewerMode === "compact" ? "none" : "";
if (viewerMode === "compact") {
if (actions) actions.style.display = viewerMode !== "default" ? "none" : "";
if (viewerMode !== "default") {
hideSeatPopup();
selectedChairKey = null;
}
@@ -1147,7 +1165,7 @@ def build_center_chair_viewer_html(layout: dict[str, object]) -> str:
<strong>${assignment.name}</strong>
<div>직급: ${assignment.rank || "-"}</div>
<div>상태: 배치완료</div>
<button type="button" data-seatmap-delete="${chairKey}">자리 비우기</button>
${viewerMode === "default" ? `<button type="button" data-seatmap-delete="${chairKey}">자리 비우기</button>` : ""}
`;
popup.style.left = `${x + 18}px`;
popup.style.top = `${y + 18}px`;
@@ -1219,6 +1237,44 @@ def build_center_chair_viewer_html(layout: dict[str, object]) -> str:
tooltip.classList.add("visible");
};
const originalDraw = draw;
draw = function drawWithAssignments() {
originalDraw();
if (!seatAssignments.size) return;
const rect = canvas.getBoundingClientRect();
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
ctx.textBaseline = "middle";
for (const chair of chairGeometry) {
const assignment = getAssignment(chair.key);
if (!assignment) continue;
const center = worldToScreen((chair.minX + chair.maxX) / 2, (chair.minY + chair.maxY) / 2);
if (center.x < -120 || center.x > rect.width + 120 || center.y < -50 || center.y > rect.height + 50) continue;
const primary = `${assignment.name}`;
const secondary = `${assignment.rank || "-"}`;
ctx.font = "700 12px Pretendard, sans-serif";
const primaryWidth = ctx.measureText(primary).width;
ctx.font = "600 10px Pretendard, sans-serif";
const secondaryWidth = ctx.measureText(secondary).width;
const boxWidth = Math.max(primaryWidth, secondaryWidth) + 20;
const boxHeight = 34;
const boxX = center.x - boxWidth / 2;
const boxY = center.y - 46;
ctx.fillStyle = "rgba(255,255,255,0.96)";
ctx.strokeStyle = "rgba(220,38,38,0.18)";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.roundRect(boxX, boxY, boxWidth, boxHeight, 10);
ctx.fill();
ctx.stroke();
ctx.fillStyle = "#111827";
ctx.font = "700 12px Pretendard, sans-serif";
ctx.fillText(primary, boxX + 10, boxY + 12);
ctx.fillStyle = "#6b7280";
ctx.font = "600 10px Pretendard, sans-serif";
ctx.fillText(secondary, boxX + 10, boxY + 25);
}
};
window.__mhSeatmap = {
getCanvas() { return document.getElementById("canvas"); },
pickChairAt(x, y) { return typeof pickChair === "function" ? pickChair(x, y) : null; },
@@ -1232,6 +1288,8 @@ def build_center_chair_viewer_html(layout: dict[str, object]) -> str:
setViewerMode,
};
setAssignments(__INITIAL_ASSIGNMENTS__);
canvas.addEventListener("click", (event) => {
if (viewerMode === "compact") return;
const rect = canvas.getBoundingClientRect();
@@ -1283,6 +1341,7 @@ def build_center_chair_viewer_html(layout: dict[str, object]) -> str:
});
</script>
"""
bridge_script = bridge_script.replace("__INITIAL_ASSIGNMENTS__", assignments_literal, 1)
html = html.replace("</body>", f"{bridge_script}\n</body>", 1)
return html
@@ -1381,21 +1440,114 @@ def get_member_count() -> int:
return int(cur.fetchone()["count"])
def merge_import_member(item: MemberPayload, existing: dict[str, object] | None) -> MemberPayload:
if existing is None:
return item
payload = item.model_copy(deep=True)
if not payload.photo_url.strip():
payload.photo_url = str(existing.get("photo_url") or "")
if not payload.seat_label.strip():
payload.seat_label = str(existing.get("seat_label") or "")
return payload
def pick_existing_member(
item: MemberPayload,
existing_by_employee_id: dict[str, list[dict[str, object]]],
existing_by_name: dict[str, list[dict[str, object]]],
matched_ids: set[int],
) -> dict[str, object] | None:
employee_id = item.employee_id.strip()
if employee_id:
for candidate in existing_by_employee_id.get(employee_id, []):
candidate_id = int(candidate["id"])
if candidate_id not in matched_ids:
return candidate
name = item.name.strip()
if name:
available = [
candidate
for candidate in existing_by_name.get(name, [])
if int(candidate["id"]) not in matched_ids
]
if len(available) == 1:
return available[0]
return None
def replace_members(items: list[MemberPayload]) -> list[dict[str, object]]:
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute("TRUNCATE TABLE members RESTART IDENTITY CASCADE")
cur.execute(
"""
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
ORDER BY id ASC
"""
)
existing_members = cur.fetchall()
existing_by_employee_id: dict[str, list[dict[str, object]]] = {}
existing_by_name: dict[str, list[dict[str, object]]] = {}
for member in existing_members:
employee_id = str(member.get("employee_id") or "").strip()
name = str(member.get("name") or "").strip()
if employee_id:
existing_by_employee_id.setdefault(employee_id, []).append(member)
if name:
existing_by_name.setdefault(name, []).append(member)
matched_ids: set[int] = set()
for index, item in enumerate(items):
existing = pick_existing_member(item, existing_by_employee_id, existing_by_name, matched_ids)
merged_item = merge_import_member(item, existing)
if existing is None:
cur.execute(
"""
INSERT INTO members (
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, %s)
""",
serialize_member_payload(merged_item, index),
)
continue
matched_ids.add(int(existing["id"]))
cur.execute(
"""
INSERT INTO members (
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, %s)
UPDATE members
SET name = %s,
employee_id = %s,
company = %s,
rank = %s,
role = %s,
department = %s,
grp = %s,
division = %s,
team = %s,
cell = %s,
work_status = %s,
work_time = %s,
phone = %s,
email = %s,
seat_label = %s,
photo_url = %s,
sort_order = %s,
updated_at = NOW()
WHERE id = %s
""",
serialize_member_payload(item, index),
(*serialize_member_payload(merged_item, index), int(existing["id"])),
)
stale_ids = [int(member["id"]) for member in existing_members if int(member["id"]) not in matched_ids]
if stale_ids:
cur.execute("DELETE FROM members WHERE id = ANY(%s)", (stale_ids,))
conn.commit()
return fetch_members()