Merge remote-tracking branch 'origin/sub_laptop_1' into sub_laptop_5

This commit is contained in:
2026-10-05 16:23:09 +09:00
21 changed files with 2198 additions and 358 deletions
@@ -130,11 +130,11 @@ export function buildSide(opt: SideOptions): SideHandle {
openOnExpand(summary, async () => {
if (!isPicked("table", STRUCTURE_TABLE) && (await opt.confirmLeave())) opt.onOpen(null);
});
// 구조물 옵션 보이기 · 도면 양식은 시스템 층(마스터 템플릿)에만 · 옵션 보이기(그룹)는 시스템 관리자만(PLAN 46)
// 구조물 옵션 정의 · 도면 양식은 시스템 층(마스터 템플릿)에만 · 정의 고쳐 씀은 시스템 관리자만 · 다른 역할은 읽기만(PLAN 48-2)
const extras =
layer === "system"
? {
옵션: canEdit(layer, opt.isAdmin) ? section(options.title, options.body) : null,
옵션: section(options.title, options.body),
도면: section(L("M02_KindDrawing"), bodies.get("drawing")!, ...actionRow("drawing")),
}
: null;
+18 -45
View File
@@ -1,56 +1,29 @@
/* =============================================================================
* M02_SO_Api.ts
* 구조물 옵션 보이기 · 그룹 서버 호출 — 서버 = `M02_SO_Router.py`
* (마스터 템플릿 `구조물정의.json` 「표시」 · 「그룹」 칸 · PLAN 48-1)
* 구조물 정의 서버 호출 — 서버 = `M02_SO_Router.py`(마스터 템플릿 `구조물정의.json` 한 벌 · PLAN 48)
* ========================================================================== */
import { call } from "../M02_MasterTemplete_Api_Fetch";
import type { StructDef } from "./M02_SO_Def_Model";
export interface OptionKind {
type_id: string;
group: string;
name: string;
/** 거짓이면 B05 선택지에 안 나오는 종류 */
enabled: boolean;
options: { key: string; label: string; phase: "b05" | "detail"; required: boolean }[];
/* --- 구조물 정의(PLAN 48 · 마스터 템플릿 `구조물정의.json` · 판 번호 · 이력) --- */
/** 지난 판 한 줄 */
export interface DefVersion {
판: number;
저장일: string;
}
/** 판 · 표시(숨긴 것만 `{종류: {옵션: false}}`) · 종류(옵션이 있는 것과 그 옵션) */
export interface OptionsDoc {
판: string;
표시: Record<string, Record<string, false>>;
종류: OptionKind[];
}
/** 정의 한 벌 — 읽기 = 로그인 누구나 */
export const fetchStructureDef = (): Promise<StructDef> => call("/structure-definition");
export const fetchStructureOptions = (): Promise<OptionsDoc> =>
call("/layers/system/structure-options"); // 읽기 = 로그인 누구나 · 고쳐 씀은 시스템 관리자
/** 통째로 고쳐 씀(시스템 관리자만) — 읽어 간 판이 지금 판과 다르면 StaleError · 돌려받는 것 = 판 +1 정의 */
export const saveStructureDef = (판: number, 정의: StructDef): Promise<StructDef> =>
call("/structure-definition", { method: "PUT", body: JSON.stringify({ 판, 정의 }) });
/** 고쳐 씀 — 숨긴 것만 보냄 · 낡은 판이면 StaleError · 돌려받는 것은 새 문서 */
export const saveStructureOptions = (판: string, 표시: OptionsDoc["표시"]): Promise<OptionsDoc> =>
call("/structure-options", { method: "PUT", body: JSON.stringify({ 판, 표시 }) });
/** 지난 판 목록(`구조물정의_이력/판_N.json`) */
export const fetchStructureDefHistory = (): Promise<DefVersion[]> =>
call("/structure-definition/history");
/* --- 옵션 그룹(PLAN 46-2 · 구조물 정의 「그룹」 칸 · 시스템 관리자만) --- */
/** 그룹 하나 = 프로젝트 폼의 보조 컨테이너 하나(제목 = 이름) · 옵션 0 도 됨 */
export interface OptionGroupDef {
id: string;
이름: string;
옵션: string[];
}
/** 판 · 그룹(`{종류: 그룹[]}`) · 종류(레지스트리 전부 — 옵션 없는 종류도) */
export interface GroupsDoc {
판: string;
그룹: Record<string, OptionGroupDef[]>;
종류: OptionKind[];
}
export const fetchStructureOptionGroups = (): Promise<GroupsDoc> =>
call("/structure-option-groups");
/** 고쳐 씀 — 낡은 판이면 StaleError · 돌려받는 것은 새 문서 */
export const saveStructureOptionGroups = (
판: string,
그룹: GroupsDoc["그룹"],
): Promise<GroupsDoc> =>
call("/structure-option-groups", { method: "PUT", body: JSON.stringify({ 판, 그룹 }) });
export const fetchStructureDefAt = (판: number): Promise<StructDef> =>
call(`/structure-definition/history/${판}`);
@@ -0,0 +1,339 @@
/* =============================================================================
* M02_SO_Def.ts
* 구조물 정의 편집 화면(PLAN 48-2 · 메인 칸) — 왼쪽 구조물군 · 종류 목록(이름 바꿈 · 순서 · 안 씀) ·
* 오른쪽 고른 종류(이름 · 안 씀 · 기준 측점 표시 · 옵션 · 배치 격자 · 그룹 · 규칙) ·
* 아래 [저장](판 +1 · 시스템 관리자만) · 지난 판 목록(누르면 그 판을 읽기만으로 봄).
* 다른 역할은 읽기만. 고칠 사본은 저장 전까지 서버에 안 감 · 정본 = `구조물정의.json`(48-1).
* ========================================================================== */
import { createButton, createTag, el, showToast } from "@ui/ui_template_elements";
import { StaleError } from "../M02_MasterTemplete_Api_Fetch";
import {
fetchStructureDef,
fetchStructureDefAt,
fetchStructureDefHistory,
saveStructureDef,
type DefVersion,
} from "./M02_SO_Api";
import { buildLayoutBlock } from "./M02_SO_Def_Layout";
import {
baseShown,
clone,
problems,
stepFamily,
stepType,
type StructDef,
} from "./M02_SO_Def_Model";
import { buildOptionsBlock } from "./M02_SO_Def_Options";
import { buildRulesBlock } from "./M02_SO_Def_Rules";
import { checkBox, textBox } from "./M02_SO_Def_Ui";
import { buildGroupBlock } from "./M02_SO_Groups";
import { to } from "./M02_SO_Text";
export interface DefScreenOptions {
/** 시스템 관리자 — 아니면 읽기만 */
isAdmin: boolean;
/** 메인 칸에 화면을 띄움 — 고침 표시 손잡이를 돌려받음 */
show: (label: string, node: HTMLElement) => (dirty: boolean) => void;
}
const why = (error: unknown): string => (error instanceof Error ? error.message : "");
/** 빈 묶음 걷어냄 — 그리기 중 만든 빈 그룹 목록 · 배치 · 종류 덧칸 */
function tidy(def: StructDef): StructDef {
const out = clone(def);
for (const part of [out.그룹, out.배치, out.종류, out.표시] as Record<string, unknown>[])
for (const [k, v] of Object.entries(part))
if (Array.isArray(v) ? !v.length : !Object.keys(v as object).length) delete part[k];
return out;
}
/** 작은 단추 */
function smallButton(
label: string,
run: () => void,
off = false,
variant: "ghost" | "danger" = "ghost",
): HTMLButtonElement {
const b = createButton({ label, variant, size: "sm" });
b.disabled = off;
b.addEventListener("click", run);
return b;
}
/** 화면을 받아 띄움 — 못 받으면 알림만 · 띄웠으면 참 */
export async function openDefScreen(opt: DefScreenOptions): Promise<boolean> {
let def: StructDef;
try {
def = await fetchStructureDef();
} catch (error) {
showToast(to("Load_Failed", why(error)), "error");
return false;
}
drawDef(def, opt);
return true;
}
function drawDef(first: StructDef, opt: DefScreenOptions): void {
/** 지금 판(서버) · 고칠 사본 · 보는 지난 판(있으면 읽기만) */
let base = first;
let work = clone(first);
let past: StructDef | null = null;
let dirty = false;
let current: string | null = null;
const open = new Set<string>(); // 선택지를 펼친 옵션
let versions: DefVersion[] | null = null;
const shown = (): StructDef => past ?? work;
const canEdit = (): boolean => opt.isAdmin && !past;
const kindList = el("div", { className: "m02-opts__kinds", attrs: { "data-sod-kinds": "" } });
const detail = el("div", { className: "m02-opts__list", attrs: { "data-sod-detail": "" } });
const banner = el("div", { className: "m02-sod__banner", attrs: { "data-sod-banner": "" } });
const issues = el("div", { className: "m02-sod__issues", attrs: { "data-sod-issues": "" } });
const historyBox = el("div", {
className: "m02-sod__history",
attrs: { "data-sod-history": "" },
});
const changed = (): void => {
dirty = true;
sync();
paint();
};
const paintKinds = (): void => {
const d = shown();
const edit = canEdit();
const parts: HTMLElement[] = [];
const families = Object.keys(d.구조물군);
families.forEach((g, gi) => {
parts.push(
el("div", {
className: "m02-sod__family",
attrs: { "data-sod-family": g },
children: [
textBox(d.구조물군[g], (v) => (d.구조물군[g] = v), changed, {
edit,
attr: "data-sod-family-name",
}),
...(edit
? [
smallButton(to("GroupUp"), () => stepFamily(d, g, -1) && changed(), gi === 0),
smallButton(
to("GroupDown"),
() => stepFamily(d, g, 1) && changed(),
gi === families.length - 1,
),
]
: []),
],
}),
);
const kinds = d.types.filter((t) => t.group === g);
kinds.forEach((t, ti) => {
const off = t.enabled === false;
const groups = d.그룹[t.type_id]?.length ?? 0;
const b = el("button", {
className: `m02-opts__kind${t.type_id === current ? " is-active" : ""}${off ? " is-off" : ""}`,
attrs: { type: "button", "data-sod-kind": t.type_id },
children: [
el("span", { text: t.name }),
...(groups ? [createTag(to("GroupTag", String(groups)), "accent")] : []),
...(off ? [createTag(to("Off"), "neutral")] : []),
],
});
b.addEventListener("click", () => {
current = t.type_id;
paint();
});
parts.push(
el("div", {
className: "m02-sod__kindrow",
children: [
b,
...(edit
? [
smallButton(
to("GroupUp"),
() => stepType(d, t.type_id, -1) && changed(),
ti === 0,
),
smallButton(
to("GroupDown"),
() => stepType(d, t.type_id, 1) && changed(),
ti === kinds.length - 1,
),
]
: []),
],
}),
);
});
});
kindList.replaceChildren(...parts);
};
const paintDetail = (): void => {
const d = shown();
const type = d.types.find((t) => t.type_id === current);
if (!type) {
detail.replaceChildren(el("p", { className: "m02-side__empty", text: to("Pick") }));
return;
}
const edit = canEdit();
const extra = (): NonNullable<StructDef["종류"][string]> => (d.종류[type.type_id] ??= {});
detail.replaceChildren(
el("div", {
className: "m02-sod__kindhead",
children: [
textBox(type.name, (v) => (type.name = v), changed, {
edit,
attr: "data-sod-kind-name",
cls: "m02-sod__name",
}),
el("small", { className: "m02-opts__key", text: type.type_id }),
checkBox(to("Unused"), type.enabled === false, (v) => ((type.enabled = !v), changed()), {
edit,
attr: "data-sod-kind-off",
}),
checkBox(
to("BaseStationShow"),
baseShown(d, type.type_id),
(v) => {
if (v) delete extra().기준측점;
else extra().기준측점 = false;
changed();
},
{ edit, attr: "data-sod-base" },
),
...(d.종류[type.type_id]?.동작 ?? []).map((a) => {
const tag = createTag(a, "neutral");
tag.title = d.동작[a] ?? "";
return tag;
}),
],
}),
buildOptionsBlock({ def: d, type, edit, open, changed }),
buildLayoutBlock({ def: d, type, edit, changed }),
buildGroupBlock({ list: (d.그룹[type.type_id] ??= []), edit, changed }),
buildRulesBlock({ def: d, type, edit, changed }),
);
};
const paintFoot = (): void => {
banner.replaceChildren(
...(past
? [
el("span", { text: to("PastView", String(past.판)) }),
smallButton(to("PastBack"), () => ((past = null), paint(), paintHistory())),
]
: [
el("span", { text: to("Version", String(base.판)) }),
...(opt.isAdmin ? [] : [createTag(to("ReadOnly"), "neutral")]),
]),
);
banner.classList.toggle("is-past", !!past);
const list = past ? [] : problems(work);
issues.replaceChildren(...list.map((t) => el("p", { className: "m02-sod__bad", text: t })));
save.disabled = !opt.isAdmin || !!past || !dirty || list.length > 0;
};
const paint = (): void => {
paintKinds();
paintDetail();
paintFoot();
};
const reload = async (): Promise<void> => {
try {
base = await fetchStructureDef();
work = clone(base);
dirty = false;
sync();
} catch (again) {
showToast(to("Load_Failed", why(again)), "error");
}
};
const save = createButton({ label: to("Save"), variant: "filled" });
save.setAttribute("data-sod-save", "");
save.addEventListener("click", async () => {
if (problems(work).length) return;
save.disabled = true; // 연달아 눌러도 한 번만
try {
base = await saveStructureDef(base.판, tidy(work));
work = clone(base);
dirty = false;
versions = null;
sync();
showToast(to("SavedVersion", String(base.판)), "success");
} catch (error) {
if (error instanceof StaleError) {
showToast(to("Stale"), "error");
await reload();
} else showToast(to("Failed", why(error)), "error");
} finally {
paint();
paintHistory();
}
});
/** 지난 판 목록 — 펼칠 때 받음 · 누르면 그 판을 읽기만으로 */
const paintHistory = (): void => {
if (!versions) return historyBox.replaceChildren();
historyBox.replaceChildren(
...(versions.length
? versions.map((v) => {
const b = createButton({
label: to("PastItem", `${v.판} · ${v.저장일}`),
variant: past?.판 === v.판 ? "filled" : "ghost",
size: "sm",
});
b.setAttribute("data-sod-past", String(v.판));
b.addEventListener("click", async () => {
try {
past = await fetchStructureDefAt(v.판);
} catch (error) {
return showToast(to("Load_Failed", why(error)), "error");
}
paint();
paintHistory();
});
return b;
})
: [el("p", { className: "m02-side__empty", text: to("PastNone") })]),
);
};
const history = createButton({ label: to("PastList"), variant: "ghost" });
history.setAttribute("data-sod-history-toggle", "");
history.addEventListener("click", async () => {
if (versions) {
versions = null;
return paintHistory();
}
try {
versions = (await fetchStructureDefHistory()).sort((a, b) => b.판 - a.판);
} catch (error) {
return showToast(to("Load_Failed", why(error)), "error");
}
paintHistory();
});
const node = el("div", {
className: "m02-opts m02-sod",
children: [
el("p", { className: "m02-side__empty", text: to("DefNote") }),
banner,
el("div", { className: "m02-opts__panes", children: [kindList, detail] }),
issues,
el("div", {
className: "m02-side__row",
children: [...(opt.isAdmin ? [save] : []), history],
}),
historyBox,
],
});
const setDirty = opt.show(to("Screen"), node);
const sync = (): void => setDirty(dirty);
paint();
}
@@ -0,0 +1,120 @@
/* =============================================================================
* M02_SO_Def_Layout.ts
* 구조물 정의 편집 화면(PLAN 48-2) — 고른 종류의 **배치 격자**(B05 · B06 좌측 구조물 칸 = 두 칸)
* 옵션마다 줄 · 칸 · 너비 + 미리보기(기준 측점은 맨 위 한 줄 · 겹치면 빨강).
* 자리를 안 정한 옵션은 차례대로 빈 칸을 채움 · 숫자를 고치면 그 옵션 자리만 정의 `배치` 에 남음.
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
import {
baseShown,
GRID_COLS,
live,
overlaps,
placeAll,
type Spot,
type StructDef,
type TypeDef,
} from "./M02_SO_Def_Model";
import { head, numberBox } from "./M02_SO_Def_Ui";
import { to } from "./M02_SO_Text";
export interface LayoutBlockOptions {
def: StructDef;
type: TypeDef;
edit: boolean;
changed: () => void;
}
const ROW_MAX = 60;
/** 미리보기 — 두 칸 격자 · 옵션마다 상자 */
function preview(def: StructDef, type: TypeDef, cells: Map<string, Spot>): HTMLElement {
const bad = new Set(overlaps(cells));
const base = baseShown(def, type.type_id);
const offset = base ? 1 : 0;
const boxes: HTMLElement[] = [];
if (base)
boxes.push(
el("div", {
className: "m02-sod__cell is-base",
text: to("BaseStation"),
attrs: { style: `grid-row: 1; grid-column: 1 / span ${GRID_COLS}` },
}),
);
for (const o of live(type.options)) {
const c = cells.get(o.key)!;
boxes.push(
el("div", {
className: `m02-sod__cell${bad.has(o.key) ? " is-bad" : ""}`,
text: o.label,
attrs: {
"data-sod-cell": o.key,
title: o.label,
style: `grid-row: ${c.줄 + offset}; grid-column: ${c.칸} / span ${c.너비}`,
},
}),
);
}
return el("div", {
className: "m02-sod__grid",
attrs: {
"data-sod-preview": "",
style: `grid-template-columns: repeat(${GRID_COLS}, minmax(0, 1fr))`,
},
children: boxes,
});
}
export function buildLayoutBlock(opt: LayoutBlockOptions): HTMLElement {
const { def, type, edit, changed } = opt;
const cells = placeAll(type.options, def.배치[type.type_id]);
const rows = live(type.options).map((o) => {
const c = cells.get(o.key)!;
const fix = (patch: Partial<Spot>): void => {
(def.배치[type.type_id] ??= {})[o.key] = { ...c, ...patch };
changed();
};
return el("div", {
className: "m02-sod__place",
attrs: { "data-sod-place-row": o.key },
children: [
el("span", { className: "m02-sod__placename", text: o.label }),
el("small", { text: to("GridRow") }),
numberBox(c.줄, (n) => fix({ 줄: n }), {
edit,
min: 1,
max: ROW_MAX,
attr: "data-sod-row",
}),
el("small", { text: to("GridCol") }),
numberBox(c.칸, (n) => fix({ 칸: n }), {
edit,
min: 1,
max: GRID_COLS,
attr: "data-sod-col",
}),
el("small", { text: to("GridWidth") }),
numberBox(c.너비, (n) => fix({ 너비: n }), {
edit,
min: 1,
max: GRID_COLS,
attr: "data-sod-width",
}),
],
});
});
return el("div", {
className: "m02-sod__block",
children: [
...head(to("LayoutHead"), to("LayoutNote")),
el("div", {
className: "m02-sod__layout",
children: [
el("div", { className: "m02-sod__places", children: rows }),
preview(def, type, cells),
],
}),
],
});
}
@@ -0,0 +1,268 @@
/* =============================================================================
* M02_SO_Def_Model.ts
* 구조물 정의 편집 화면(PLAN 48-2)의 자료 꼴 · 순수 손질 함수 — 화면 그리기 없음.
* 정본 = 마스터 템플릿 `구조물정의.json`(48-1 · 틀 `docs/raw/M02_구조물옵션정의/정의_틀.md`).
* 키는 만들 때 자동 · 안 바뀜 · 이름 자유 · 지움 = 「안 씀」(`enabled: false`).
* 선택지는 글 그대로가 값 — 이름을 바꾸면 이 정의 안의 기본값 · 규칙도 같이 바꿈.
* ========================================================================== */
export type OptionForm = "select" | "number" | "text" | "bool";
export interface Spot {
줄: number;
칸: number;
너비: number;
}
export interface OptionDef {
key: string;
label: string;
input: OptionForm;
choices?: string[];
unit?: string | null;
default?: unknown;
enabled?: boolean;
phase?: "b05" | "detail";
[more: string]: unknown;
}
export interface TypeDef {
type_id: string;
group: string;
name: string;
options: OptionDef[];
enabled?: boolean;
[more: string]: unknown;
}
export type ShapeKey = "길이" | "높이" | "전" | "후";
export const SHAPE_KEYS: readonly ShapeKey[] = ["길이", "높이", "전", "후"];
export interface GroupDef {
id: string;
이름: string;
옵션: string[];
형상?: Partial<Record<ShapeKey, { 받기: boolean; 뜻: string }>>;
}
/** 만약 상위 옵션 = 값들 중 하나 → 하위 옵션의 선택지 · 보이기 · 기본값(하나 이상) */
export interface RuleDef {
종류: string;
만약: { 옵션: string; 값들: string[] };
그러면: { 옵션: string; 선택지?: string[]; 보이기?: boolean; 기본값?: unknown };
}
export interface StructDef {
판: number;
구조물군: Record<string, string>;
동작: Record<string, string>;
types: TypeDef[];
종류: Record<string, { 기준측점?: boolean; 동작?: string[] }>;
표시: Record<string, Record<string, false>>;
그룹: Record<string, GroupDef[]>;
배치: Record<string, Record<string, Spot>>;
규칙: RuleDef[];
[more: string]: unknown;
}
export const NAME_MAX = 40;
/** B05 · B06 좌측 구조물 칸 = 두 칸 격자 */
export const GRID_COLS = 2;
/** 깊은 사본 — 고칠 사본을 서버 문서와 떼어 놓음 */
export const clone = <T>(value: T): T => structuredClone(value);
/** 새 키 — 머리글 + 영문 소문자 · 숫자 6자 · 이미 있는 키와 안 겹침 */
export function newKey(prefix: string, taken: Iterable<string>): string {
const used = new Set(taken);
for (;;) {
const key = `${prefix}${Math.random().toString(36).slice(2, 8)}`;
if (key.length > prefix.length + 3 && !used.has(key)) return key;
}
}
/** 목록 안에서 한 칸 옮김 — 끝이면 그대로 · 옮겼으면 참 */
export function step<T>(list: T[], index: number, delta: number): boolean {
const at = index + delta;
if (index < 0 || at < 0 || at >= list.length) return false;
[list[index], list[at]] = [list[at], list[index]];
return true;
}
/** 쓰는 것만 — 「안 씀」(enabled 거짓) 뺌 */
export const live = <T extends { enabled?: boolean }>(list: readonly T[]): T[] =>
list.filter((x) => x.enabled !== false);
/** 기준 측점 칸 보이기 — 없으면 보임 */
export const baseShown = (def: StructDef, typeId: string): boolean =>
def.종류[typeId]?.기준측점 !== false;
/** 이름 검사 — 1~40자 · 같은 목록 안에서 안 겹침 · 틀린 이름들 */
export function badNames(names: readonly string[]): string[] {
const seen = new Set<string>();
const bad: string[] = [];
for (const raw of names) {
const name = raw.trim();
if (!name || name.length > NAME_MAX || seen.has(name)) bad.push(name || "(빈 이름)");
seen.add(name);
}
return bad;
}
/** 구조물군 순서 바꿈 — `{키: 이름}` 묶음의 키 차례 */
export function stepFamily(def: StructDef, key: string, delta: number): boolean {
const entries = Object.entries(def.구조물군);
if (
!step(
entries,
entries.findIndex(([k]) => k === key),
delta,
)
)
return false;
def.구조물군 = Object.fromEntries(entries);
return true;
}
/** 종류 순서 바꿈 — 같은 군 안 앞뒤 종류와 `types` 자리를 바꿈 */
export function stepType(def: StructDef, typeId: string, delta: number): boolean {
const t = def.types.find((x) => x.type_id === typeId);
if (!t) return false;
const same = def.types.filter((x) => x.group === t.group);
const other = same[same.indexOf(t) + delta];
if (!other) return false;
const i = def.types.indexOf(t);
const j = def.types.indexOf(other);
[def.types[i], def.types[j]] = [def.types[j], def.types[i]];
return true;
}
/** 선택지 이름 바꿈 — 그 옵션 기본값 · 이 종류 규칙의 값들 · 선택지 · 기본값도 같이 */
export function renameChoice(
def: StructDef,
typeId: string,
o: OptionDef,
from: string,
to: string,
): void {
if (from === to || !o.choices) return;
o.choices = o.choices.map((c) => (c === from ? to : c));
if (o.default === from) o.default = to;
for (const r of def.규칙) {
if (r.종류 !== typeId) continue;
if (r.만약.옵션 === o.key) r.만약.값들 = r.만약.값들.map((v) => (v === from ? to : v));
if (r.그러면.옵션 === o.key) {
if (r.그러면.선택지) r.그러면.선택지 = r.그러면.선택지.map((v) => (v === from ? to : v));
if (r.그러면.기본값 === from) r.그러면.기본값 = to;
}
}
}
/** 선택지 뺌 — 기본값 · 규칙에서도 뺌(규칙 값들이 비면 그 규칙은 검사에서 걸림) */
export function dropChoice(def: StructDef, typeId: string, o: OptionDef, value: string): void {
o.choices = (o.choices ?? []).filter((c) => c !== value);
if (o.default === value) o.default = null;
for (const r of def.규칙) {
if (r.종류 !== typeId) continue;
if (r.만약.옵션 === o.key) r.만약.값들 = r.만약.값들.filter((v) => v !== value);
if (r.그러면.옵션 === o.key) {
if (r.그러면.선택지) r.그러면.선택지 = r.그러면.선택지.filter((v) => v !== value);
if (r.그러면.기본값 === value) delete r.그러면.기본값;
}
}
}
/** 배치 격자 — 정한 자리 먼저 · 안 정한 옵션은 차례대로 빈 칸 채움 · 안 씀은 빠짐 */
export function placeAll(
options: readonly OptionDef[],
spots: Record<string, Spot> = {},
cols = GRID_COLS,
): Map<string, Spot> {
const out = new Map<string, Spot>();
const taken = new Set<string>();
const mark = (c: Spot): void => {
for (let i = 0; i < c.너비; i += 1) taken.add(`${c.줄}:${c.칸 + i}`);
};
const shown = live(options);
for (const o of shown) {
const s = spots[o.key];
if (!s) continue;
const width = Math.max(1, Math.min(cols, s.너비 || 1));
const cell = { 줄: s.줄, 칸: Math.min(s.칸, cols - width + 1), 너비: width };
out.set(o.key, cell);
mark(cell);
}
let row = 1;
let col = 1;
for (const o of shown) {
if (out.has(o.key)) continue;
while (taken.has(`${row}:${col}`)) {
col += 1;
if (col > cols) ((row += 1), (col = 1));
}
const cell = { 줄: row, 칸: col, 너비: 1 };
out.set(o.key, cell);
mark(cell);
}
return out;
}
/** 겹친 칸 — 두 옵션이 같은 줄 · 칸을 쓰면 그 키들 */
export function overlaps(cells: Map<string, Spot>): string[] {
const owner = new Map<string, string>();
const bad = new Set<string>();
for (const [key, c] of cells)
for (let i = 0; i < c.너비; i += 1) {
const at = `${c.줄}:${c.칸 + i}`;
const other = owner.get(at);
if (other) bad.add(other).add(key);
else owner.set(at, key);
}
return [...bad];
}
/** 규칙 한 줄이 틀렸나 — 서버 검사(`M02_SO_Definition._rule_errors`)와 같은 뜻 */
export function ruleBad(def: StructDef, r: RuleDef): boolean {
const t = def.types.find((x) => x.type_id === r.종류);
const src = t?.options.find((o) => o.key === r.만약.옵션);
const dst = t?.options.find((o) => o.key === r.그러면.옵션);
if (!src || !dst || src === dst || !r.만약.값들.length) return true;
if (src.input === "select" && !r.만약.값들.every((v) => src.choices?.includes(v))) return true;
const { 선택지, 보이기, 기본값 } = r.그러면;
if (선택지 === undefined && 보이기 === undefined && 기본값 === undefined) return true;
let allowed = dst.choices ?? [];
if (선택지 !== undefined) {
if (dst.input !== "select" || !선택지.every((v) => allowed.includes(v))) return true;
allowed = 선택지;
}
return 기본값 !== undefined && dst.input === "select" && !allowed.includes(String(기본값));
}
/** 저장 전 검사 — 화면에 띄울 까닭 글들(빈 목록 = 저장 됨) */
export function problems(def: StructDef): string[] {
const out: string[] = [];
const fam = badNames(Object.values(def.구조물군));
if (fam.length) out.push(`구조물군 이름: ${fam.join(", ")}`);
for (const g of Object.keys(def.구조물군)) {
const bad = badNames(def.types.filter((t) => t.group === g).map((t) => t.name));
if (bad.length) out.push(`${def.구조물군[g]} 종류 이름: ${bad.join(", ")}`);
}
for (const t of def.types) {
const opt = badNames(t.options.map((o) => o.label));
if (opt.length) out.push(`${t.name} 옵션 이름: ${opt.join(", ")}`);
for (const o of t.options) {
if (o.input !== "select") continue;
const ch = badNames(o.choices ?? []);
if (ch.length) out.push(`${t.name}.${o.label} 선택지: ${ch.join(", ")}`);
if (!o.choices?.length) out.push(`${t.name}.${o.label} 고르기 꼴은 선택지가 있어야 함`);
}
const grp = badNames((def.그룹[t.type_id] ?? []).map((g) => g.이름));
if (grp.length) out.push(`${t.name} 그룹 이름: ${grp.join(", ")}`);
const lap = overlaps(placeAll(t.options, def.배치[t.type_id]));
if (lap.length) out.push(`${t.name} 배치 겹침: ${lap.join(", ")}`);
}
def.규칙.forEach((r, i) => {
if (ruleBad(def, r)) out.push(`규칙 ${i + 1} 이 틀림`);
});
return out;
}
@@ -0,0 +1,231 @@
/* =============================================================================
* M02_SO_Def_Options.ts
* 구조물 정의 편집 화면(PLAN 48-2) — 고른 종류의 **옵션 목록**
* (이름 · 꼴 · 단위 · 기본값 · 보이기 · 들어갈 자리 · 안 씀 · 순서 · [옵션 더함]) +
* 고르기 꼴의 **선택지 목록**(이름 바꿈 · 빼기 · 순서 · [선택지 더함]).
* 옵션 키는 더할 때 자동 · 안 바뀜 · 선택지는 글이 곧 값(이름 바꿈 = 기본값 · 규칙도 같이) ·
* 저장은 화면 [저장](`M02_SO_Def.ts`).
* ========================================================================== */
import { createButton, createTag, el, showConfirmDialog } from "@ui/ui_template_elements";
import {
dropChoice,
newKey,
renameChoice,
type OptionDef,
type OptionForm,
type StructDef,
type TypeDef,
} from "./M02_SO_Def_Model";
import { checkBox, head, moveButtons, pickBox, textBox } from "./M02_SO_Def_Ui";
import { buildPlaceSelect } from "./M02_SO_Groups";
import { to } from "./M02_SO_Text";
export interface OptionsBlockOptions {
def: StructDef;
type: TypeDef;
edit: boolean;
/** 선택지를 펼친 옵션 키 — 다시 그려도 남음 */
open: Set<string>;
changed: () => void;
}
const FORMS: readonly OptionForm[] = ["select", "number", "text", "bool"];
const FORM_TEXT = {
select: "FormSelect",
number: "FormNumber",
text: "FormText",
bool: "FormBool",
} as const;
/** 기본값 칸 — 꼴에 맞춤(고르기 = 선택지 · 예/아니오 · 숫자 · 글) · 비우면 기본값 없음 */
function defaultBox(o: OptionDef, edit: boolean, changed: () => void): HTMLElement {
const attr = "data-sod-default";
const now = o.default == null ? "" : String(o.default);
if (o.input === "select" || o.input === "bool") {
const pairs: [string, string][] =
o.input === "select"
? (o.choices ?? []).map((c) => [c, c])
: [
["true", to("Yes")],
["false", to("No")],
];
return pickBox(
[["", to("NoDefault")], ...pairs],
now,
(v) => {
o.default = v === "" ? null : o.input === "bool" ? v === "true" : v;
changed();
},
{ edit, attr, cls: "m02-sod__default" },
);
}
return textBox(
now,
(v) => {
const n = Number(v);
o.default = v.trim() === "" ? null : o.input === "number" && Number.isFinite(n) ? n : v;
},
changed,
{ edit, attr, cls: "m02-sod__default", place: to("NoDefault") },
);
}
/** 선택지 목록 — 이름(떠날 때 바꿈) · 빼기 · 순서 · [선택지 더함] */
function choicesBlock(opt: OptionsBlockOptions, o: OptionDef): HTMLElement {
const { def, type, edit, changed } = opt;
const list = (o.choices ??= []);
const rows = list.map((c) => {
let next = c;
const del = createButton({ label: to("ChoiceDrop"), variant: "danger", size: "sm" });
del.addEventListener("click", async () => {
if (!(await showConfirmDialog(to("ChoiceDropAsk", c), to("ChoiceDrop")))) return;
dropChoice(def, type.type_id, o, c);
changed();
});
return el("div", {
className: "m02-sod__choice",
attrs: { "data-sod-choice": c },
children: [
textBox(
c,
(v) => (next = v.trim()),
() => {
if (next && next !== c && !list.includes(next))
renameChoice(def, type.type_id, o, c, next);
changed();
},
{ edit, attr: "data-sod-choice-name" },
),
...moveButtons(list, c, changed, edit),
...(edit ? [del] : []),
],
});
});
const add = createButton({ label: to("ChoiceAdd"), variant: "ghost", size: "sm" });
add.setAttribute("data-sod-choice-add", "");
add.addEventListener("click", () => {
let n = list.length + 1;
while (list.includes(to("ChoiceNew", String(n)))) n += 1;
list.push(to("ChoiceNew", String(n)));
changed();
});
return el("div", {
className: "m02-sod__choices",
children: [
el("p", { className: "m02-side__empty", text: to("ChoiceNote") }),
...(rows.length ? rows : [el("p", { className: "m02-sod__bad", text: to("ChoiceNone") })]),
...(edit ? [add] : []),
],
});
}
/** 옵션 한 줄(+ 펼친 선택지) */
function optionRow(opt: OptionsBlockOptions, o: OptionDef): HTMLElement {
const { def, type, edit, open, changed } = opt;
const off = o.enabled === false;
const hidden = def.표시[type.type_id]?.[o.key] === false;
const form = pickBox(
FORMS.map((f) => [f, to(FORM_TEXT[f])] as const),
o.input,
(v) => {
o.input = v as OptionForm;
o.default = null; // 꼴이 바뀌면 옛 기본값은 뜻이 없음
if (o.input === "select") open.add(o.key);
changed();
},
{ edit, attr: "data-sod-form", cls: "m02-sod__form" },
);
const choices =
o.input === "select"
? createButton({
label: to("Choices", String(o.choices?.length ?? 0)),
variant: open.has(o.key) ? "filled" : "ghost",
size: "sm",
})
: null;
choices?.setAttribute("data-sod-choices", o.key);
choices?.addEventListener("click", () => {
if (open.has(o.key)) open.delete(o.key);
else open.add(o.key);
changed();
});
const show = checkBox(
to("Shown"),
!hidden,
(v) => {
const row = (def.표시[type.type_id] ??= {});
if (v) delete row[o.key];
else row[o.key] = false;
if (!Object.keys(row).length) delete def.표시[type.type_id];
changed();
},
{ edit, attr: "data-sod-shown" },
);
const row = el("div", {
className: `m02-sod__option${off ? " is-off" : ""}`,
attrs: { "data-sod-option": o.key },
children: [
textBox(o.label, (v) => (o.label = v), changed, {
edit,
attr: "data-sod-name",
cls: "m02-sod__name",
}),
el("small", { className: "m02-opts__key", text: o.key }),
...(o.phase === "detail" ? [createTag(to("Detail"), "accent")] : []),
form,
textBox(o.unit ?? "", (v) => (o.unit = v.trim() || null), changed, {
edit,
attr: "data-sod-unit",
cls: "m02-sod__unit",
place: to("Unit"),
max: 12,
}),
defaultBox(o, edit, changed),
show,
buildPlaceSelect((def.그룹[type.type_id] ??= []), o.key, edit, changed),
checkBox(to("Unused"), off, (v) => ((o.enabled = !v), changed()), {
edit,
attr: "data-sod-off",
}),
...moveButtons(type.options, o, changed, edit),
...(choices ? [choices] : []),
],
});
return o.input === "select" && open.has(o.key)
? el("div", { className: "m02-sod__optwrap", children: [row, choicesBlock(opt, o)] })
: row;
}
/** 옵션 목록 칸 — 제목 · 안내 · 옵션 줄 · [옵션 더함] */
export function buildOptionsBlock(opt: OptionsBlockOptions): HTMLElement {
const { type, edit, changed } = opt;
const add = createButton({ label: to("OptionAdd"), variant: "ghost", size: "sm" });
add.setAttribute("data-sod-option-add", "");
add.addEventListener("click", () => {
const n = type.options.length + 1;
const o: OptionDef = {
key: newKey(
"opt_",
type.options.map((x) => x.key),
),
label: to("OptionNew", String(n)),
input: "select",
choices: [to("ChoiceNew", "1")],
default: null,
};
type.options.push(o);
opt.open.add(o.key);
changed();
});
return el("div", {
className: "m02-sod__block",
children: [
...head(to("OptionHead"), to("OptionNote")),
...(type.options.length
? type.options.map((o) => optionRow(opt, o))
: [el("p", { className: "m02-side__empty", text: to("NoOptionKind") })]),
...(edit ? [el("div", { className: "m02-side__row", children: [add] })] : []),
],
});
}
@@ -0,0 +1,234 @@
/* =============================================================================
* M02_SO_Def_Rules.ts
* 구조물 정의 편집 화면(PLAN 48-2) — 고른 종류의 **규칙 표**(정의 `규칙` 중 그 종류 것만)
* 「만약 상위 옵션 = 값들 중 하나 → 하위 옵션의 선택지 · 보이기 · 기본값」 한 줄씩 · [규칙 더함] · 지우기.
* 「그대로」 칸은 정의에 안 남김 · 셋 다 그대로면 틀린 줄(빨강) · 적용은 B05 · B06(48-4).
* ========================================================================== */
import { createButton, el } from "@ui/ui_template_elements";
import {
live,
ruleBad,
type OptionDef,
type RuleDef,
type StructDef,
type TypeDef,
} from "./M02_SO_Def_Model";
import { head, pickBox } from "./M02_SO_Def_Ui";
import { to } from "./M02_SO_Text";
export interface RulesBlockOptions {
def: StructDef;
type: TypeDef;
edit: boolean;
changed: () => void;
}
const bool = (): [string, string][] => [
["true", to("Yes")],
["false", to("No")],
];
/** 여럿 고르기 — 체크한 것만 차례대로 */
function multiPick(
all: readonly string[],
picked: readonly string[],
set: (v: string[]) => void,
edit: boolean,
attr: string,
): HTMLElement {
const on = new Set(picked);
return el("div", {
className: "m02-sod__rulechoices",
attrs: { [attr]: "" },
children: all.map((c) => {
const box = el("input", { attrs: { type: "checkbox", value: c } }) as HTMLInputElement;
box.checked = on.has(c);
box.disabled = !edit;
box.addEventListener("change", () => {
if (box.checked) on.add(c);
else on.delete(c);
set(all.filter((x) => on.has(x)));
});
return el("label", { className: "m02-sod__check", children: [box, el("span", { text: c })] });
}),
});
}
/** 글 칸 — 쉼표로 여럿 */
function listText(
value: string[],
set: (v: string[]) => void,
edit: boolean,
attr: string,
): HTMLElement {
const box = el("input", {
className: "ui-input ui-input--sm m02-sod__rulepick",
attrs: { type: "text", [attr]: "", placeholder: to("RuleValues") },
}) as HTMLInputElement;
box.value = value.join(", ");
box.disabled = !edit;
box.addEventListener("change", () =>
set(
box.value
.split(",")
.map((s) => s.trim())
.filter(Boolean),
),
);
return box;
}
/** 만약 값들 — 고르기 = 선택지 체크 · 예/아니오 = 하나 · 그 밖 = 쉼표 글 */
function whenValues(
src: OptionDef | undefined,
rule: RuleDef,
edit: boolean,
changed: () => void,
): HTMLElement {
const set = (v: string[]): void => ((rule.만약.값들 = v), changed());
if (src?.input === "select")
return multiPick(src.choices ?? [], rule.만약.값들, set, edit, "data-sod-rule-value");
if (src?.input === "bool")
return pickBox(bool(), rule.만약.값들[0] ?? "true", (v) => set([v]), {
edit,
attr: "data-sod-rule-value",
cls: "m02-sod__rulepick",
});
return listText(rule.만약.값들, set, edit, "data-sod-rule-value");
}
/** 그러면 기본값 — 「그대로」 + (좁힌) 선택지 · 예/아니오 · 글 */
function thenDefault(
dst: OptionDef | undefined,
rule: RuleDef,
edit: boolean,
changed: () => void,
): HTMLElement {
const then = rule.그러면;
const now = then.기본값 === undefined ? "" : String(then.기본값);
const set = (v: string): void => {
if (v === "") delete then.기본값;
else
then.기본값 =
dst?.input === "bool"
? v === "true"
: dst?.input === "number" && Number.isFinite(Number(v))
? Number(v)
: v;
changed();
};
const pairs: [string, string][] =
dst?.input === "select"
? (then.선택지 ?? dst.choices ?? []).map((c) => [c, c])
: dst?.input === "bool"
? bool()
: [];
if (pairs.length || (dst?.input !== "number" && dst?.input !== "text"))
return pickBox([["", to("KeepDefault")], ...pairs], now, set, {
edit,
attr: "data-sod-rule-default",
cls: "m02-sod__rulepick",
});
const box = el("input", {
className: "ui-input ui-input--sm m02-sod__rulepick",
attrs: { type: "text", "data-sod-rule-default": "", placeholder: to("KeepDefault") },
}) as HTMLInputElement;
box.value = now;
box.disabled = !edit;
box.addEventListener("change", () => set(box.value.trim()));
return box;
}
function ruleRow(opt: RulesBlockOptions, rule: RuleDef, index: number): HTMLElement {
const { def, type, edit, changed } = opt;
const byKey = new Map(type.options.map((o) => [o.key, o]));
const names = live(type.options).map((o) => [o.key, o.label] as [string, string]);
const src = byKey.get(rule.만약.옵션);
const dst = byKey.get(rule.그러면.옵션);
const then = rule.그러면;
const del = createButton({ label: to("GroupDel"), variant: "danger", size: "sm" });
del.addEventListener("click", () => (def.규칙.splice(def.규칙.indexOf(rule), 1), changed()));
const show = then.보이기 === undefined ? "" : then.보이기 ? "1" : "0";
return el("div", {
className: `m02-sod__rule${ruleBad(def, rule) ? " is-bad" : ""}`,
attrs: { "data-sod-rule": String(index) },
children: [
el("small", { text: to("RuleIf") }),
pickBox(names, rule.만약.옵션, (v) => ((rule.만약 = { 옵션: v, 값들: [] }), changed()), {
edit,
attr: "data-sod-rule-top",
cls: "m02-sod__rulepick",
}),
el("small", { text: "=" }),
whenValues(src, rule, edit, changed),
el("small", { text: to("RuleThen") }),
pickBox(names, then.옵션, (v) => ((rule.그러면 = { 옵션: v }), changed()), {
edit,
attr: "data-sod-rule-target",
cls: "m02-sod__rulepick",
}),
...(dst?.input === "select"
? [
el("small", { text: to("RuleChoices") }),
multiPick(
dst.choices ?? [],
then.선택지 ?? [],
(v) => {
if (v.length) then.선택지 = v;
else delete then.선택지;
changed();
},
edit,
"data-sod-rule-choice",
),
]
: []),
pickBox(
[
["", to("Keep")],
["1", to("Show")],
["0", to("Hide")],
],
show,
(v) => {
if (v === "") delete then.보이기;
else then.보이기 = v === "1";
changed();
},
{ edit, attr: "data-sod-rule-show", cls: "m02-sod__rulepick" },
),
thenDefault(dst, rule, edit, changed),
...(edit ? [del] : []),
],
});
}
export function buildRulesBlock(opt: RulesBlockOptions): HTMLElement {
const { def, type, edit, changed } = opt;
const mine = def.규칙.filter((r) => r.종류 === type.type_id);
const opts = live(type.options);
const add = createButton({ label: to("RuleAdd"), variant: "ghost", size: "sm" });
add.setAttribute("data-sod-rule-add", "");
add.disabled = opts.length < 2;
add.addEventListener("click", () => {
const top = opts.find((o) => o.input === "select") ?? opts[0];
const target = opts.find((o) => o.key !== top.key)!;
def.규칙.push({
종류: type.type_id,
만약: { 옵션: top.key, 값들: top.choices?.length ? [top.choices[0]] : [] },
그러면: { 옵션: target.key, 보이기: false },
});
changed();
});
return el("div", {
className: "m02-sod__block",
children: [
...head(to("RuleHead"), to("RuleNote")),
...(mine.length
? mine.map((r) => ruleRow(opt, r, def.규칙.indexOf(r)))
: [el("p", { className: "m02-side__empty", text: to("RuleNone") })]),
...(edit ? [el("div", { className: "m02-side__row", children: [add] })] : []),
],
});
}
@@ -0,0 +1,121 @@
/* =============================================================================
* M02_SO_Def_Ui.ts
* 구조물 정의 편집 화면(PLAN 48-2)의 작은 입력 부품 — 글 · 숫자 · 체크 · 고르기 · 위아래 단추.
* 값은 받은 손잡이로 사본만 고침 · 글은 칸을 떠날 때(`change`) 다시 그림(초점 안 끊김).
* ========================================================================== */
import { createButton, el } from "@ui/ui_template_elements";
import { step } from "./M02_SO_Def_Model";
import { to } from "./M02_SO_Text";
/** 글 칸 — 글자마다 `set` · 떠날 때 `done` */
export function textBox(
value: string,
set: (v: string) => void,
done: () => void,
opt: { edit: boolean; attr?: string; place?: string; cls?: string; max?: number },
): HTMLInputElement {
const box = el("input", {
className: `ui-input ui-input--sm ${opt.cls ?? ""}`,
attrs: {
type: "text",
maxlength: String(opt.max ?? 40),
...(opt.attr ? { [opt.attr]: "" } : {}),
},
}) as HTMLInputElement;
box.value = value;
box.placeholder = opt.place ?? "";
box.disabled = !opt.edit;
box.addEventListener("input", () => {
set(box.value);
box.classList.toggle("is-invalid", !box.value.trim() && !opt.place);
});
box.addEventListener("change", done);
return box;
}
/** 숫자 칸 — 범위 밖 · 빈 칸은 무름 */
export function numberBox(
value: number,
set: (v: number) => void,
opt: { edit: boolean; min: number; max: number; attr?: string },
): HTMLInputElement {
const box = el("input", {
className: "ui-input ui-input--sm m02-sod__num",
attrs: {
type: "number",
min: String(opt.min),
max: String(opt.max),
...(opt.attr ? { [opt.attr]: "" } : {}),
},
}) as HTMLInputElement;
box.value = String(value);
box.disabled = !opt.edit;
box.addEventListener("change", () => {
const n = Math.round(Number(box.value));
if (box.value === "" || !Number.isFinite(n) || n < opt.min || n > opt.max) {
box.value = String(value);
return;
}
set(n);
});
return box;
}
/** 체크 + 글 */
export function checkBox(
label: string,
on: boolean,
set: (v: boolean) => void,
opt: { edit: boolean; attr?: string },
): HTMLLabelElement {
const box = el("input", {
attrs: { type: "checkbox", ...(opt.attr ? { [opt.attr]: "" } : {}) },
}) as HTMLInputElement;
box.checked = on;
box.disabled = !opt.edit;
box.addEventListener("change", () => set(box.checked));
return el("label", { className: "m02-sod__check", children: [box, el("span", { text: label })] });
}
/** 고르기 — [값, 글] 짝 */
export function pickBox(
pairs: readonly (readonly [string, string])[],
value: string,
set: (v: string) => void,
opt: { edit: boolean; attr?: string; cls?: string },
): HTMLSelectElement {
const box = el("select", {
className: `ui-input ui-input--sm ${opt.cls ?? ""}`,
attrs: opt.attr ? { [opt.attr]: "" } : {},
}) as HTMLSelectElement;
box.append(...pairs.map(([v, t]) => new Option(t, v)));
box.value = value;
box.disabled = !opt.edit;
box.addEventListener("change", () => set(box.value));
return box;
}
/** 위로 · 아래로 단추 둘 — 고치는 사람만 · 옮겼으면 `changed` */
export function moveButtons<T>(
list: T[],
item: T,
changed: () => void,
edit: boolean,
): HTMLElement[] {
if (!edit) return [];
const index = list.indexOf(item);
const make = (label: string, delta: number, off: boolean): HTMLButtonElement => {
const b = createButton({ label, variant: "ghost", size: "sm" });
b.disabled = off;
b.addEventListener("click", () => step(list, list.indexOf(item), delta) && changed());
return b;
};
return [make(to("GroupUp"), -1, index <= 0), make(to("GroupDown"), 1, index >= list.length - 1)];
}
/** 절 제목 + 안내 한 줄 */
export const head = (title: string, note?: string): HTMLElement[] => [
el("h4", { className: "m02-opts__group", text: title }),
...(note ? [el("p", { className: "m02-side__empty", text: note })] : []),
];
@@ -7,7 +7,9 @@
from __future__ import annotations
import json
from collections.abc import Callable
from datetime import datetime
from pathlib import Path
from typing import Any
@@ -32,6 +34,26 @@ def history_path(version: int) -> Path:
return Path(schema.DEFINITION_PATH).parent / HISTORY / f"판_{version}.json"
def history() -> list[dict[str, Any]]:
"""지난 판 목록 `[{판, 저장일}]` — 이력 `판_N.json` · 저장일 = 파일 고친 때(분까지)."""
folder = history_path(1).parent
rows = []
for path in folder.glob("판_*.json") if folder.is_dir() else []:
number = path.stem.removeprefix("판_")
if number.isdigit():
when = datetime.fromtimestamp(path.stat().st_mtime).strftime("%Y-%m-%d %H:%M")
rows.append({"판": int(number), "저장일": when})
return sorted(rows, key=lambda r: r["판"], reverse=True)
def read_version(version: int) -> dict[str, Any]:
"""지난 판 한 벌(읽기만) — 없으면 404."""
path = history_path(version)
if not path.is_file():
raise store.StoreError(404, f"판 {version} 이력이 없음")
return json.loads(path.read_text("utf-8"))
def _type_errors(doc: dict[str, Any]) -> tuple[list[str], dict[str, dict[str, Any]]]:
"""(틀린 것 · 종류 → {옵션 키: 옵션}) — 종류 꼴 · 키 겹침 · 구조물군 있음."""
errors: list[str] = []
@@ -1,19 +1,21 @@
/* =============================================================================
* M02_SO_Groups.ts
* 「구조물 옵션 보이기」 화면의 **옵션 그룹** 부품(PLAN 46-2) — 고른 종류의 그룹 목록
* (이름 · 위로 · 아래로 · 지우기 · [그룹 추가]) · 옵션 줄마다 「들어갈 자리」 고르기(본체 · 그룹).
* 값은 화면이 쥔 고칠 사본(종류 → 그룹[])에만 — 저장은 화면 [저장](`M02_SO_Options.ts`).
* 구조물 정의 화면의 **옵션 그룹** 부품(PLAN 46-2 · 48-2) — 고른 종류의 그룹 목록
* (이름 · 위로 · 아래로 · 지우기 · 형상 칸 길이 · 높이 · 전 · 후 받기 켬끔과 뜻 · [그룹 추가]) ·
* 옵션 줄마다 「들어갈 자리」 고르기(본체 · 그룹).
* 값은 화면이 쥔 고칠 사본에만 — 저장은 화면 [저장](`M02_SO_Def.ts`).
* 그룹 하나 = 프로젝트 B05 · B06 폼의 보조 컨테이너 하나 · 옵션이 없어도 됨(서버 `M02_SO_Groups.py`).
* ========================================================================== */
import { createButton, el, showConfirmDialog } from "@ui/ui_template_elements";
import type { OptionGroupDef } from "./M02_SO_Api";
import { SHAPE_KEYS, type GroupDef } from "./M02_SO_Def_Model";
import { checkBox, textBox } from "./M02_SO_Def_Ui";
import { to } from "./M02_SO_Text";
const NAME_MAX = 40;
/** 새 그룹 id — 서버 규칙 `g[0-9a-z]{1,16}` · 같은 종류 안에서 안 겹치게 */
export function newGroupId(list: readonly OptionGroupDef[]): string {
export function newGroupId(list: readonly GroupDef[]): string {
const taken = new Set(list.map((g) => g.id));
for (;;) {
const id = `g${Math.random().toString(36).slice(2, 8)}`;
@@ -22,35 +24,49 @@ export function newGroupId(list: readonly OptionGroupDef[]): string {
}
/** 그 옵션이 든 그룹 id — 없으면 null(본체) */
export function groupOf(list: readonly OptionGroupDef[], key: string): string | null {
export function groupOf(list: readonly GroupDef[], 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 {
export function moveOption(list: GroupDef[], 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[];
list: GroupDef[];
edit: boolean;
/** 고쳤음 — 화면이 고침 표시 · 다시 그리기 */
changed: () => void;
}
/** 그룹 형상 줄 — 칸마다 받기 체크 + 뜻(무엇을 정하는지 한 줄) */
function shapeLine(group: GroupDef, edit: boolean, changed: () => void): HTMLElement {
const shape = (group.형상 ??= {});
return el("div", {
className: "m02-sog__shape",
attrs: { "data-sog-shape": group.id },
children: SHAPE_KEYS.map((k) => {
const f = shape[k] ?? { 받기: false, 뜻: "" };
const on = checkBox(
to(`Shape_${k}` as const),
f.받기,
(v) => ((shape[k] = { ...f, 받기: v }), changed()),
{ edit, attr: `data-sog-shape-${SHAPE_KEYS.indexOf(k)}` },
);
const mean = textBox(f.뜻, (v) => (shape[k] = { ...(shape[k] ?? f), 뜻: v }), changed, {
edit: edit && f.받기,
cls: "m02-sog__mean",
place: to("ShapeMean"),
});
return el("div", { className: "m02-sog__shapecell", children: [on, mean] });
}),
});
}
/** 그룹 목록 칸 — 제목 · 안내 · 그룹 줄 · [그룹 추가] */
export function buildGroupBlock(opt: GroupBlockOptions): HTMLElement {
const { list } = opt;
@@ -87,7 +103,7 @@ export function buildGroupBlock(opt: GroupBlockOptions): HTMLElement {
list.splice(list.indexOf(group), 1);
opt.changed();
});
return el("div", {
const line = el("div", {
className: "m02-sog__row",
attrs: { "data-sog-group": group.id },
children: [
@@ -99,6 +115,10 @@ export function buildGroupBlock(opt: GroupBlockOptions): HTMLElement {
...(opt.edit ? [up, down, del] : []),
],
});
return el("div", {
className: "m02-sog__item",
children: [line, shapeLine(group, opt.edit, opt.changed)],
});
});
const add = createButton({ label: to("GroupAdd"), variant: "ghost", size: "sm" });
add.setAttribute("data-sog-add", "");
@@ -121,7 +141,7 @@ export function buildGroupBlock(opt: GroupBlockOptions): HTMLElement {
/** 옵션 줄의 「들어갈 자리」 고르기 — 본체 · 그룹 이름들 */
export function buildPlaceSelect(
list: OptionGroupDef[],
list: GroupDef[],
key: string,
edit: boolean,
changed: () => void,
@@ -1,25 +1,12 @@
/* =============================================================================
* M02_SO_Options.ts
* 좌측 「구조물 옵션 보이기」 줄(구조물 집계 양식 아래 별도 · 시스템 관리자만) — 누르면 메인 칸에
* 종류 목록(군별) → 고른 종류의 **그룹**(PLAN 46-2 · `M02_SO_Groups.ts`) + 옵션 줄
* (보이기 체크 · 라벨 · 키 작은 글 · B06 전용 · 필수 표시 · 들어갈 자리) · [저장] · [모두 보임].
* 체크 안 한 옵션은 B05 · B06 구조물 폼에서 숨음(정의 · 값은 그대로).
* 서버 = `M02_SO_Options.py`(정의 「표시」 · 숨긴 것만) · `M02_SO_Groups.py`(정의 「그룹」).
* 좌측 「구조물 옵션 정의」 줄(구조물 집계 양식 아래 별도 · 시스템 층) — 누르면 메인 칸에
* 정의 편집 화면(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) };
}
@@ -84,6 +84,20 @@ def get_structure_definition(session=Depends(verify_session)) -> dict:
return _call(definition.read)
@read_router.get("/structure-definition/history")
def get_structure_definition_history(session=Depends(verify_session)) -> list[dict]:
"""지난 판 목록 `[{판, 저장일}]`(새 판부터) — 로그인 누구나(PLAN 48-2)."""
access.require(session, "system", None)
return _call(definition.history)
@read_router.get("/structure-definition/history/{version}")
def get_structure_definition_at(version: int, session=Depends(verify_session)) -> dict:
"""지난 판 한 벌(읽기만) — 없으면 404."""
access.require(session, "system", None)
return _call(definition.read_version, version)
@read_router.put("/structure-definition")
def put_structure_definition(body: DefinitionBody, session=Depends(verify_session)) -> dict:
"""정의 고쳐 씀 — 시스템 관리자만(403) · 낡은 판 409 · 검사 422 · 판 +1 · 앞 판은 이력."""
@@ -112,3 +112,223 @@
color: var(--color-text-muted);
font-size: 11px;
}
/* 구조물 정의 편집 화면(PLAN 48-2) — 판 띠 · 종류 머리 · 옵션 · 선택지 · 배치 격자 · 규칙 · 지난 판 */
.m02-sod {
max-width: 80rem;
}
.m02-sod__banner {
display: flex;
align-items: center;
gap: var(--spacing-8);
color: var(--color-text-muted);
}
.m02-sod__banner.is-past {
padding: var(--spacing-4) var(--spacing-8);
border: 1px solid var(--color-accent);
border-radius: var(--radius-sm);
background: var(--color-mist-violet);
color: var(--color-text);
}
.m02-sod__family,
.m02-sod__kindrow,
.m02-sod__kindhead,
.m02-sod__option,
.m02-sod__choice,
.m02-sod__place,
.m02-sod__rule {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-8);
}
.m02-sod__family {
margin-top: var(--spacing-8);
}
.m02-sod__family > .ui-input {
flex: 1 1 8rem;
min-width: 0;
font-weight: 600;
}
.m02-sod__kindrow {
flex-wrap: nowrap;
}
.m02-sod__kindrow > .m02-opts__kind {
flex: 1 1 auto;
min-width: 0;
}
.m02-sod__kindrow > button:not(.m02-opts__kind),
.m02-sod__family > button {
flex: none;
white-space: nowrap;
}
.m02-sod__block {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
padding-top: var(--spacing-8);
border-top: 1px solid var(--color-border);
}
.m02-sod__option {
padding: var(--spacing-4) 0;
border-bottom: 1px solid var(--color-border);
}
.is-off > .ui-input,
.m02-opts__kind.is-off > span:first-child {
color: var(--color-text-muted);
text-decoration: line-through;
}
.m02-sod__name {
flex: 1 1 10rem;
min-width: 0;
}
.m02-sod__form,
.m02-sod__default {
width: 8rem;
}
.m02-sod__unit,
.m02-sod__num {
width: 4.5rem;
}
.m02-sod__check {
display: inline-flex;
align-items: center;
gap: var(--spacing-4);
white-space: nowrap;
cursor: pointer;
}
.m02-sod__choices {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
margin: 0 0 var(--spacing-8) var(--spacing-24);
padding-left: var(--spacing-8);
border-left: 2px solid var(--color-border);
}
/* 배치 격자 — 왼쪽 칸 숫자 · 오른쪽 미리보기 */
.m02-sod__layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(12rem, 22rem);
gap: var(--spacing-16);
align-items: start;
}
.m02-sod__place {
flex-wrap: nowrap;
}
.m02-sod__placename {
flex: 1 1 6rem;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.m02-sod__place > .m02-sod__num {
flex: none;
width: 4rem;
}
.m02-sod__grid {
display: grid;
gap: var(--spacing-4);
padding: var(--spacing-8);
border: 1px dashed var(--color-border);
border-radius: var(--radius-sm);
}
.m02-sod__cell {
overflow: hidden;
padding: var(--spacing-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface-raised);
font-size: 11px;
white-space: nowrap;
text-overflow: ellipsis;
}
.m02-sod__cell.is-base {
background: var(--color-mist-violet);
}
.m02-sod__cell.is-bad,
.m02-sod__rule.is-bad {
border-color: var(--color-danger);
}
.m02-sod__rule {
padding: var(--spacing-4);
border: 1px solid transparent;
border-radius: var(--radius-sm);
}
.m02-sod__rulepick {
width: 9rem;
}
.m02-sod__rulechoices {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-8);
}
/* 그룹 형상 칸 — 길이 · 높이 · 전 · 후 받기 + 뜻 */
.m02-sog__item {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
padding-bottom: var(--spacing-4);
border-bottom: 1px solid var(--color-border);
}
.m02-sog__shape {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr));
gap: var(--spacing-4) var(--spacing-12);
}
.m02-sog__shapecell {
display: flex;
align-items: center;
gap: var(--spacing-4);
}
.m02-sog__mean {
flex: 1 1 auto;
min-width: 0;
}
.m02-sod__issues:empty,
.m02-sod__history:empty {
display: none;
}
.m02-sod__history {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-4);
}
.m02-sod__bad {
margin: 0;
color: var(--color-danger);
}
+80 -24
View File
@@ -6,13 +6,12 @@
import { currentLanguageIndex } from "@ui/ui_template_locale";
const TEXT = {
Title: ["구조물 옵션 보이기", "Structure options shown"],
Row: ["종류마다 그룹 · 보일 옵션 정하기", "Groups and shown options per type"],
Screen: ["구조물 옵션 보이기 · 그룹", "Structure options · groups"],
Note: [
"체크한 옵션만 B05 · B06 구조물 폼에 보임 — 옵션 정의 · 이미 넣은 값은 그대로 · 처음은 모두 보임",
"Only checked options appear in the B05 · B06 forms — definitions and stored values stay",
Title: ["구조물 옵션 정의", "Structure option definition"],
Row: [
"구조물군 · 종류 · 옵션 · 배치 · 그룹 · 규칙",
"Families · types · options · layout · groups · rules",
],
Screen: ["구조물 옵션 정의", "Structure option definition"],
GroupHead: ["그룹", "Groups"],
GroupNote: [
"그룹 하나 = 프로젝트 B05 · B06 폼의 보조 컨테이너 하나(제목 = 그룹 이름) · 그 안에서 구조물 조합 항목을 따로 지정 · 옵션이 없어도 연결됨 · 그룹에 안 넣은 옵션은 본체",
@@ -35,35 +34,92 @@ const TEXT = {
],
GroupTag: ["그룹 {value}", "{value} groups"],
Main: ["본체", "Main body"],
OptionHead: ["옵션 — 보이기 · 들어갈 자리", "Options — shown · container"],
OptionHead: ["옵션", "Options"],
OptionNote: [
"이름 · 꼴 · 단위 · 기본값 · 들어갈 자리(본체 · 그룹) · 안 씀 · 순서 — 키(작은 글)는 안 바뀜 · 고르기 꼴은 [선택지]로 목록을 고침",
"Name · form · unit · default · container · unused · order — keys never change",
],
OptionAdd: ["옵션 더함", "Add option"],
OptionNew: ["새 옵션 {value}", "New option {value}"],
FormSelect: ["고르기", "Select"],
FormNumber: ["숫자", "Number"],
FormText: ["글", "Text"],
FormBool: ["예/아니오", "Yes/No"],
Yes: ["예", "Yes"],
No: ["아니오", "No"],
Unit: ["단위", "Unit"],
NoDefault: ["기본값 없음", "No default"],
Unused: ["안 씀", "Unused"],
Choices: ["선택지 {value}", "{value} choices"],
ChoiceAdd: ["선택지 더함", "Add choice"],
ChoiceNew: ["새 선택지 {value}", "New choice {value}"],
ChoiceNone: ["선택지 없음 — 고르기 꼴은 하나 이상", "No choices — select needs at least one"],
ChoiceNote: [
"선택지 글이 곧 저장 값 — 이름을 바꾸면 기본값 · 규칙도 같이 바뀜 · 이미 저장한 프로젝트 값은 「지난 정의 값」으로 남음",
"Choice text is the stored value — renaming updates defaults and rules here",
],
ChoiceDrop: ["빼기", "Remove"],
ChoiceDropAsk: [
"선택지 「{value}」 를 뺌 — 기본값 · 규칙에서도 빠짐 · 이미 저장한 프로젝트 값은 「지난 정의 값」으로 남음",
"Remove choice “{value}” — also from defaults and rules",
],
Shown: ["보이기", "Shown"],
RuleValues: ["값(쉼표로 여럿)", "Values (comma separated)"],
RuleChoices: ["선택지", "Choices"],
LayoutHead: ["배치 격자", "Layout grid"],
LayoutNote: [
"B05 · B06 좌측 구조물 칸(두 칸)의 배치 — 옵션마다 줄 · 칸 · 너비 · 기준 측점은 맨 위 한 줄 · 겹치면 빨강 · 안 정한 옵션은 차례대로",
"Left panel layout (two columns) — row · column · width per option · overlaps in red",
],
GridRow: ["줄", "Row"],
GridCol: ["칸", "Col"],
GridWidth: ["너비", "Width"],
BaseStation: ["기준 측점", "Base station"],
BaseStationShow: ["기준 측점 표시", "Show base station"],
Shape_길이: ["길이", "Length"],
Shape_높이: ["높이", "Height"],
Shape_전: ["기준 측점 전", "Before"],
Shape_후: ["기준 측점 후", "After"],
ShapeMean: ["무엇을 정하는지", "What it controls"],
RuleHead: ["규칙", "Rules"],
RuleNote: [
"만약 상위 옵션 = 값 → 하위 옵션의 선택지 · 보이기 · 기본값 — 「그대로」 칸은 손대지 않음 · 틀린 줄은 빨강",
"If parent = value → child choices · visibility · default",
],
RuleNone: ["규칙 없음", "No rules"],
RuleAdd: ["규칙 더함", "Add rule"],
RuleIf: ["만약", "If"],
RuleThen: ["→", "→"],
Keep: ["보이기 그대로", "Keep visibility"],
Show: ["보임", "Show"],
Hide: ["숨김", "Hide"],
KeepDefault: ["기본값 그대로", "Keep default"],
DefNote: [
"구조물군 · 종류 · 옵션 · 선택지 · 배치 · 그룹 · 규칙 정의 — [저장]하면 판이 하나 오르고 B05 · B06 에 바로 적용 · 지난 판은 이력으로 남음",
"Structure definition — Save bumps the version; past versions stay in history",
],
Version: ["판 {value}", "Version {value}"],
ReadOnly: ["읽기만", "Read only"],
SavedVersion: ["저장함 — 판 {value}", "Saved — version {value}"],
PastList: ["지난 판", "Past versions"],
PastItem: ["판 {value}", "v{value}"],
PastNone: ["지난 판 없음", "No past versions"],
PastView: ["판 {value} 보는 중 — 읽기만", "Viewing version {value} — read only"],
PastBack: ["지금 판으로", "Back to current"],
NoOptionKind: [
"이 종류는 옵션이 없음 — 그룹만 만들 수 있음(그룹마다 조합 연결)",
"No options — groups only (each links a combo)",
],
NameBad: [
"그룹 이름은 1~40자 · 같은 종류 안에서 겹치면 안 됨",
"Group names: 1–40 chars, unique",
],
Group: ["{value}군", "Group {value}"],
Hidden: ["숨김 {value}", "{value} hidden"],
Off: ["안 쓰는 종류", "Not offered"],
NoOption: ["옵션 없음", "No options"],
Pick: ["왼쪽에서 구조물 종류를 고름", "Pick a structure type on the left"],
Detail: ["B06 전용", "B06 only"],
Required: ["필수", "Required"],
RequiredTip: [
"필수 옵션 — 숨기면 폼에 안 나오고 필수로도 안 침(저장이 안 막힘)",
"Required — when hidden it is not shown and not enforced",
],
Save: ["저장", "Save"],
ShowAll: ["모두 보임", "Show all"],
Saved: ["저장함", "Saved"],
Stale: [
"그 사이 옵션 표시가 바뀜 — 새로 받았으니 다시 고쳐 주세요",
"그 사이 정의가 바뀜 — 새로 받았으니 다시 고쳐 주세요",
"Changed meanwhile — reloaded, please edit again",
],
Load_Failed: ["옵션 표시를 못 받음 — {value}", "Could not load option display — {value}"],
Failed: ["옵션 표시를 못 저장함 — {value}", "Could not save option display — {value}"],
Load_Failed: ["구조물 정의를 못 받음 — {value}", "Could not load the definition — {value}"],
Failed: ["구조물 정의를 못 저장함 — {value}", "Could not save the definition — {value}"],
} as const satisfies Record<string, readonly [string, string]>;
export type OptionsTextKey = keyof typeof TEXT;
@@ -0,0 +1,112 @@
/* M02 구조물 정의 편집 화면 자료 손질(`M02_SO_Def_Model.ts` · PLAN 48-2) 단위검증 헬퍼 —
* TS 를 그 자리에서 트랜스파일해 Node 로 돌리고 결과를 JSON 으로 냄(pytest 가 판정). */
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const source = fs.readFileSync(
path.join(__dirname, "..", "..", "M02_MasterTemplete", "StructOptions", "M02_SO_Def_Model.ts"),
"utf8",
);
const js = ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
new Function("exports", "module", "require", js)(box.exports, box, require);
const m = box.exports;
const sel = (key, choices) => ({ key, label: key, input: "select", choices: [...choices] });
const type = () => ({
type_id: "k1",
group: "A",
name: "배수관",
options: [sel("a", ["x", "y"]), sel("b", ["p", "q"]), { key: "c", label: "c", input: "number" }],
});
const defOf = (over = {}) => ({
판: 3,
구조물군: { A: "A군", B: "B군" },
동작: {},
types: [
type(),
{ ...type(), type_id: "k2", name: "둘째" },
{ ...type(), type_id: "k3", group: "B", name: "셋째" },
],
종류: {},
표시: {},
그룹: {},
배치: {},
규칙: [],
...over,
});
const cells = (t, spots) => Object.fromEntries(m.placeAll(t.options, spots));
const out = {};
// 자리 안 정한 옵션 = 차례대로 채움 · 두 칸이면 셋째는 둘째 줄
out.auto = cells(type());
// 정한 자리 먼저 · 남은 것은 빈 칸으로 · 너비는 칸 수를 안 넘음
out.fixed = cells(type(), { c: { 줄: 1, 칸: 1, 너비: 5 } });
// 안 씀은 격자에서 빠짐
const off = type();
off.options[0].enabled = false;
out.off = cells(off);
// 겹침
out.overlap = m
.overlaps(
m.placeAll(type().options, { a: { 줄: 1, 칸: 1, 너비: 2 }, b: { 줄: 1, 칸: 2, 너비: 1 } }),
)
.sort();
// 규칙 — 맞는 줄 · 없는 값 · 자기 자신 · 없는 하위 선택지 · 그러면 비었음 · 고르기 아닌 상위
const rd = defOf();
const R = (만약, 그러면) => ({ 종류: "k1", 만약, 그러면 });
out.rules = [
R({ 옵션: "a", 값들: ["x"] }, { 옵션: "b", 선택지: ["p"], 기본값: "p" }),
R({ 옵션: "a", 값들: ["zz"] }, { 옵션: "b", 보이기: false }),
R({ 옵션: "a", 값들: ["x"] }, { 옵션: "a", 보이기: false }),
R({ 옵션: "a", 값들: ["y"] }, { 옵션: "b", 선택지: ["nope"] }),
R({ 옵션: "a", 값들: ["x"] }, { 옵션: "b" }),
R({ 옵션: "c", 값들: ["3"] }, { 옵션: "b", 보이기: false }),
R({ 옵션: "a", 값들: ["x"] }, { 옵션: "b", 선택지: ["p"], 기본값: "q" }),
].map((r) => m.ruleBad(rd, r));
// 이름 — 빈 이름 · 겹침
out.names = m.badNames(["가", " ", "가"]);
// 저장 전 검사 — 깨끗한 정의는 빈 목록 · 겹침 · 이름 틀림 · 빈 선택지는 까닭
out.clean = m.problems(defOf());
const broken = defOf({
배치: { k1: { a: { 줄: 1, 칸: 1, 너비: 2 }, b: { 줄: 1, 칸: 2, 너비: 1 } } },
});
broken.types[0].options[1].label = "a";
broken.types[1].options[0].choices = [];
out.broken = m.problems(broken);
// 선택지 이름 바꿈 · 뺌 — 기본값 · 규칙 같이
const rn = defOf({
규칙: [R({ 옵션: "a", 값들: ["x", "y"] }, { 옵션: "b", 선택지: ["p"], 기본값: "p" })],
});
const a = rn.types[0].options[0];
const b = rn.types[0].options[1];
a.default = "x";
m.renameChoice(rn, "k1", a, "x", "엑스");
m.renameChoice(rn, "k1", b, "p", "피");
out.rename = [
a.choices,
a.default,
rn.규칙[0].만약.값들,
rn.규칙[0].그러면.선택지,
rn.규칙[0].그러면.기본값,
];
m.dropChoice(rn, "k1", b, "피");
out.drop = [b.choices, rn.규칙[0].그러면.선택지, "기본값" in rn.규칙[0].그러면];
// 순서 — 구조물군 키 차례 · 종류는 같은 군 안에서만
const od = defOf();
out.family = [m.stepFamily(od, "A", -1), m.stepFamily(od, "A", 1), Object.keys(od.구조물군)];
out.type = [m.stepType(od, "k2", 1), m.stepType(od, "k1", 1), od.types.map((t) => t.type_id)];
out.base = [
m.baseShown(defOf(), "k1"),
m.baseShown(defOf({ 종류: { k1: { 기준측점: false } } }), "k1"),
];
// 목록 순서 바꿈 · 끝은 그대로
const list = [1, 2, 3];
out.step = [m.step(list, 0, -1), m.step(list, 0, 1), list];
// 새 키 — 머리글 · 안 겹침 · 영문 소문자 숫자
const k = m.newKey("opt_", ["opt_aaaaaa"]);
out.newKey = [/^opt_[0-9a-z]{4,}$/.test(k), k !== "opt_aaaaaa"];
process.stdout.write(JSON.stringify(out));
+87
View File
@@ -0,0 +1,87 @@
"""M02 구조물 정의 편집 화면 자료 손질(`M02_SO_Def_Model.ts` · PLAN 48-2) 단위검증.
프론트에 JS 테스트 러너가 없어 Node 헬퍼(`helper_m02_so_def_model.cjs`)가 TS 를 트랜스파일해 돌리고
pytest 는 그 결과(JSON)를 판정.
못박는 것
- 배치 격자: 안 정한 옵션은 차례대로 · 정한 자리 먼저 · 너비 ≤ 칸 수 · 안 씀 빠짐 · 겹침 찾음.
- 규칙: 없는 값 · 자기 자신 · 없는 하위 선택지 · 그러면 비었음 · 좁힌 선택지 밖 기본값은 틀린 줄
(서버 `M02_SO_Definition._rule_errors` 와 같은 뜻) · 고르기 아닌 상위는 값 검사 안 함.
- 저장 전 검사: 깨끗한 정의 = 빈 목록 · 이름 겹침 · 배치 겹침 · 빈 선택지 = 까닭 글.
- 선택지 이름 바꿈 · 뺌은 그 옵션 기본값 · 같은 종류 규칙도 같이 고침.
- 순서: 구조물군 = 키 차례 · 종류 = 같은 군 안에서만.
"""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
import pytest
HERE = Path(__file__).resolve().parent
@pytest.fixture(scope="module")
def r() -> dict:
proc = subprocess.run(
["node", str(HERE / "helper_m02_so_def_model.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_배치_자동_채움(r) -> None:
assert r["auto"] == {
"a": {"줄": 1, "칸": 1, "너비": 1},
"b": {"줄": 1, "칸": 2, "너비": 1},
"c": {"줄": 2, "칸": 1, "너비": 1},
}
def test_배치_정한_자리_먼저_너비는_칸수까지(r) -> None:
assert r["fixed"]["c"] == {"줄": 1, "칸": 1, "너비": 2}
assert r["fixed"]["a"] == {"줄": 2, "칸": 1, "너비": 1}
assert r["fixed"]["b"] == {"줄": 2, "칸": 2, "너비": 1}
def test_안씀은_격자에서_빠짐(r) -> None:
assert set(r["off"]) == {"b", "c"}
assert r["off"]["b"] == {"줄": 1, "칸": 1, "너비": 1}
def test_겹침(r) -> None:
assert r["overlap"] == ["a", "b"]
def test_규칙_틀린_줄(r) -> None:
assert r["rules"] == [False, True, True, True, True, False, True]
def test_이름_검사(r) -> None:
assert r["names"] == ["(빈 이름)", "가"]
def test_저장_전_검사(r) -> None:
assert r["clean"] == []
assert any("옵션 이름" in t for t in r["broken"])
assert any("배치 겹침" in t for t in r["broken"])
assert any("선택지가 있어야" in t for t in r["broken"])
def test_선택지_이름_바꿈_뺌은_기본값_규칙도_같이(r) -> None:
assert r["rename"] == [["엑스", "y"], "엑스", ["엑스", "y"], ["피"], "피"]
assert r["drop"] == [["q"], [], False]
def test_순서_기준측점_새키(r) -> None:
assert r["family"] == [False, True, ["B", "A"]]
assert r["type"] == [False, True, ["k2", "k1", "k3"]]
assert r["base"] == [True, False]
assert r["step"] == [False, True, [2, 1, 3]]
assert r["newKey"] == [True, True]
@@ -0,0 +1,123 @@
"""M02 구조물 정의 편집 화면(PLAN 48-2) — 지난 판 길 · 화면이 보내는 꼴을 서버가 받음.
못박는 것
- 지난 판 목록 `[{판, 저장일}]` 새 판부터 · 한 벌 읽기 · 없는 판 404 · 읽기는 누구나.
- 화면이 고친 정의(이름 · 순서 · 안 씀 · 기준측점 · 선택지 이름 · 배치 · 그룹 형상 · 규칙)를
그대로 PUT 하면 판 +1 · 같은 값으로 다시 읽힘.
- 시스템 관리자 아니면 PUT 403(화면은 읽기만).
- 마스터 정본은 안 씀(사본).
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from B05_Profile import B05_Profile_Structures_Schema as schema
from common_util.common_util_auth import verify_session
from M02_MasterTemplete.StructOptions.M02_SO_Router import read_router
URL = "/api/m02/structure-definition"
ADMIN = {"role": "SYSTEM_ADMIN", "user_id": 1}
USER = {"role": "USER", "user_id": 2, "company_id": 1}
@pytest.fixture
def copy(tmp_path: Path, monkeypatch) -> Path:
path = tmp_path / "구조물정의.json"
path.write_text(Path(schema.DEFINITION_PATH).read_text("utf-8"), "utf-8")
monkeypatch.setattr(schema, "DEFINITION_PATH", str(path))
return path
def _client(session: dict) -> TestClient:
app = FastAPI()
app.include_router(read_router)
app.dependency_overrides[verify_session] = lambda: session
return TestClient(app)
def _screen_edit(doc: dict) -> dict:
"""화면이 하는 고침 한 벌 — `M02_SO_Def*.ts` 가 만드는 꼴 그대로."""
pipe = next(t for t in doc["types"] if t["type_id"] == "pipe")
pipe["name"] = "배수관(고침)"
kind = next(o for o in pipe["options"] if o["key"] == "pipe_kind")
kind["label"] = "관 종류"
kind["choices"] = ["흄관(고침)" if c == "흄관" else c for c in kind["choices"]]
pipe["options"].append(
{
"key": "opt_ab12cd",
"label": "새 옵션 1",
"input": "select",
"choices": ["새 선택지 1", "새 선택지 2"],
"default": None,
}
)
pipe["options"][1]["enabled"] = False # 안 씀
first = list(doc["구조물군"])
doc["구조물군"] = {k: doc["구조물군"][k] for k in [first[1], first[0], *first[2:]]}
doc["종류"].setdefault("pipe", {})["기준측점"] = False
doc["배치"]["pipe"] = {"pipe_kind": {"줄": 1, "칸": 1, "너비": 2}}
doc["표시"]["pipe"] = {"inlet_type": False}
doc["그룹"]["pipe"][1]["형상"] = {
"길이": {"받기": True, "뜻": "유입 보호공 연장"},
"높이": {"받기": False, "뜻": ""},
}
doc["규칙"] = [
{
"종류": "pipe",
"만약": {"옵션": "pipe_kind", "값들": ["흄관(고침)"]},
"그러면": {"옵션": "opt_ab12cd", "선택지": ["새 선택지 2"], "기본값": "새 선택지 2"},
},
{
"종류": "pipe",
"만약": {"옵션": "inlet_type", "값들": ["집수정"]},
"그러면": {"옵션": "opt_ab12cd", "보이기": False},
},
]
return doc
def test_화면_고침을_그대로_받아_판_더하기_1_지난_판_길(copy) -> None:
admin = _client(ADMIN)
doc = admin.get(URL).json()
assert admin.get(f"{URL}/history").json() == []
sent = _screen_edit(json.loads(json.dumps(doc)))
res = admin.put(URL, json={"판": doc["판"], "정의": sent})
assert res.status_code == 200, res.text
got = res.json()
assert got["판"] == doc["판"] + 1
for key in ("구조물군", "종류", "배치", "표시", "그룹", "규칙", "types"):
assert got[key] == sent[key], key
assert list(got["구조물군"]) == list(sent["구조물군"]) # 순서 그대로
rows = admin.get(f"{URL}/history").json()
assert [r["판"] for r in rows] == [doc["판"]] and len(rows[0]["저장일"]) == 16
old = admin.get(f"{URL}/history/{doc['판']}").json()
assert old == doc
assert admin.get(f"{URL}/history/999").status_code == 404
def test_지난_판은_누구나_읽고_쓰기는_시스템_관리자만(copy) -> None:
user = _client(USER)
doc = user.get(URL).json()
assert user.get(f"{URL}/history").status_code == 200
assert user.put(URL, json={"판": doc["판"], "정의": _screen_edit(doc)}).status_code == 403
assert json.loads(copy.read_text("utf-8"))["판"] == doc["판"]
def test_화면이_못_거른_틀린_규칙은_422_안_씀(copy) -> None:
admin = _client(ADMIN)
doc = admin.get(URL).json()
doc["규칙"] = [
{
"종류": "pipe",
"만약": {"옵션": "pipe_kind", "값들": ["없음"]},
"그러면": {"옵션": "inlet_type"},
}
]
assert admin.put(URL, json={"판": doc["판"], "정의": doc}).status_code == 422
assert json.loads(copy.read_text("utf-8"))["판"] == doc["판"]
@@ -102,8 +102,10 @@ def test_틀린_그룹은_422_낡은_판은_409(client) -> None:
assert res.status_code == 200 and res.json()["그룹"] == {}
def test_좌측_옵션_보이기는_시스템_관리자만() -> None:
def test_좌측_정의_줄은_시스템_층에_늘_고쳐_씀은_시스템_관리자만() -> None:
"""PLAN 48-2 — 다른 역할은 읽기만(화면이 칸을 잠금 · 서버 PUT 403)."""
side = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts").read_text("utf-8")
assert "옵션: canEdit(layer, opt.isAdmin) ? section(options.title, options.body) : null" in side
ui = (ROOT / "M02_MasterTemplete/StructOptions/M02_SO_Options.ts").read_text("utf-8")
assert "buildGroupBlock(" in ui and "buildPlaceSelect(" in ui
assert "옵션: section(options.title, options.body)," in side
folder = ROOT / "M02_MasterTemplete/StructOptions"
assert "buildGroupBlock(" in (folder / "M02_SO_Def.ts").read_text("utf-8")
assert "buildPlaceSelect(" in (folder / "M02_SO_Def_Options.ts").read_text("utf-8")
@@ -123,12 +123,10 @@ def test_좌측_줄은_구조물_집계_양식_아래_한_줄_같은_한_곳에
assert "matset" not in side
paint = side.split("function paint(): void {")[1].split("\n }\n")[0]
assert "if (opt.getOpen()) options.clear();" in paint # 양식을 열면 이 줄 고름 지움
# 화면은 정의 한 벌로 읽고 씀(PLAN 48-2) · 보이기 체크는 정의 화면 옵션 줄
api = (ROOT / "M02_MasterTemplete/StructOptions/M02_SO_Api.ts").read_text("utf-8")
assert (
'call("/layers/system/structure-options")' in api
and 'call("/structure-options", { method: "PUT"' in api
)
ui = (ROOT / "M02_MasterTemplete/StructOptions/M02_SO_Options.ts").read_text("utf-8")
assert 'call("/structure-definition")' in api
ui = (ROOT / "M02_MasterTemplete/StructOptions/M02_SO_Def_Options.ts").read_text("utf-8")
assert 'o.phase === "detail"' in ui # B06 전용 표시
@@ -5,7 +5,7 @@
* - def = { 판 ≥ 1, types: 37, options: 235 }(GET /api/m02/structure-definition)
* - types = { types: 37, options: 235, visibleAll: true, pipeGroups: ["관", "관 보호공(유입)", "관 보호공(유출)"] }
* (GET /api/projects/structure-types — 옛 꼴 그대로)
* - 좌측 「구조물 옵션 보이기」 → 종류 단추 kinds = 37(옵션 없는 종류도 · 옛 화면 그대로)
* - 좌측 「구조물 옵션 정의」 → 종류 단추 kinds = 37(옵션 없는 종류도 · 48-2 정의 화면)
* · pipe 단추에 그룹 표지 「3」
*/
(async () => {
@@ -41,14 +41,14 @@
pipeGroups: pipe.groups.map((g) => g.name),
};
const row = await until(() => $("[data-options-row]"));
if (!row) return JSON.stringify({ ...out, error: "옵션 보이기 줄 없음" });
if (!row) return JSON.stringify({ ...out, error: "정의 줄 없음" });
row.click();
await until(() => $$("[data-options-kind]").length);
const kinds = $$("[data-options-kind]");
await until(() => $$("[data-sod-kind]").length);
const kinds = $$("[data-sod-kind]");
out.screen = {
kinds: kinds.length,
withOptions: def.types.filter((t) => t.options.length).length,
pipeTag: $('[data-options-kind="pipe"]')?.innerText.replace(/\s+/g, " ").trim() ?? null,
pipeTag: $('[data-sod-kind="pipe"]')?.innerText.replace(/\s+/g, " ").trim() ?? null,
};
out.ok =
out.def.types === 37 &&
@@ -0,0 +1,145 @@
/* 48-2 — M02 구조물 옵션 정의 편집 화면(군 · 종류 · 옵션 · 선택지 · 배치 격자 · 그룹 형상 · 규칙 · 지난 판)
* 시작 화면: M02 마스터 템플릿 · 마스터 층(#/m02-master-template?root=master) · 시스템 관리자
* 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌 · 20초 안)
* 쓰는 것: 없음 — 화면 사본만 고치고 [저장] 안 누름(저장 길은 test_m02_structure_def_screen.py 가 사본으로) ·
* 끝에 판이 그대로인지 GET 으로 봄 · 화면을 다시 띄우면 고침은 사라짐
* 기대 수치:
* - families = 구조물군 수 · kinds = 37 · pipe 줄 options = pipe 옵션 수 · cells = 쓰는 옵션 수 · base = true
* - 이름 바꿈 → 왼쪽 단추 글 따라감 · [저장] 켜짐
* - 선택지 펼침 = 3(흄관 · VR관 · 파형강관) · 「흄관」 → 「흄관X」 = 기본값 고르기에도 「흄관X」
* - 너비 2 → 미리보기 span 2 · 겹치게 두면 issues 에 「배치 겹침」 · [저장] 꺼짐 · 되돌리면 사라짐
* - 기준 측점 끔 → 미리보기 기준 측점 칸 0
* - 그룹 형상 「길이」 켬 → 뜻 칸 열림
* - [규칙 더함] → 규칙 줄 1 · 빨강 아님 · 지우기 → 0
* - [지난 판] → 목록 칸(판 단추 또는 「지난 판 없음」)
* - 끝 판 = 처음 판(저장 안 함)
*/
(async () => {
const W = (ms) => new Promise((r) => setTimeout(r, ms));
const $ = (s, root = document) => root.querySelector(s);
const $$ = (s, root = document) => [...root.querySelectorAll(s)];
const until = async (fn, ms = 8000) => {
for (let t = 0; t < ms; t += 120) {
const v = fn();
if (v) return v;
await W(120);
}
return null;
};
/** 칸 값 바꿈 — 글자 넣음(input) + 떠남(change) */
const put = (box, value) => {
box.value = value;
box.dispatchEvent(new Event("input", { bubbles: true }));
box.dispatchEvent(new Event("change", { bubbles: true }));
};
const tick = (box, on) => {
if (box.checked !== on) box.click();
};
const def = await (
await fetch("/api/m02/structure-definition", { credentials: "include" })
).json();
if (!def.types) return JSON.stringify({ error: "정의 못 읽음" });
const pipe = def.types.find((t) => t.type_id === "pipe");
const out = { 판: def.판 };
const row = await until(() => $("[data-options-row]"));
if (!row) return JSON.stringify({ error: "정의 줄 없음" });
row.click();
if (!(await until(() => $$("[data-sod-kind]").length)))
return JSON.stringify({ error: "종류 목록 없음" });
out.families = [$$("[data-sod-family]").length, Object.keys(def.구조물군).length];
out.kinds = $$("[data-sod-kind]").length;
$('[data-sod-kind="pipe"]').click();
await until(() => $('[data-sod-option="pipe_kind"]'));
const liveOpts = pipe.options.filter((o) => o.enabled !== false).length;
out.pipe = {
options: [$$("[data-sod-option]").length, pipe.options.length],
cells: [$$("[data-sod-cell]").length, liveOpts],
base: !!$(".m02-sod__cell.is-base"),
};
// 이름 바꿈 → 왼쪽 단추 · [저장] 켜짐
put($("[data-sod-kind-name]"), "배수관(시험)");
out.rename = {
left: $('[data-sod-kind="pipe"]').innerText.includes("배수관(시험)"),
save: !$("[data-sod-save]").disabled,
};
// 선택지 펼침 · 이름 바꿈 → 기본값 고르기에도
$('[data-sod-choices="pipe_kind"]').click();
await until(() => $$("[data-sod-choice]").length);
out.choices = $$("[data-sod-choice]").map((r) => $("input", r).value);
put($('[data-sod-choice="흄관"] [data-sod-choice-name]'), "흄관X");
const defaultPick = $('[data-sod-option="pipe_kind"] [data-sod-default]');
out.choiceRename = [...defaultPick.options].some((o) => o.value === "흄관X");
// 배치 — 너비 2 · 겹침 · 되돌림
put($('[data-sod-place-row="pipe_kind"] [data-sod-width]'), "2");
const cell = $('[data-sod-cell="pipe_kind"]');
out.width2 = cell.style.gridColumn.replace(/\s+/g, " ");
const other = pipe.options.filter((o) => o.enabled !== false)[1].key;
put($(`[data-sod-place-row="${other}"] [data-sod-row]`), "1");
put($(`[data-sod-place-row="${other}"] [data-sod-col]`), "2");
out.overlap = {
issue: $("[data-sod-issues]").innerText.includes("배치 겹침"),
red: $$(".m02-sod__cell.is-bad").length,
saveOff: $("[data-sod-save]").disabled,
};
put($('[data-sod-place-row="pipe_kind"] [data-sod-width]'), "1");
out.overlapGone = !$("[data-sod-issues]").innerText.includes("배치 겹침");
// 기준 측점 끔
tick($("[data-sod-base]"), false);
out.baseOff = $$(".m02-sod__cell.is-base").length;
// 그룹 형상 「길이」 켬 → 뜻 칸 열림
const shape = $("[data-sog-shape]");
const meanBefore = $(".m02-sog__mean", shape).disabled;
tick($("[data-sog-shape-0]", shape), true);
out.shape = { before: meanBefore, after: $(".m02-sog__mean", $("[data-sog-shape]")).disabled };
// 규칙 더함 · 지우기
$("[data-sod-rule-add]").click();
const rule = await until(() => $("[data-sod-rule]"));
out.rule = {
rows: $$("[data-sod-rule]").length,
bad: rule.classList.contains("is-bad"),
top: $("[data-sod-rule-top]", rule).value,
target: $("[data-sod-rule-target]", rule).value,
};
[...$$("button", rule)].find((b) => b.innerText.trim() === "지우기").click();
out.ruleGone = $$("[data-sod-rule]").length;
// 지난 판 목록
$("[data-sod-history-toggle]").click();
await until(() => $("[data-sod-history]").children.length);
out.history = $("[data-sod-history]").innerText.replace(/\s+/g, " ").trim().slice(0, 80);
const after = await (
await fetch("/api/m02/structure-definition", { credentials: "include" })
).json();
out.판그대로 = after.판 === def.판;
out.ok =
out.families[0] === out.families[1] &&
out.kinds === 37 &&
out.pipe.options[0] === out.pipe.options[1] &&
out.pipe.cells[0] === out.pipe.cells[1] &&
out.pipe.base &&
out.rename.left &&
out.rename.save &&
out.choices.length === 3 &&
out.choiceRename &&
out.width2.includes("span 2") &&
out.overlap.issue &&
out.overlap.saveOff &&
out.overlapGone &&
out.baseOff === 0 &&
out.shape.before === true &&
out.shape.after === false &&
out.rule.rows === 1 &&
!out.rule.bad &&
out.ruleGone === 0 &&
out.history.length > 0 &&
out.판그대로;
return JSON.stringify(out);
})();