Merge remote-tracking branch 'origin/main_desktop_1' into main_laptop_1

This commit is contained in:
2026-10-05 13:30:37 +09:00
12 changed files with 680 additions and 75 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)
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<HTMLElement>("button")?.click();
};
+28 -1
View File
@@ -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<OptionsDoc> =>
/** 고쳐 씀 — 숨긴 것만 보냄 · 낡은 판이면 StaleError · 돌려받는 것은 새 문서 */
export const saveStructureOptions = (판: string, 표시: OptionsDoc["표시"]): Promise<OptionsDoc> =>
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<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({ 판, 그룹 }) });
@@ -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()
@@ -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<string, OptionGroupDef[]>): boolean {
for (const list of groups.values()) {
const names = list.map((g) => g.이름.trim());
if (names.some((n) => !n || n.length > NAME_MAX)) return false;
if (new Set(names).size !== names.length) return false;
}
return true;
}
export interface GroupBlockOptions {
/** 고른 종류의 그룹(고칠 사본 — 이 부품이 직접 고침) */
list: OptionGroupDef[];
edit: boolean;
/** 고쳤음 — 화면이 고침 표시 · 다시 그리기 */
changed: () => void;
}
/** 그룹 목록 칸 — 제목 · 안내 · 그룹 줄 · [그룹 추가] */
export function buildGroupBlock(opt: GroupBlockOptions): HTMLElement {
const { list } = opt;
const rows = list.map((group, index) => {
const name = el("input", {
className: "ui-input ui-input--sm m02-sog__name",
attrs: { type: "text", maxlength: String(NAME_MAX), "data-sog-name": group.id },
}) as HTMLInputElement;
name.value = group.이름;
name.placeholder = to("GroupName");
name.disabled = !opt.edit;
// 글자마다 사본만 고침(다시 그리면 초점이 끊김) · 칸을 떠날 때 다시 그림(선택지 이름)
name.addEventListener("input", () => {
group.이름 = name.value;
name.classList.toggle("is-invalid", !name.value.trim());
});
name.addEventListener("change", () => opt.changed());
const step = (delta: number): void => {
const at = index + delta;
if (at < 0 || at >= list.length) return;
[list[index], list[at]] = [list[at], list[index]];
opt.changed();
};
const up = createButton({ label: to("GroupUp"), variant: "ghost", size: "sm" });
const down = createButton({ label: to("GroupDown"), variant: "ghost", size: "sm" });
const del = createButton({ label: to("GroupDel"), variant: "danger", size: "sm" });
up.disabled = !opt.edit || index === 0;
down.disabled = !opt.edit || index === list.length - 1;
del.disabled = !opt.edit;
up.addEventListener("click", () => step(-1));
down.addEventListener("click", () => step(1));
del.addEventListener("click", async () => {
if (!(await showConfirmDialog(to("GroupDelAsk", group.이름), to("GroupDel")))) return;
list.splice(list.indexOf(group), 1);
opt.changed();
});
return el("div", {
className: "m02-sog__row",
attrs: { "data-sog-group": group.id },
children: [
name,
el("small", {
className: "m02-opts__key",
text: to("GroupCount", String(group.옵션.length)),
}),
...(opt.edit ? [up, down, del] : []),
],
});
});
const add = createButton({ label: to("GroupAdd"), variant: "ghost", size: "sm" });
add.setAttribute("data-sog-add", "");
add.addEventListener("click", () => {
let n = list.length + 1;
while (list.some((g) => g.이름 === to("GroupNew", String(n)))) n += 1;
list.push({ id: newGroupId(list), 이름: to("GroupNew", String(n)), 옵션: [] });
opt.changed();
});
return el("div", {
className: "m02-sog",
children: [
el("h4", { className: "m02-opts__group", text: to("GroupHead") }),
el("p", { className: "m02-side__empty", text: to("GroupNote") }),
...(rows.length ? rows : [el("p", { className: "m02-side__empty", text: to("GroupNone") })]),
...(opt.edit ? [el("div", { className: "m02-side__row", children: [add] })] : []),
],
});
}
/** 옵션 줄의 「들어갈 자리」 고르기 — 본체 · 그룹 이름들 */
export function buildPlaceSelect(
list: OptionGroupDef[],
key: string,
edit: boolean,
changed: () => void,
): HTMLSelectElement {
const select = el("select", {
className: "ui-input ui-input--sm m02-sog__place",
attrs: { "data-sog-place": key },
}) as HTMLSelectElement;
select.append(new Option(to("Main"), ""), ...list.map((g) => new Option(g.이름 || g.id, g.id)));
select.value = groupOf(list, key) ?? "";
select.disabled = !edit || !list.length;
select.addEventListener("change", () => {
moveOption(list, key, select.value || null);
changed();
});
return select;
}
@@ -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()
]
@@ -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<void> {
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<string, Set<string>>();
@@ -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<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.표시);
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<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 }),
...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);
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<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 () => {
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();
@@ -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:
"""구조물 옵션 보이기 읽기 — 로그인 누구나(마스터 한 벌 · 고쳐 씀은 시스템 관리자만)."""
@@ -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;
@@ -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"],
@@ -0,0 +1 @@
{}
@@ -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
@@ -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 (