- 정본 `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
142 lines
5.9 KiB
TypeScript
142 lines
5.9 KiB
TypeScript
/* =============================================================================
|
|
* M02_SO_Groups.ts
|
|
* 「구조물 옵션 보이기」 화면의 **옵션 그룹** 부품(PLAN 46-2) — 고른 종류의 그룹 목록
|
|
* (이름 · 위로 · 아래로 · 지우기 · [그룹 추가]) · 옵션 줄마다 「들어갈 자리」 고르기(본체 · 그룹).
|
|
* 값은 화면이 쥔 고칠 사본(종류 → 그룹[])에만 — 저장은 화면 [저장](`M02_SO_Options.ts`).
|
|
* 그룹 하나 = 프로젝트 B05 · B06 폼의 보조 컨테이너 하나 · 옵션이 없어도 됨(서버 `M02_SO_Groups.py`).
|
|
* ========================================================================== */
|
|
|
|
import { createButton, el, showConfirmDialog } from "@ui/ui_template_elements";
|
|
import type { OptionGroupDef } from "./M02_SO_Api";
|
|
import { to } from "./M02_SO_Text";
|
|
|
|
const NAME_MAX = 40;
|
|
|
|
/** 새 그룹 id — 서버 규칙 `g[0-9a-z]{1,16}` · 같은 종류 안에서 안 겹치게 */
|
|
export function newGroupId(list: readonly OptionGroupDef[]): string {
|
|
const taken = new Set(list.map((g) => g.id));
|
|
for (;;) {
|
|
const id = `g${Math.random().toString(36).slice(2, 8)}`;
|
|
if (/^g[0-9a-z]{1,16}$/.test(id) && !taken.has(id)) return id;
|
|
}
|
|
}
|
|
|
|
/** 그 옵션이 든 그룹 id — 없으면 null(본체) */
|
|
export function groupOf(list: readonly OptionGroupDef[], key: string): string | null {
|
|
return list.find((g) => g.옵션.includes(key))?.id ?? null;
|
|
}
|
|
|
|
/** 옵션을 그룹(null = 본체)으로 옮김 — 한 옵션은 한 그룹에만 */
|
|
export function moveOption(list: OptionGroupDef[], key: string, gid: string | null): void {
|
|
for (const g of list) g.옵션 = g.옵션.filter((k) => k !== key);
|
|
const target = gid ? list.find((g) => g.id === gid) : null;
|
|
if (target) target.옵션.push(key);
|
|
}
|
|
|
|
/** 이름이 다 괜찮은가 — 1~40자 · 같은 종류 안에서 안 겹침(서버도 같은 검사) */
|
|
export function namesOk(groups: Map<string, OptionGroupDef[]>): boolean {
|
|
for (const list of groups.values()) {
|
|
const names = list.map((g) => g.이름.trim());
|
|
if (names.some((n) => !n || n.length > NAME_MAX)) return false;
|
|
if (new Set(names).size !== names.length) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
export interface GroupBlockOptions {
|
|
/** 고른 종류의 그룹(고칠 사본 — 이 부품이 직접 고침) */
|
|
list: OptionGroupDef[];
|
|
edit: boolean;
|
|
/** 고쳤음 — 화면이 고침 표시 · 다시 그리기 */
|
|
changed: () => void;
|
|
}
|
|
|
|
/** 그룹 목록 칸 — 제목 · 안내 · 그룹 줄 · [그룹 추가] */
|
|
export function buildGroupBlock(opt: GroupBlockOptions): HTMLElement {
|
|
const { list } = opt;
|
|
const rows = list.map((group, index) => {
|
|
const name = el("input", {
|
|
className: "ui-input ui-input--sm m02-sog__name",
|
|
attrs: { type: "text", maxlength: String(NAME_MAX), "data-sog-name": group.id },
|
|
}) as HTMLInputElement;
|
|
name.value = group.이름;
|
|
name.placeholder = to("GroupName");
|
|
name.disabled = !opt.edit;
|
|
// 글자마다 사본만 고침(다시 그리면 초점이 끊김) · 칸을 떠날 때 다시 그림(선택지 이름)
|
|
name.addEventListener("input", () => {
|
|
group.이름 = name.value;
|
|
name.classList.toggle("is-invalid", !name.value.trim());
|
|
});
|
|
name.addEventListener("change", () => opt.changed());
|
|
const step = (delta: number): void => {
|
|
const at = index + delta;
|
|
if (at < 0 || at >= list.length) return;
|
|
[list[index], list[at]] = [list[at], list[index]];
|
|
opt.changed();
|
|
};
|
|
const up = createButton({ label: to("GroupUp"), variant: "ghost", size: "sm" });
|
|
const down = createButton({ label: to("GroupDown"), variant: "ghost", size: "sm" });
|
|
const del = createButton({ label: to("GroupDel"), variant: "danger", size: "sm" });
|
|
up.disabled = !opt.edit || index === 0;
|
|
down.disabled = !opt.edit || index === list.length - 1;
|
|
del.disabled = !opt.edit;
|
|
up.addEventListener("click", () => step(-1));
|
|
down.addEventListener("click", () => step(1));
|
|
del.addEventListener("click", async () => {
|
|
if (!(await showConfirmDialog(to("GroupDelAsk", group.이름), to("GroupDel")))) return;
|
|
list.splice(list.indexOf(group), 1);
|
|
opt.changed();
|
|
});
|
|
return el("div", {
|
|
className: "m02-sog__row",
|
|
attrs: { "data-sog-group": group.id },
|
|
children: [
|
|
name,
|
|
el("small", {
|
|
className: "m02-opts__key",
|
|
text: to("GroupCount", String(group.옵션.length)),
|
|
}),
|
|
...(opt.edit ? [up, down, del] : []),
|
|
],
|
|
});
|
|
});
|
|
const add = createButton({ label: to("GroupAdd"), variant: "ghost", size: "sm" });
|
|
add.setAttribute("data-sog-add", "");
|
|
add.addEventListener("click", () => {
|
|
let n = list.length + 1;
|
|
while (list.some((g) => g.이름 === to("GroupNew", String(n)))) n += 1;
|
|
list.push({ id: newGroupId(list), 이름: to("GroupNew", String(n)), 옵션: [] });
|
|
opt.changed();
|
|
});
|
|
return el("div", {
|
|
className: "m02-sog",
|
|
children: [
|
|
el("h4", { className: "m02-opts__group", text: to("GroupHead") }),
|
|
el("p", { className: "m02-side__empty", text: to("GroupNote") }),
|
|
...(rows.length ? rows : [el("p", { className: "m02-side__empty", text: to("GroupNone") })]),
|
|
...(opt.edit ? [el("div", { className: "m02-side__row", children: [add] })] : []),
|
|
],
|
|
});
|
|
}
|
|
|
|
/** 옵션 줄의 「들어갈 자리」 고르기 — 본체 · 그룹 이름들 */
|
|
export function buildPlaceSelect(
|
|
list: OptionGroupDef[],
|
|
key: string,
|
|
edit: boolean,
|
|
changed: () => void,
|
|
): HTMLSelectElement {
|
|
const select = el("select", {
|
|
className: "ui-input ui-input--sm m02-sog__place",
|
|
attrs: { "data-sog-place": key },
|
|
}) as HTMLSelectElement;
|
|
select.append(new Option(to("Main"), ""), ...list.map((g) => new Option(g.이름 || g.id, g.id)));
|
|
select.value = groupOf(list, key) ?? "";
|
|
select.disabled = !edit || !list.length;
|
|
select.addEventListener("change", () => {
|
|
moveOption(list, key, select.value || null);
|
|
changed();
|
|
});
|
|
return select;
|
|
}
|