refactor(M02): 46-1 구조물 옵션 보이기 코드를 M02_MasterTemplete/StructOptions/ 한 곳으로

- 서버 정본 `M02_SO_Options.py` · 길 `M02_SO_Router.py`(고쳐 씀 = 시스템 관리자 · 읽기 = 로그인)
- 화면 `M02_SO_Options.ts` · 글자 `M02_SO_Text.ts` · API `M02_SO_Api.ts` · 스타일 `M02_SO_Style.css`
- 길 주소 · 동작 그대로 · B05 · 시험은 경로만 고침

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPcuokNm1ZR2kqgGGDfaJA
This commit is contained in:
2026-10-05 13:14:09 +09:00
co-authored by Claude Opus 5.5
parent acf7570da4
commit bd3c48c525
17 changed files with 182 additions and 142 deletions
@@ -0,0 +1,29 @@
/* =============================================================================
* M02_SO_Api.ts
* 구조물 옵션 보이기 서버 호출 — 서버 = `M02_SO_Router.py`(마스터 템플릿 `구조물옵션표시.json`)
* ========================================================================== */
import { call } from "../M02_MasterTemplete_Api_Fetch";
export interface OptionKind {
type_id: string;
group: string;
name: string;
/** 거짓이면 B05 선택지에 안 나오는 종류 */
enabled: boolean;
options: { key: string; label: string; phase: "b05" | "detail"; required: boolean }[];
}
/** 판 · 표시(숨긴 것만 `{종류: {옵션: false}}`) · 종류(옵션이 있는 것과 그 옵션) */
export interface OptionsDoc {
판: string;
표시: Record<string, Record<string, false>>;
종류: OptionKind[];
}
export const fetchStructureOptions = (): Promise<OptionsDoc> =>
call("/layers/system/structure-options"); // 읽기 = 로그인 누구나 · 고쳐 씀은 시스템 관리자
/** 고쳐 씀 — 숨긴 것만 보냄 · 낡은 판이면 StaleError · 돌려받는 것은 새 문서 */
export const saveStructureOptions = (판: string, 표시: OptionsDoc["표시"]): Promise<OptionsDoc> =>
call("/structure-options", { method: "PUT", body: JSON.stringify({ 판, 표시 }) });
@@ -0,0 +1,163 @@
"""M02 구조물 옵션 보이기 — 구조물 종류마다 B05/B06 폼에 보일 옵션을 사용자가 체크.
정본 = `resources/master_template/구조물옵션표시.json` = `{type_id: {option_key: false}}`
— 없는 종류 · 키는 보임(파일이 없어도 모두 보임).
저장은 **숨김(false)만** 남김 — 「모두 보임」 = `{}`.
옵션 정의 · 옛 값은 그대로 — 폼이 그리는 것만 갈림(`GET /api/projects/structure-types` `visible`).
판 = 파일 sha256 앞 16자(`store.version_of`) · 낡은 판으로 쓰면 409.
"""
from __future__ import annotations
import json
import logging
from typing import Any
from pydantic import Field
from B05_Profile.B05_Profile_Structures_Schema import (
StructureOptionField,
StructureType,
StructureTypesResponse,
load_structure_types,
registry_schema_version,
)
from common_util.common_util_json import atomic_write_json
from M02_MasterTemplete import M02_MasterTemplete_Store as store
FILE = "구조물옵션표시.json"
logger = logging.getLogger(__name__)
class OptionWithVisible(StructureOptionField):
"""폼 옵션 한 칸 + 보임 여부(서버가 채움)."""
visible: bool = True
class TypeWithVisible(StructureType):
options: list[OptionWithVisible] = Field(default_factory=list)
class TypesWithVisible(StructureTypesResponse):
"""`GET /structure-types` 응답 — 옵션마다 `visible` 칸."""
types: list[TypeWithVisible]
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 _shown(doc: dict[str, Any], type_id: str, key: str) -> bool:
row = doc.get(type_id)
return not (isinstance(row, dict) and row.get(key) is False)
def hidden_keys() -> dict[str, set[str]]:
"""숨긴 옵션(종류 → 키 집합) — 저장 검증이 숨긴 옵션을 필수로 안 침 · 깨진 표는 모두 보임."""
try:
doc = _load()[0]
except store.StoreError as e:
logger.warning("구조물 옵션 표시 표를 못 읽어 모두 보임으로 봄 — %s", e.detail)
return {}
return {
type_id: {key for key, shown in row.items() if shown is False}
for type_id, row in doc.items()
if isinstance(row, dict)
}
def with_visible() -> TypesWithVisible:
"""레지스트리 정본 + 보임 여부 — 파일이 깨졌으면 모두 보임(폼이 죽지 않게 · 로그만)."""
try:
doc = _load()[0]
except store.StoreError as e:
logger.warning("구조물 옵션 표시 표를 못 읽어 모두 보임으로 냄 — %s", e.detail)
doc = {}
types = [
{
**t.model_dump(),
"options": [
{**o.model_dump(), "visible": _shown(doc, t.type_id, o.key)} for o in t.options
],
}
for t in load_structure_types()
]
return TypesWithVisible.model_validate(
{"schema_version": registry_schema_version(), "types": types}
)
def listing() -> dict[str, Any]:
"""`{판, 표시, 종류}` — 표시 = 숨긴 것만 · 종류 = 옵션이 있는 종류와 그 옵션."""
doc, version = _load()
hidden: dict[str, dict[str, bool]] = {}
kinds = []
for t in load_structure_types():
if not t.options:
continue
off = {o.key: False for o in t.options if not _shown(doc, t.type_id, o.key)}
if off:
hidden[t.type_id] = off
kinds.append(
{
"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
],
}
)
return {"판": version, "표시": hidden, "종류": kinds}
def _clean(shown: Any) -> dict[str, dict[str, bool]]:
"""받은 표 검사 → 숨김만 남김. 없는 종류 · 키 · 참거짓 아닌 값은 422."""
if not isinstance(shown, 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, dict[str, bool]] = {}
for type_id, row in shown.items():
if type_id not in keys:
errors.append(f"없는 구조물 종류 「{type_id}」")
elif not isinstance(row, dict):
errors.append(f"「{type_id}」 는 옵션별 묶음이어야 함")
else:
for key, value in row.items():
if key not in keys[type_id]:
errors.append(f"「{type_id}」 에 없는 옵션 「{key}」")
elif not isinstance(value, bool):
errors.append(f"「{type_id}.{key}」 는 참 · 거짓이어야 함")
elif not value:
out.setdefault(type_id, {})[key] = False
if errors:
raise store.StoreError(422, {"errors": errors})
return out
def save(shown: Any, version: str) -> dict[str, Any]:
"""고쳐 씀 — 판이 같을 때만. 돌려주는 것 = 새 `listing()`."""
fixed = _clean(shown)
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,221 @@
/* =============================================================================
* M02_SO_Options.ts
* 좌측 「구조물 옵션 보이기」 줄(구조물 집계 양식 아래 별도) — 누르면 메인 칸에
* 종류 목록(군별) → 고른 종류의 옵션 체크 목록(라벨 · 키 작은 글 · B06 전용 · 필수 표시) ·
* [저장] · [모두 보임]. 체크 안 한 옵션은 B05 · B06 구조물 폼에서 숨음(정의 · 값은 그대로).
* 서버 = `M02_SO_Options.py`(마스터 템플릿 `구조물옵션표시.json` · 숨긴 것만 저장).
* ========================================================================== */
import { createButton, createTag, el, showToast } from "@ui/ui_template_elements";
import { StaleError } from "../M02_MasterTemplete_Api_Fetch";
import {
fetchStructureOptions,
saveStructureOptions,
type OptionKind,
type OptionsDoc,
} from "./M02_SO_Api";
import "./M02_SO_Style.css";
import { to } from "./M02_SO_Text";
export interface OptionsSectionOptions {
isAdmin: boolean;
/** 저장 안 한 고침을 버릴지 확인 — 취소면 false */
confirmLeave: () => Promise<boolean>;
/** 메인 칸에 화면을 띄움 — 고침 표시 손잡이를 돌려받음 */
show: (label: string, node: HTMLElement) => (dirty: boolean) => void;
/** 화면을 열었음 — 좌측 다른 고름 표시를 지움 */
onOpen: () => void;
}
export interface OptionsSection {
title: string;
body: HTMLElement;
/** 다른 화면을 열었음 — 고름 표시만 지움 */
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",
text: to("Row"),
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;
const mark = (on: boolean): void => {
opened = on;
row.classList.toggle("is-active", on);
};
async function open(): Promise<void> {
if (opened || !(await opt.confirmLeave())) return;
let doc: OptionsDoc;
try {
doc = await fetchStructureOptions();
} catch (error) {
return showToast(to("Load_Failed", why(error)), "error");
}
draw(doc);
}
/** 메인 칸 화면 — `doc.표시` 를 고칠 사본(저장 전까지 서버에 안 감)으로 쥠 */
function draw(doc: OptionsDoc): void {
let version = doc.판;
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)));
};
load(doc.표시);
let kinds = doc.종류;
let current: OptionKind | null = null;
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 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 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 }),
...(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 paintOptions = (): void => {
if (!current) {
optionList.replaceChildren(el("p", { className: "m02-side__empty", text: to("Pick") }));
return;
}
const kind = current;
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,
],
});
}),
);
};
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();
setDirty(true);
paintKinds();
paintOptions();
});
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]));
save.disabled = true; // 연달아 눌러도 한 번만
try {
const done = await saveStructureOptions(version, 표시);
version = done.판;
load(done.표시);
setDirty(false);
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");
}
} 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);
paintKinds();
paintOptions();
opt.onOpen();
mark(true);
}
return { title: to("Title"), body, clear: () => mark(false) };
}
@@ -0,0 +1,52 @@
"""M02 구조물 옵션 보이기 길 — 정본 `M02_SO_Options.py`.
`router` = 고쳐 씀 · 관리 읽기(`main.py` 가 `system_admin_only` 로 붙임) ·
`read_router` = 읽기만(로그인 누구나 · `main.py` 가 `protected` 로 붙임).
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
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_Options as options
router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete"])
read_router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete"])
class OptionsBody(BaseModel):
판: str = "" # 옵션 표시 표 판 — 어긋나면 409
표시: dict[str, Any] # {종류: {옵션 키: 참 · 거짓}} — 거짓만 남김
def _call(fn, *args):
with store.at(store.FOLDER):
try:
return fn(*args)
except store.StoreError as e:
raise HTTPException(status_code=e.status, detail=e.detail) from e
@router.get("/structure-options")
def get_structure_options() -> dict:
"""`{판, 표시, 종류}` — 표시 = 숨긴 것만 · 종류 = 옵션 있는 종류와 그 옵션."""
return _call(options.listing)
@router.put("/structure-options")
def put_structure_options(body: OptionsBody) -> dict:
"""고쳐 씀(낡은 판이면 409 · 없는 종류 · 키는 422) — 돌려주는 것 = 새 `{판, 표시, 종류}`."""
return _call(options.save, body.표시, body.판)
@read_router.get("/layers/system/structure-options")
def get_structure_options_read(session=Depends(verify_session)) -> dict:
"""구조물 옵션 보이기 읽기 — 로그인 누구나(마스터 한 벌 · 고쳐 씀은 시스템 관리자만)."""
access.require(session, "system", None)
return _call(options.listing)
@@ -0,0 +1,76 @@
/* 좌측 「구조물 옵션 보이기」 화면(메인 칸) — 왼쪽 종류(군별) · 오른쪽 옵션 체크 */
.m02-opts {
display: flex;
flex-direction: column;
gap: var(--spacing-12);
max-width: 64rem;
font-size: var(--text-body-sm);
}
/* 종류 제목 */
.m02-options__head {
margin: 0;
color: var(--color-text);
font-size: var(--text-body);
}
.m02-opts__panes {
display: grid;
grid-template-columns: minmax(14rem, 20rem) 1fr;
gap: var(--spacing-16);
align-items: start;
}
.m02-opts__kinds,
.m02-opts__list {
max-height: calc(100vh - 20rem);
overflow-y: auto;
display: flex;
flex-direction: column;
gap: var(--spacing-4);
min-width: 0;
}
.m02-opts__group {
margin: var(--spacing-8) 0 0;
color: var(--color-text-muted);
font-size: var(--text-body-sm);
}
.m02-opts__kind {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-4);
padding: var(--spacing-4) var(--spacing-8);
border: 1px solid transparent;
border-radius: var(--radius-sm);
background: none;
color: var(--color-text);
font: inherit;
text-align: left;
cursor: pointer;
}
.m02-opts__kind:hover {
background: var(--color-mist-violet);
}
.m02-opts__kind.is-active {
border-color: var(--color-accent);
background: var(--color-mist-violet);
}
.m02-opts__option {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: var(--spacing-8);
padding: var(--spacing-4) 0;
cursor: pointer;
}
.m02-opts__key {
color: var(--color-text-muted);
font-size: 11px;
}
@@ -0,0 +1,43 @@
/* =============================================================================
* 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"],
Note: [
"체크한 옵션만 B05 · B06 구조물 폼에 보임 — 옵션 정의 · 이미 넣은 값은 그대로 · 처음은 모두 보임",
"Only checked options appear in the B05 · B06 forms — definitions and stored values stay",
],
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}"],
} as const satisfies Record<string, readonly [string, string]>;
export type OptionsTextKey = keyof typeof TEXT;
export function to(key: OptionsTextKey, value = ""): string {
const entry = TEXT[key];
return (entry[currentLanguageIndex as 0 | 1] ?? entry[0]).replace("{value}", value);
}