- 정본 `resources/master_template/구조물옵션그룹.json` = {type_id: [{id, 이름, 옵션[]}]} · 빈 그룹 됨
- 길 GET · PUT /api/m02/structure-option-groups(시스템 관리자) · 검사(없는 종류 · 키 · 겹침 · 옵션 두 그룹 422 · 낡은 판 409)
- /api/projects/structure-types 종류마다 groups(차례 · 이름 · 옵션) · 깨진 표 = 그룹 없음
- 옵션 보이기 화면 = 그룹 목록(이름 · 위로 · 아래로 · 지우기 · 추가) + 옵션 줄 「들어갈 자리」(본체 · 그룹)
- 좌측 「구조물 옵션 보이기」 = 시스템 관리자만
- 시험 test_m02_structure_option_groups.py
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPcuokNm1ZR2kqgGGDfaJA
298 lines
11 KiB
TypeScript
298 lines
11 KiB
TypeScript
/* =============================================================================
|
|
* M02_SO_Options.ts
|
|
* 좌측 「구조물 옵션 보이기」 줄(구조물 집계 양식 아래 별도 · 시스템 관리자만) — 누르면 메인 칸에
|
|
* 종류 목록(군별) → 고른 종류의 **그룹**(PLAN 46-2 · `M02_SO_Groups.ts`) + 옵션 줄
|
|
* (보이기 체크 · 라벨 · 키 작은 글 · B06 전용 · 필수 표시 · 들어갈 자리) · [저장] · [모두 보임].
|
|
* 체크 안 한 옵션은 B05 · B06 구조물 폼에서 숨음(정의 · 값은 그대로).
|
|
* 서버 = `M02_SO_Options.py`(`구조물옵션표시.json` · 숨긴 것만) · `M02_SO_Groups.py`(`구조물옵션그룹.json`).
|
|
* ========================================================================== */
|
|
|
|
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<boolean>;
|
|
/** 메인 칸에 화면을 띄움 — 고침 표시 손잡이를 돌려받음 */
|
|
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<void> {
|
|
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<string, Set<string>>();
|
|
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<string, OptionGroupDef[]>();
|
|
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<string>();
|
|
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<void> => {
|
|
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) };
|
|
}
|