feat(M02): 48-2 구조물 옵션 정의 편집 화면 — 군 · 종류 · 옵션 · 선택지 · 배치 격자 · 그룹 형상 · 규칙 · 판 이력

- 옛 「옵션 보이기 · 그룹」 화면을 정의 편집 화면으로 넓힘(시스템 관리자 고쳐 씀 · 다른 역할 읽기만)
- 자료 손질 = M02_SO_Def_Model.ts(정의 틀 48-1 꼴) · 시험 test_m02_so_def_model.py

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
This commit is contained in:
2026-10-05 16:15:10 +09:00
co-authored by Claude Opus 5.5
parent 9ca9dff3cb
commit 8ca2f1cd6e
14 changed files with 1857 additions and 344 deletions
@@ -1,25 +1,12 @@
/* =============================================================================
* M02_SO_Options.ts
* 좌측 「구조물 옵션 보이기」 줄(구조물 집계 양식 아래 별도 · 시스템 관리자만) — 누르면 메인 칸에
* 종류 목록(군별) → 고른 종류의 **그룹**(PLAN 46-2 · `M02_SO_Groups.ts`) + 옵션 줄
* (보이기 체크 · 라벨 · 키 작은 글 · B06 전용 · 필수 표시 · 들어갈 자리) · [저장] · [모두 보임].
* 체크 안 한 옵션은 B05 · B06 구조물 폼에서 숨음(정의 · 값은 그대로).
* 서버 = `M02_SO_Options.py`(`구조물옵션표시.json` · 숨긴 것만) · `M02_SO_Groups.py`(`구조물옵션그룹.json`).
* 좌측 「구조물 옵션 정의」 줄(구조물 집계 양식 아래 별도 · 시스템 층) — 누르면 메인 칸에
* 정의 편집 화면(PLAN 48-2 · `M02_SO_Def.ts`) · 고쳐 씀은 시스템 관리자만 · 다른 역할은 읽기만.
* 옛 「보이기 · 그룹」 화면(46장)은 정의 화면의 옵션 줄 「보이기」 · 그룹 칸으로 들어감.
* ========================================================================== */
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 { el } from "@ui/ui_template_elements";
import { openDefScreen } from "./M02_SO_Def";
import "./M02_SO_Style.css";
import { to } from "./M02_SO_Text";
@@ -40,8 +27,6 @@ export interface OptionsSection {
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",
@@ -49,7 +34,6 @@ export function buildOptionsSection(opt: OptionsSectionOptions): OptionsSection
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;
@@ -57,241 +41,12 @@ export function buildOptionsSection(opt: OptionsSectionOptions): OptionsSection
opened = on;
row.classList.toggle("is-active", on);
};
async function open(): Promise<void> {
row.addEventListener("click", async () => {
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();
if (!(await openDefScreen({ isAdmin: opt.isAdmin, show: opt.show }))) return;
opt.onOpen();
mark(true);
}
});
return { title: to("Title"), body, clear: () => mark(false) };
}