/* ============================================================================= * M02_SO_Options.ts * 좌측 「구조물 옵션 보이기」 줄(구조물 집계 양식 아래 별도 · 시스템 관리자만) — 누르면 메인 칸에 * 종류 목록(군별) → 고른 종류의 **그룹**(PLAN 46-2 · `M02_SO_Groups.ts`) + 옵션 줄 * (보이기 체크 · 라벨 · 키 작은 글 · B06 전용 · 필수 표시 · 들어갈 자리) · [저장] · [모두 보임]. * 체크 안 한 옵션은 B05 · B06 구조물 폼에서 숨음(정의 · 값은 그대로). * 서버 = `M02_SO_Options.py`(정의 「표시」 · 숨긴 것만) · `M02_SO_Groups.py`(정의 「그룹」). * ========================================================================== */ import { createButton, createTag, el, showToast } from "@ui/ui_template_elements"; import { StaleError } from "../M02_MasterTemplete_Api_Fetch"; import { fetchStructureOptionGroups, fetchStructureOptions, saveStructureOptionGroups, saveStructureOptions, type GroupsDoc, type OptionGroupDef, type OptionKind, type OptionsDoc, } from "./M02_SO_Api"; import { buildGroupBlock, buildPlaceSelect, namesOk } from "./M02_SO_Groups"; import "./M02_SO_Style.css"; import { to } from "./M02_SO_Text"; export interface OptionsSectionOptions { isAdmin: boolean; /** 저장 안 한 고침을 버릴지 확인 — 취소면 false */ confirmLeave: () => Promise; /** 메인 칸에 화면을 띄움 — 고침 표시 손잡이를 돌려받음 */ show: (label: string, node: HTMLElement) => (dirty: boolean) => void; /** 화면을 열었음 — 좌측 다른 고름 표시를 지움 */ onOpen: () => void; } export interface OptionsSection { title: string; body: HTMLElement; /** 다른 화면을 열었음 — 고름 표시만 지움 */ clear: () => void; } const why = (error: unknown): string => (error instanceof Error ? error.message : ""); export function buildOptionsSection(opt: OptionsSectionOptions): OptionsSection { const row = el("button", { className: "m02-side__item ui-sidebar-row", text: to("Row"), attrs: { type: "button", "data-options-row": "" }, }); const body = el("div", { className: "m02-side__items ui-sidebar-list", children: [row] }); row.addEventListener("click", () => void open()); /** 이 화면이 열려 있는 동안 참 — 다른 것이 열리면 `clear` 가 내림 */ let opened = false; const mark = (on: boolean): void => { opened = on; row.classList.toggle("is-active", on); }; async function open(): Promise { if (opened || !(await opt.confirmLeave())) return; let doc: OptionsDoc; let groups: GroupsDoc; try { [doc, groups] = await Promise.all([fetchStructureOptions(), fetchStructureOptionGroups()]); } catch (error) { return showToast(to("Load_Failed", why(error)), "error"); } draw(doc, groups); } /** 메인 칸 화면 — `doc.표시` · `groups.그룹` 을 고칠 사본(저장 전까지 서버에 안 감)으로 쥠 */ function draw(doc: OptionsDoc, groupsDoc: GroupsDoc): void { let version = doc.판; let groupVersion = groupsDoc.판; const edit = opt.isAdmin; /** 숨긴 옵션 — 종류 → 키 집합 */ const hidden = new Map>(); const load = (src: OptionsDoc["표시"]): void => { hidden.clear(); for (const [id, keys] of Object.entries(src)) hidden.set(id, new Set(Object.keys(keys))); }; /** 그룹 — 종류 → 그룹[](고칠 사본) */ const groups = new Map(); const loadGroups = (src: GroupsDoc["그룹"]): void => { groups.clear(); for (const [id, list] of Object.entries(src)) groups.set( id, list.map((g) => ({ ...g, 옵션: [...g.옵션] })), ); }; load(doc.표시); loadGroups(groupsDoc.그룹); /** 종류 = 레지스트리 전부(옵션 없는 종류도 그룹을 둘 수 있음) */ let kinds = groupsDoc.종류; let current: OptionKind | null = null; let shownDirty = false; let groupDirty = false; const kindList = el("div", { className: "m02-opts__kinds" }); const optionList = el("div", { className: "m02-opts__list" }); const off = (id: string): number => hidden.get(id)?.size ?? 0; const groupList = (id: string): OptionGroupDef[] => { if (!groups.has(id)) groups.set(id, []); return groups.get(id)!; }; const paintKinds = (): void => { const parts: HTMLElement[] = []; let group = ""; for (const k of kinds) { if (k.group !== group) { group = k.group; parts.push( el("h4", { className: "m02-opts__group", text: group.length === 1 ? to("Group", group) : group, }), ); } const n = off(k.type_id); const g = groups.get(k.type_id)?.length ?? 0; const b = el("button", { className: `m02-opts__kind${k.type_id === current?.type_id ? " is-active" : ""}`, attrs: { type: "button", "data-options-kind": k.type_id }, children: [ el("span", { text: k.name }), ...(g ? [createTag(to("GroupTag", String(g)), "accent")] : []), ...(n ? [createTag(to("Hidden", String(n)), "warning")] : []), ...(k.enabled ? [] : [createTag(to("Off"), "neutral")]), ], }); b.addEventListener("click", () => { current = k; paintKinds(); paintOptions(); }); parts.push(b); } kindList.replaceChildren(...parts); }; /** 그룹을 고쳤음 — 고침 표시 · 종류 목록(그룹 수) · 오른쪽 다시 그림 */ const groupsChanged = (): void => { groupDirty = true; sync(); paintKinds(); paintOptions(); }; const paintOptions = (): void => { if (!current) { optionList.replaceChildren(el("p", { className: "m02-side__empty", text: to("Pick") })); return; } const kind = current; const list = groupList(kind.type_id); const rows = kind.options.map((o) => { const box = el("input", { attrs: { type: "checkbox", "data-options-key": o.key }, }) as HTMLInputElement; box.checked = !hidden.get(kind.type_id)?.has(o.key); box.disabled = !edit; box.addEventListener("change", () => { const set = hidden.get(kind.type_id) ?? new Set(); if (box.checked) set.delete(o.key); else set.add(o.key); hidden.set(kind.type_id, set); shownDirty = true; sync(); paintKinds(); }); const tags: HTMLElement[] = []; if (o.phase === "detail") tags.push(createTag(to("Detail"), "accent")); if (o.required && o.phase === "b05") { const tag = createTag(to("Required"), "danger"); tag.title = to("RequiredTip"); tags.push(tag); } return el("div", { className: "m02-opts__option", children: [ el("label", { className: "m02-opts__check", children: [ box, el("span", { text: o.label }), el("small", { className: "m02-opts__key", text: o.key }), ...tags, ], }), buildPlaceSelect(list, o.key, edit, groupsChanged), ], }); }); optionList.replaceChildren( el("h3", { className: "m02-options__head", text: kind.name }), buildGroupBlock({ list, edit, changed: groupsChanged }), el("h4", { className: "m02-opts__group", text: to("OptionHead") }), ...(rows.length ? rows : [el("p", { className: "m02-side__empty", text: to("NoOptionKind") })]), ); }; const save = createButton({ label: to("Save"), variant: "filled" }); const all = createButton({ label: to("ShowAll"), variant: "ghost" }); all.addEventListener("click", () => { if (![...hidden.values()].some((s) => s.size)) return; hidden.clear(); shownDirty = true; sync(); paintKinds(); paintOptions(); }); /** 다시 받기 — 낡은 판으로 막혔을 때 둘 다 새로 */ const reload = async (): Promise => { try { const [fresh, freshGroups] = await Promise.all([ fetchStructureOptions(), fetchStructureOptionGroups(), ]); version = fresh.판; groupVersion = freshGroups.판; kinds = freshGroups.종류; load(fresh.표시); loadGroups(freshGroups.그룹); shownDirty = groupDirty = false; sync(); current = kinds.find((k) => k.type_id === current?.type_id) ?? null; paintKinds(); paintOptions(); } catch (again) { showToast(to("Load_Failed", why(again)), "error"); } }; save.addEventListener("click", async () => { if (groupDirty && !namesOk(groups)) return showToast(to("NameBad"), "error"); save.disabled = true; // 연달아 눌러도 한 번만 try { if (shownDirty) { const 표시: OptionsDoc["표시"] = {}; for (const [id, keys] of hidden) if (keys.size) 표시[id] = Object.fromEntries([...keys].map((k) => [k, false as const])); const done = await saveStructureOptions(version, 표시); version = done.판; load(done.표시); shownDirty = false; } if (groupDirty) { const 그룹: GroupsDoc["그룹"] = {}; for (const [id, list] of groups) if (list.length) 그룹[id] = list.map((g) => ({ ...g, 이름: g.이름.trim() })); const done = await saveStructureOptionGroups(groupVersion, 그룹); groupVersion = done.판; loadGroups(done.그룹); groupDirty = false; } sync(); showToast(to("Saved"), "success"); paintKinds(); paintOptions(); } catch (error) { if (error instanceof StaleError) { showToast(to("Stale"), "error"); await reload(); } else showToast(to("Failed", why(error)), "error"); } finally { save.disabled = false; } }); const node = el("div", { className: "m02-opts", children: [ el("p", { className: "m02-side__empty", text: to("Note") }), el("div", { className: "m02-opts__panes", children: [kindList, optionList], }), ...(edit ? [el("div", { className: "m02-side__row", children: [save, all] })] : []), ], }); const setDirty = opt.show(to("Screen"), node); const sync = (): void => setDirty(shownDirty || groupDirty); paintKinds(); paintOptions(); opt.onOpen(); mark(true); } return { title: to("Title"), body, clear: () => mark(false) }; }