diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts index 8504ca93e..5fc86b60a 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts @@ -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) const extras = layer === "system" ? { - 옵션: section(options.title, options.body), + 옵션: canEdit(layer, opt.isAdmin) ? section(options.title, options.body) : null, 도면: section(L("M02_KindDrawing"), bodies.get("drawing")!, ...actionRow("drawing")), } : null; @@ -151,7 +151,7 @@ export function buildSide(opt: SideOptions): SideHandle { className: "m02-side__lists ui-sidebar-panel", children: [ summary, - ...(extras ? [extras.옵션, extras.도면] : []), + ...(extras ? [extras.옵션, extras.도면].filter((s): s is HTMLElement => !!s) : []), ...(practice ? [section(practice.title, practice.body)] : []), ], }); @@ -308,7 +308,9 @@ export function buildSide(opt: SideOptions): SideHandle { } const reveal = (target: "옵션" | "도면"): void => { - extras?.[target].classList.remove("is-collapsed"); + const sec = extras?.[target]; + if (!sec) return; // 옵션 보이기는 시스템 관리자만 + sec.classList.remove("is-collapsed"); if (target === "옵션") options.body.querySelector("button")?.click(); }; diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Api.ts b/M02_MasterTemplete/StructOptions/M02_SO_Api.ts index 61430d310..a8bafd1fa 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Api.ts +++ b/M02_MasterTemplete/StructOptions/M02_SO_Api.ts @@ -1,6 +1,7 @@ /* ============================================================================= * M02_SO_Api.ts - * 구조물 옵션 보이기 서버 호출 — 서버 = `M02_SO_Router.py`(마스터 템플릿 `구조물옵션표시.json`) + * 구조물 옵션 보이기 · 그룹 서버 호출 — 서버 = `M02_SO_Router.py` + * (마스터 템플릿 `구조물옵션표시.json` · `구조물옵션그룹.json`) * ========================================================================== */ import { call } from "../M02_MasterTemplete_Api_Fetch"; @@ -27,3 +28,29 @@ export const fetchStructureOptions = (): Promise => /** 고쳐 씀 — 숨긴 것만 보냄 · 낡은 판이면 StaleError · 돌려받는 것은 새 문서 */ export const saveStructureOptions = (판: string, 표시: OptionsDoc["표시"]): Promise => call("/structure-options", { method: "PUT", body: JSON.stringify({ 판, 표시 }) }); + +/* --- 옵션 그룹(PLAN 46-2 · 마스터 템플릿 `구조물옵션그룹.json` · 시스템 관리자만) --- */ + +/** 그룹 하나 = 프로젝트 폼의 보조 컨테이너 하나(제목 = 이름) · 옵션 0 도 됨 */ +export interface OptionGroupDef { + id: string; + 이름: string; + 옵션: string[]; +} + +/** 판 · 그룹(`{종류: 그룹[]}`) · 종류(레지스트리 전부 — 옵션 없는 종류도) */ +export interface GroupsDoc { + 판: string; + 그룹: Record; + 종류: OptionKind[]; +} + +export const fetchStructureOptionGroups = (): Promise => + call("/structure-option-groups"); + +/** 고쳐 씀 — 낡은 판이면 StaleError · 돌려받는 것은 새 문서 */ +export const saveStructureOptionGroups = ( + 판: string, + 그룹: GroupsDoc["그룹"], +): Promise => + call("/structure-option-groups", { method: "PUT", body: JSON.stringify({ 판, 그룹 }) }); diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Groups.py b/M02_MasterTemplete/StructOptions/M02_SO_Groups.py new file mode 100644 index 000000000..6a23fb62e --- /dev/null +++ b/M02_MasterTemplete/StructOptions/M02_SO_Groups.py @@ -0,0 +1,148 @@ +"""M02 구조물 옵션 그룹 — 구조물 종류 안에 이름 붙은 묶음(PLAN 46-2). + +정본 = `resources/master_template/구조물옵션그룹.json` = + `{type_id: [{"id": "g1a2b3c", "이름": "유입구 기슭막이", "옵션": ["inlet_revet_form", …]}]}` +— 없는 종류 = 그룹 없음(파일이 없어도 됨) · 빈 그룹(옵션 0)도 그룹. + +그룹 하나 = 프로젝트 B05 · B06 폼의 보조 컨테이너 하나(제목 = 이름) — 그 안에서 구조물 조합 +항목 하나를 지정(구조물 기록 `group_items[그룹 id]`). 그룹에 안 넣은 옵션 = 본체 칸(`summary_item`). +옵션 하나는 한 종류 안에서 그룹 하나에만. 시스템 공통 · 고쳐 씀은 시스템 관리자(길이 막음). +판 = 파일 sha256 앞 16자(`store.version_of`) · 낡은 판으로 쓰면 409. +""" + +from __future__ import annotations + +import json +import logging +import re +from typing import Any + +from B05_Profile.B05_Profile_Structures_Schema import load_structure_types +from common_util.common_util_json import atomic_write_json +from M02_MasterTemplete import M02_MasterTemplete_Store as store + +FILE = "구조물옵션그룹.json" +GROUP_ID = re.compile(r"^g[0-9a-z]{1,16}$") +NAME_MAX = 40 +logger = logging.getLogger(__name__) + + +def _load() -> tuple[dict[str, Any], str]: + """(그룹 표 · 판) — 파일이 없으면 빈 표 · 판 빈 글.""" + path = store.FOLDER / FILE + if not path.is_file(): + return {}, "" + raw = path.read_bytes() + try: + doc = json.loads(raw) + except ValueError as e: + raise store.StoreError(500, f"{FILE} 을 못 읽음 — {e}") from e + if not isinstance(doc, dict): + raise store.StoreError(500, f"{FILE} 은 종류별 묶음이어야 함") + return doc, store.version_of(raw) + + +def groups_of() -> dict[str, list[dict[str, Any]]]: + """종류 → `[{id, name, options}]` — 폼 응답용 · 깨진 표 · 모르는 종류는 빼고 로그만.""" + try: + doc = _load()[0] + except store.StoreError as e: + logger.warning("구조물 옵션 그룹 표를 못 읽어 그룹 없음으로 봄 — %s", e.detail) + return {} + known = {t.type_id: {o.key for o in t.options} for t in load_structure_types()} + out: dict[str, list[dict[str, Any]]] = {} + for type_id, rows in doc.items(): + if type_id not in known or not isinstance(rows, list): + continue + for row in rows: + if not isinstance(row, dict) or not GROUP_ID.match(str(row.get("id", ""))): + continue + keys = [k for k in row.get("옵션") or [] if k in known[type_id]] + out.setdefault(type_id, []).append( + {"id": row["id"], "name": str(row.get("이름") or row["id"]), "options": keys} + ) + return out + + +def listing() -> dict[str, Any]: + """`{판, 그룹, 종류}` — 종류 = 레지스트리 전부(옵션 없는 종류도 · 빈 그룹을 둘 수 있음).""" + doc, version = _load() + kinds = [ + { + "type_id": t.type_id, + "group": t.group, + "name": t.name, + "enabled": t.enabled, + "options": [ + {"key": o.key, "label": o.label, "phase": o.phase, "required": o.required} + for o in t.options + ], + } + for t in load_structure_types() + ] + return {"판": version, "그룹": doc, "종류": kinds} + + +def _clean(groups: Any) -> dict[str, list[dict[str, Any]]]: + """받은 표 검사 — 없는 종류 · 키 · 겹친 id · 이름 · 옵션 두 그룹은 422 · 빈 종류는 뺌.""" + if not isinstance(groups, dict): + raise store.StoreError(400, "그룹은 종류별 묶음이어야 함") + keys = {t.type_id: {o.key for o in t.options} for t in load_structure_types()} + errors: list[str] = [] + out: dict[str, list[dict[str, Any]]] = {} + for type_id, rows in groups.items(): + if type_id not in keys: + errors.append(f"없는 구조물 종류 「{type_id}」") + continue + if not isinstance(rows, list): + errors.append(f"「{type_id}」 는 그룹 목록이어야 함") + continue + ids: set[str] = set() + names: set[str] = set() + owner: dict[str, str] = {} + clean: list[dict[str, Any]] = [] + for row in rows: + if not isinstance(row, dict): + errors.append(f"「{type_id}」 그룹은 묶음이어야 함") + continue + gid = str(row.get("id", "")) + name = str(row.get("이름", "")).strip() + opts = row.get("옵션") or [] + if not GROUP_ID.match(gid) or gid in ids: + errors.append(f"「{type_id}」 그룹 id 「{gid}」 가 틀리거나 겹침") + if not name or len(name) > NAME_MAX or name in names: + errors.append( + f"「{type_id}」 그룹 이름 「{name}」 은 1~{NAME_MAX}자 · 겹치면 안 됨" + ) + if not isinstance(opts, list): + errors.append(f"「{type_id}.{name}」 옵션은 목록이어야 함") + opts = [] + for key in opts: + if key not in keys[type_id]: + errors.append(f"「{type_id}」 에 없는 옵션 「{key}」") + elif key in owner: + errors.append( + f"「{type_id}.{key}」 는 「{owner[key]}」 · 「{name}」 두 그룹에 있음" + ) + else: + owner[key] = name + ids.add(gid) + names.add(name) + clean.append({"id": gid, "이름": name, "옵션": [k for k in opts if k in keys[type_id]]}) + if clean: + out[type_id] = clean + if errors: + raise store.StoreError(422, {"errors": errors}) + return out + + +def save(groups: Any, version: str) -> dict[str, Any]: + """고쳐 씀 — 판이 같을 때만. 돌려주는 것 = 새 `listing()`.""" + fixed = _clean(groups) + path = store.FOLDER / FILE + with store._LOCK: + have = store.version_of(path.read_bytes()) if path.is_file() else "" + if have != (version or ""): + raise store.StoreError(409, {"stale": [FILE], "판": have}) + atomic_write_json(path, fixed) + return listing() diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Groups.ts b/M02_MasterTemplete/StructOptions/M02_SO_Groups.ts new file mode 100644 index 000000000..242dad8b4 --- /dev/null +++ b/M02_MasterTemplete/StructOptions/M02_SO_Groups.ts @@ -0,0 +1,141 @@ +/* ============================================================================= + * 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): 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; +} diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Options.py b/M02_MasterTemplete/StructOptions/M02_SO_Options.py index e5fb55147..18ad33184 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Options.py +++ b/M02_MasterTemplete/StructOptions/M02_SO_Options.py @@ -13,7 +13,7 @@ import json import logging from typing import Any -from pydantic import Field +from pydantic import BaseModel, Field from B05_Profile.B05_Profile_Structures_Schema import ( StructureOptionField, @@ -24,6 +24,7 @@ from B05_Profile.B05_Profile_Structures_Schema import ( ) from common_util.common_util_json import atomic_write_json from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete.StructOptions.M02_SO_Groups import groups_of FILE = "구조물옵션표시.json" logger = logging.getLogger(__name__) @@ -35,12 +36,21 @@ class OptionWithVisible(StructureOptionField): visible: bool = True +class OptionGroup(BaseModel): + """옵션 그룹 한 개(PLAN 46-2 · `M02_SO_Groups`) — 폼의 보조 컨테이너 하나 · 빈 옵션도 됨.""" + + id: str + name: str + options: list[str] = Field(default_factory=list) + + class TypeWithVisible(StructureType): options: list[OptionWithVisible] = Field(default_factory=list) + groups: list[OptionGroup] = Field(default_factory=list) class TypesWithVisible(StructureTypesResponse): - """`GET /structure-types` 응답 — 옵션마다 `visible` 칸.""" + """`GET /structure-types` 응답 — 옵션마다 `visible` 칸 · 종류마다 `groups`.""" types: list[TypeWithVisible] @@ -86,12 +96,14 @@ def with_visible() -> TypesWithVisible: except store.StoreError as e: logger.warning("구조물 옵션 표시 표를 못 읽어 모두 보임으로 냄 — %s", e.detail) doc = {} + groups = groups_of() types = [ { **t.model_dump(), "options": [ {**o.model_dump(), "visible": _shown(doc, t.type_id, o.key)} for o in t.options ], + "groups": groups.get(t.type_id, []), } for t in load_structure_types() ] diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Options.ts b/M02_MasterTemplete/StructOptions/M02_SO_Options.ts index f14fb6187..d340d13f6 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Options.ts +++ b/M02_MasterTemplete/StructOptions/M02_SO_Options.ts @@ -1,19 +1,25 @@ /* ============================================================================= * M02_SO_Options.ts - * 좌측 「구조물 옵션 보이기」 줄(구조물 집계 양식 아래 별도) — 누르면 메인 칸에 - * 종류 목록(군별) → 고른 종류의 옵션 체크 목록(라벨 · 키 작은 글 · B06 전용 · 필수 표시) · - * [저장] · [모두 보임]. 체크 안 한 옵션은 B05 · B06 구조물 폼에서 숨음(정의 · 값은 그대로). - * 서버 = `M02_SO_Options.py`(마스터 템플릿 `구조물옵션표시.json` · 숨긴 것만 저장). + * 좌측 「구조물 옵션 보이기」 줄(구조물 집계 양식 아래 별도 · 시스템 관리자만) — 누르면 메인 칸에 + * 종류 목록(군별) → 고른 종류의 **그룹**(PLAN 46-2 · `M02_SO_Groups.ts`) + 옵션 줄 + * (보이기 체크 · 라벨 · 키 작은 글 · B06 전용 · 필수 표시 · 들어갈 자리) · [저장] · [모두 보임]. + * 체크 안 한 옵션은 B05 · B06 구조물 폼에서 숨음(정의 · 값은 그대로). + * 서버 = `M02_SO_Options.py`(`구조물옵션표시.json` · 숨긴 것만) · `M02_SO_Groups.py`(`구조물옵션그룹.json`). * ========================================================================== */ import { createButton, createTag, el, showToast } from "@ui/ui_template_elements"; import { StaleError } from "../M02_MasterTemplete_Api_Fetch"; import { + fetchStructureOptionGroups, fetchStructureOptions, + saveStructureOptionGroups, saveStructureOptions, + type GroupsDoc, + type OptionGroupDef, type OptionKind, type OptionsDoc, } from "./M02_SO_Api"; +import { buildGroupBlock, buildPlaceSelect, namesOk } from "./M02_SO_Groups"; import "./M02_SO_Style.css"; import { to } from "./M02_SO_Text"; @@ -55,17 +61,19 @@ export function buildOptionsSection(opt: OptionsSectionOptions): OptionsSection async function open(): Promise { if (opened || !(await opt.confirmLeave())) return; let doc: OptionsDoc; + let groups: GroupsDoc; try { - doc = await fetchStructureOptions(); + [doc, groups] = await Promise.all([fetchStructureOptions(), fetchStructureOptionGroups()]); } catch (error) { return showToast(to("Load_Failed", why(error)), "error"); } - draw(doc); + draw(doc, groups); } - /** 메인 칸 화면 — `doc.표시` 를 고칠 사본(저장 전까지 서버에 안 감)으로 쥠 */ - function draw(doc: OptionsDoc): void { + /** 메인 칸 화면 — `doc.표시` · `groups.그룹` 을 고칠 사본(저장 전까지 서버에 안 감)으로 쥠 */ + function draw(doc: OptionsDoc, groupsDoc: GroupsDoc): void { let version = doc.판; + let groupVersion = groupsDoc.판; const edit = opt.isAdmin; /** 숨긴 옵션 — 종류 → 키 집합 */ const hidden = new Map>(); @@ -73,13 +81,31 @@ export function buildOptionsSection(opt: OptionsSectionOptions): OptionsSection hidden.clear(); for (const [id, keys] of Object.entries(src)) hidden.set(id, new Set(Object.keys(keys))); }; + /** 그룹 — 종류 → 그룹[](고칠 사본) */ + const groups = new Map(); + 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.표시); - let kinds = 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[] = []; @@ -95,11 +121,13 @@ export function buildOptionsSection(opt: OptionsSectionOptions): OptionsSection ); } 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")]), ], @@ -114,45 +142,66 @@ export function buildOptionsSection(opt: OptionsSectionOptions): OptionsSection 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(); + 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 }), - ...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(); - if (box.checked) set.delete(o.key); - else set.add(o.key); - hidden.set(kind.type_id, set); - setDirty(true); - 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("label", { - className: "m02-opts__option", - children: [ - box, - el("span", { text: o.label }), - el("small", { className: "m02-opts__key", text: o.key }), - ...tags, - ], - }); - }), + 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") })]), ); }; @@ -161,38 +210,64 @@ export function buildOptionsSection(opt: OptionsSectionOptions): OptionsSection all.addEventListener("click", () => { if (![...hidden.values()].some((s) => s.size)) return; hidden.clear(); - setDirty(true); + shownDirty = true; + sync(); paintKinds(); paintOptions(); }); + + /** 다시 받기 — 낡은 판으로 막혔을 때 둘 다 새로 */ + const reload = async (): Promise => { + 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 () => { - const 표시: OptionsDoc["표시"] = {}; - for (const [id, keys] of hidden) - if (keys.size) 표시[id] = Object.fromEntries([...keys].map((k) => [k, false as const])); + if (groupDirty && !namesOk(groups)) return showToast(to("NameBad"), "error"); save.disabled = true; // 연달아 눌러도 한 번만 try { - const done = await saveStructureOptions(version, 표시); - version = done.판; - load(done.표시); - setDirty(false); + 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"); - try { - const fresh = await fetchStructureOptions(); - version = fresh.판; - kinds = fresh.종류; - load(fresh.표시); - setDirty(false); - current = kinds.find((k) => k.type_id === current?.type_id) ?? null; - paintKinds(); - paintOptions(); - } catch (again) { - showToast(to("Load_Failed", why(again)), "error"); - } + await reload(); } else showToast(to("Failed", why(error)), "error"); } finally { save.disabled = false; @@ -211,6 +286,7 @@ export function buildOptionsSection(opt: OptionsSectionOptions): OptionsSection ], }); const setDirty = opt.show(to("Screen"), node); + const sync = (): void => setDirty(shownDirty || groupDirty); paintKinds(); paintOptions(); opt.onOpen(); diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Router.py b/M02_MasterTemplete/StructOptions/M02_SO_Router.py index 56d64ec41..d417fc2e9 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Router.py +++ b/M02_MasterTemplete/StructOptions/M02_SO_Router.py @@ -1,4 +1,4 @@ -"""M02 구조물 옵션 보이기 길 — 정본 `M02_SO_Options.py`. +"""M02 구조물 옵션 보이기 · 그룹 길 — 정본 `M02_SO_Options.py` · `M02_SO_Groups.py`. `router` = 고쳐 씀 · 관리 읽기(`main.py` 가 `system_admin_only` 로 붙임) · `read_router` = 읽기만(로그인 누구나 · `main.py` 가 `protected` 로 붙임). @@ -14,6 +14,7 @@ from pydantic import BaseModel from common_util.common_util_auth import verify_session from M02_MasterTemplete import M02_Layer_Access as access from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete.StructOptions import M02_SO_Groups as groups from M02_MasterTemplete.StructOptions import M02_SO_Options as options router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete"]) @@ -25,6 +26,11 @@ class OptionsBody(BaseModel): 표시: dict[str, Any] # {종류: {옵션 키: 참 · 거짓}} — 거짓만 남김 +class GroupsBody(BaseModel): + 판: str = "" # 그룹 표 판 — 어긋나면 409 + 그룹: dict[str, Any] # {종류: [{id, 이름, 옵션[]}]} + + def _call(fn, *args): with store.at(store.FOLDER): try: @@ -45,6 +51,18 @@ def put_structure_options(body: OptionsBody) -> dict: return _call(options.save, body.표시, body.판) +@router.get("/structure-option-groups") +def get_structure_option_groups() -> dict: + """`{판, 그룹, 종류}` — 그룹 = `{type_id: [{id, 이름, 옵션}]}` · 종류 = 레지스트리 전부.""" + return _call(groups.listing) + + +@router.put("/structure-option-groups") +def put_structure_option_groups(body: GroupsBody) -> dict: + """고쳐 씀(낡은 판 409 · 없는 종류 · 키 · 겹침 422) — 돌려주는 것 = 새 `{판, 그룹, 종류}`.""" + return _call(groups.save, body.그룹, body.판) + + @read_router.get("/layers/system/structure-options") def get_structure_options_read(session=Depends(verify_session)) -> dict: """구조물 옵션 보이기 읽기 — 로그인 누구나(마스터 한 벌 · 고쳐 씀은 시스템 관리자만).""" diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Style.css b/M02_MasterTemplete/StructOptions/M02_SO_Style.css index 9a508036f..2670d6cfe 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Style.css +++ b/M02_MasterTemplete/StructOptions/M02_SO_Style.css @@ -61,15 +61,53 @@ background: var(--color-mist-violet); } +/* 옵션 줄 = 왼쪽 보이기 체크 · 라벨 · 키 · 표시 / 오른쪽 들어갈 자리(본체 · 그룹) */ .m02-opts__option { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-8); + padding: var(--spacing-4) 0; + border-bottom: 1px solid var(--color-border); +} + +.m02-opts__check { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--spacing-8); - padding: var(--spacing-4) 0; + min-width: 0; cursor: pointer; } +/* 옵션 그룹(PLAN 46-2) — 그룹 줄 = 이름 · 옵션 수 · 위로 · 아래로 · 지우기 */ +.m02-sog { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + padding: var(--spacing-8) var(--spacing-12); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-surface-raised); +} + +.m02-sog__row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8); +} + +.m02-sog__name { + flex: 1 1 12rem; + min-width: 0; +} + +.m02-sog__place { + flex: none; + width: 11rem; +} + .m02-opts__key { color: var(--color-text-muted); font-size: 11px; diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Text.ts b/M02_MasterTemplete/StructOptions/M02_SO_Text.ts index aaccee04a..9b4409b41 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Text.ts +++ b/M02_MasterTemplete/StructOptions/M02_SO_Text.ts @@ -1,18 +1,49 @@ /* ============================================================================= * M02_SO_Text.ts - * 좌측 「구조물 옵션 보이기」 줄 · 상세 화면 글자 — [한국어, 영어] + * 좌측 「구조물 옵션 보이기」 줄 · 상세 화면(보이기 · 그룹) 글자 — [한국어, 영어] * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; const TEXT = { Title: ["구조물 옵션 보이기", "Structure options shown"], - Row: ["종류마다 보일 옵션 고르기", "Choose options per type"], - Screen: ["구조물 옵션 보이기", "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", ], + GroupHead: ["그룹", "Groups"], + GroupNote: [ + "그룹 하나 = 프로젝트 B05 · B06 폼의 보조 컨테이너 하나(제목 = 그룹 이름) · 그 안에서 구조물 조합 항목을 따로 지정 · 옵션이 없어도 연결됨 · 그룹에 안 넣은 옵션은 본체", + "One group = one sub-container in the B05 · B06 form (title = group name) with its own structure combo · works without options · ungrouped options stay in the main body", + ], + GroupAdd: ["그룹 추가", "Add group"], + GroupName: ["그룹 이름", "Group name"], + GroupNew: ["새 그룹 {value}", "New group {value}"], + GroupUp: ["위로", "Up"], + GroupDown: ["아래로", "Down"], + GroupDel: ["지우기", "Delete"], + GroupDelAsk: [ + "그룹 「{value}」 을 지움 — 그 옵션은 본체로 돌아감 · 프로젝트에 이미 지정한 조합은 값만 남음", + "Delete group “{value}” — its options return to the main body; project links keep their values", + ], + GroupCount: ["옵션 {value}", "{value} options"], + GroupNone: [ + "그룹 없음 — 모든 옵션이 본체 · 집계 항목 하나", + "No groups — all options in the main body", + ], + GroupTag: ["그룹 {value}", "{value} groups"], + Main: ["본체", "Main body"], + OptionHead: ["옵션 — 보이기 · 들어갈 자리", "Options — shown · container"], + 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"], diff --git a/resources/master_template/구조물옵션그룹.json b/resources/master_template/구조물옵션그룹.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/resources/master_template/구조물옵션그룹.json @@ -0,0 +1 @@ +{} diff --git a/resources/tester/test_m02_structure_option_groups.py b/resources/tester/test_m02_structure_option_groups.py new file mode 100644 index 000000000..660fc9c6c --- /dev/null +++ b/resources/tester/test_m02_structure_option_groups.py @@ -0,0 +1,113 @@ +"""M02 구조물 옵션 그룹(`/api/m02/structure-option-groups` · `구조물옵션그룹.json` · PLAN 46-2). + +못박는 것 + - 시드 = `{}` · 파일이 없으면 그룹 없음 · `/structure-types` 종류마다 `groups: []`. + - 저장한 그룹이 `/structure-types` `groups` 에 그대로(차례 · 이름 · 옵션) · 빈 그룹도 나감. + - 옵션 없는 종류도 그룹을 둘 수 있음(관리 목록 `종류` 는 레지스트리 전부). + - 없는 종류 · 키 · id 틀림 · 겹침 · 이름 빔 · 겹침 · 옵션 두 그룹은 422 · 낡은 판 409 · + 거절되면 안 씀. + - 파일이 깨져도 폼 응답은 그룹 없음(죽지 않음) · 관리 화면은 500. + - 좌측 「구조물 옵션 보이기」 는 시스템 관리자만(화면 코드). +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from B05_Profile.B05_Profile_Structures_Router import router as b05_router +from B05_Profile.B05_Profile_Structures_Schema import load_structure_types +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete.StructOptions import M02_SO_Groups as groups +from M02_MasterTemplete.StructOptions.M02_SO_Router import router as m02_router + +ROOT = Path(__file__).resolve().parents[2] +URL = "/api/m02/structure-option-groups" +TYPES = "/api/projects/structure-types" + + +@pytest.fixture +def client(tmp_path: Path, monkeypatch) -> TestClient: + monkeypatch.setattr(store, "FOLDER", tmp_path) + app = FastAPI() + app.include_router(m02_router) + app.include_router(b05_router) + return TestClient(app) + + +def _two_options() -> tuple[str, str, str]: + t = next(t for t in load_structure_types() if len(t.options) >= 2) + return t.type_id, t.options[0].key, t.options[1].key + + +def _groups(client: TestClient) -> dict[str, list[dict]]: + return {t["type_id"]: t["groups"] for t in client.get(TYPES).json()["types"]} + + +def test_시드는_빈_표_없으면_그룹_없음(client) -> None: + seed = ROOT / "resources/master_template/구조물옵션그룹.json" + assert json.loads(seed.read_text("utf-8")) == {} + got = client.get(URL).json() + assert got["판"] == "" and got["그룹"] == {} + assert len(got["종류"]) == len(load_structure_types()) # 옵션 없는 종류도 + assert all(v == [] for v in _groups(client).values()) + + +def test_저장한_그룹이_폼_응답에_차례대로_빈_그룹도(client) -> None: + tid, a, b = _two_options() + empty = next(t.type_id for t in load_structure_types() if not t.options) + body = { + tid: [ + {"id": "g1", "이름": "유입구", "옵션": [b]}, + {"id": "g2", "이름": " 빈 그룹 ", "옵션": []}, + ], + empty: [{"id": "gx", "이름": "부속", "옵션": []}], + } + res = client.put(URL, json={"판": "", "그룹": body}) + assert res.status_code == 200, res.text + assert res.json()["그룹"][tid][1]["이름"] == "빈 그룹" # 앞뒤 빈칸 지움 + seen = _groups(client) + assert seen[tid] == [ + {"id": "g1", "name": "유입구", "options": [b]}, + {"id": "g2", "name": "빈 그룹", "options": []}, + ] + assert seen[empty] == [{"id": "gx", "name": "부속", "options": []}] + assert a not in seen[tid][0]["options"] # 안 넣은 옵션 = 본체 + + +def test_틀린_그룹은_422_낡은_판은_409(client) -> None: + tid, a, b = _two_options() + ok = {"id": "g1", "이름": "가", "옵션": [a]} + for bad in ( + {"없는종류": [ok]}, + {tid: [{**ok, "옵션": ["없는키"]}]}, + {tid: [{**ok, "id": "G-1"}]}, + {tid: [ok, {**ok, "이름": "나", "옵션": []}]}, # id 겹침 + {tid: [{**ok, "이름": " "}]}, + {tid: [ok, {"id": "g2", "이름": "가", "옵션": []}]}, # 이름 겹침 + {tid: [ok, {"id": "g2", "이름": "나", "옵션": [a]}]}, # 옵션 두 그룹 + {tid: {"g1": ok}}, + ): + assert client.put(URL, json={"판": "", "그룹": bad}).status_code == 422, bad + assert not (store.FOLDER / groups.FILE).exists() # 거절되면 안 씀 + v1 = client.put(URL, json={"판": "", "그룹": {tid: [ok]}}).json()["판"] + assert client.put(URL, json={"판": "낡음", "그룹": {}}).status_code == 409 + res = client.put(URL, json={"판": v1, "그룹": {}}) + assert res.status_code == 200 and res.json()["그룹"] == {} + + +def test_파일이_깨져도_폼_응답은_그룹_없음(client) -> None: + (store.FOLDER / groups.FILE).write_text("{깨짐", encoding="utf-8") + assert all(v == [] for v in _groups(client).values()) + assert client.get(URL).status_code == 500 + + +def test_좌측_옵션_보이기는_시스템_관리자만() -> None: + 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 diff --git a/resources/tester/test_m02_structure_options.py b/resources/tester/test_m02_structure_options.py index b5fc71efe..670c84f23 100644 --- a/resources/tester/test_m02_structure_options.py +++ b/resources/tester/test_m02_structure_options.py @@ -117,9 +117,7 @@ def test_좌측_줄은_구조물_집계_양식_아래_한_줄_같은_한_곳에 side = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts").read_text("utf-8") assert "section(options.title, options.body)" in side # 차례 = 구조물 조합 → 옵션 → 도면 양식(시스템 층에만 · 해시 진입 `reveal` 이 이름으로 · 27-1d) - assert side.index("옵션: section(options.title") < side.index( - '도면: section(L("M02_KindDrawing")' - ) + assert side.index("section(options.title") < side.index('도면: section(L("M02_KindDrawing")') # 차례 = 구조물 조합(일위대가는 그 하위 · 39-2) → 옵션 → 도면 양식 → 실무문서 order = side.split('className: "m02-side__lists ui-sidebar-panel",')[1].split("});")[0] assert (