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:
@@ -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) };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user