From 8ca2f1cd6ee2b5e180e6b28d1a68e14d337a50b1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 16:15:10 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat(M02):=2048-2=20=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=EB=AC=BC=20=EC=98=B5=EC=85=98=20=EC=A0=95=EC=9D=98=20=ED=8E=B8?= =?UTF-8?q?=EC=A7=91=20=ED=99=94=EB=A9=B4=20=E2=80=94=20=EA=B5=B0=20=C2=B7?= =?UTF-8?q?=20=EC=A2=85=EB=A5=98=20=C2=B7=20=EC=98=B5=EC=85=98=20=C2=B7=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=EC=A7=80=20=C2=B7=20=EB=B0=B0=EC=B9=98=20?= =?UTF-8?q?=EA=B2=A9=EC=9E=90=20=C2=B7=20=EA=B7=B8=EB=A3=B9=20=ED=98=95?= =?UTF-8?q?=EC=83=81=20=C2=B7=20=EA=B7=9C=EC=B9=99=20=C2=B7=20=ED=8C=90=20?= =?UTF-8?q?=EC=9D=B4=EB=A0=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 옛 「옵션 보이기 · 그룹」 화면을 정의 편집 화면으로 넓힘(시스템 관리자 고쳐 씀 · 다른 역할 읽기만) - 자료 손질 = M02_SO_Def_Model.ts(정의 틀 48-1 꼴) · 시험 test_m02_so_def_model.py Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- .../M02_MasterTemplete_UI_Side.ts | 4 +- .../StructOptions/M02_SO_Api.ts | 63 +--- .../StructOptions/M02_SO_Def.ts | 339 ++++++++++++++++++ .../StructOptions/M02_SO_Def_Layout.ts | 120 +++++++ .../StructOptions/M02_SO_Def_Model.ts | 268 ++++++++++++++ .../StructOptions/M02_SO_Def_Options.ts | 231 ++++++++++++ .../StructOptions/M02_SO_Def_Rules.ts | 234 ++++++++++++ .../StructOptions/M02_SO_Def_Ui.ts | 121 +++++++ .../StructOptions/M02_SO_Groups.ts | 60 ++-- .../StructOptions/M02_SO_Options.ts | 261 +------------- .../StructOptions/M02_SO_Style.css | 197 ++++++++++ .../StructOptions/M02_SO_Text.ts | 104 ++++-- resources/tester/helper_m02_so_def_model.cjs | 112 ++++++ resources/tester/test_m02_so_def_model.py | 87 +++++ 14 files changed, 1857 insertions(+), 344 deletions(-) create mode 100644 M02_MasterTemplete/StructOptions/M02_SO_Def.ts create mode 100644 M02_MasterTemplete/StructOptions/M02_SO_Def_Layout.ts create mode 100644 M02_MasterTemplete/StructOptions/M02_SO_Def_Model.ts create mode 100644 M02_MasterTemplete/StructOptions/M02_SO_Def_Options.ts create mode 100644 M02_MasterTemplete/StructOptions/M02_SO_Def_Rules.ts create mode 100644 M02_MasterTemplete/StructOptions/M02_SO_Def_Ui.ts create mode 100644 resources/tester/helper_m02_so_def_model.cjs create mode 100644 resources/tester/test_m02_so_def_model.py diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts index 5fc86b60a..12561432e 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) + // 구조물 옵션 정의 · 도면 양식은 시스템 층(마스터 템플릿)에만 · 정의 고쳐 씀은 시스템 관리자만 · 다른 역할은 읽기만(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; diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Api.ts b/M02_MasterTemplete/StructOptions/M02_SO_Api.ts index a8bafd1fa..a91a2578f 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Api.ts +++ b/M02_MasterTemplete/StructOptions/M02_SO_Api.ts @@ -1,56 +1,29 @@ /* ============================================================================= * M02_SO_Api.ts - * 구조물 옵션 보이기 · 그룹 서버 호출 — 서버 = `M02_SO_Router.py` - * (마스터 템플릿 `구조물옵션표시.json` · `구조물옵션그룹.json`) + * 구조물 정의 서버 호출 — 서버 = `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>; - 종류: OptionKind[]; -} +/** 정의 한 벌 — 읽기 = 로그인 누구나 */ +export const fetchStructureDef = (): Promise => call("/structure-definition"); -export const fetchStructureOptions = (): Promise => - call("/layers/system/structure-options"); // 읽기 = 로그인 누구나 · 고쳐 씀은 시스템 관리자 +/** 통째로 고쳐 씀(시스템 관리자만) — 읽어 간 판이 지금 판과 다르면 StaleError · 돌려받는 것 = 판 +1 정의 */ +export const saveStructureDef = (판: number, 정의: StructDef): Promise => + call("/structure-definition", { method: "PUT", body: JSON.stringify({ 판, 정의 }) }); -/** 고쳐 씀 — 숨긴 것만 보냄 · 낡은 판이면 StaleError · 돌려받는 것은 새 문서 */ -export const saveStructureOptions = (판: string, 표시: OptionsDoc["표시"]): Promise => - call("/structure-options", { method: "PUT", body: JSON.stringify({ 판, 표시 }) }); +/** 지난 판 목록(`구조물정의_이력/판_N.json`) */ +export const fetchStructureDefHistory = (): Promise => + call("/structure-definition/history"); -/* --- 옵션 그룹(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({ 판, 그룹 }) }); +export const fetchStructureDefAt = (판: number): Promise => + call(`/structure-definition/history/${판}`); diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Def.ts b/M02_MasterTemplete/StructOptions/M02_SO_Def.ts new file mode 100644 index 000000000..26899fdc1 --- /dev/null +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def.ts @@ -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[]) + 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 { + 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(); // 선택지를 펼친 옵션 + 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 => (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 => { + 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(); +} diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Def_Layout.ts b/M02_MasterTemplete/StructOptions/M02_SO_Def_Layout.ts new file mode 100644 index 000000000..d6e4338e0 --- /dev/null +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def_Layout.ts @@ -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): 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): 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), + ], + }), + ], + }); +} diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Def_Model.ts b/M02_MasterTemplete/StructOptions/M02_SO_Def_Model.ts new file mode 100644 index 000000000..d45d9511b --- /dev/null +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def_Model.ts @@ -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>; +} + +/** 만약 상위 옵션 = 값들 중 하나 → 하위 옵션의 선택지 · 보이기 · 기본값(하나 이상) */ +export interface RuleDef { + 종류: string; + 만약: { 옵션: string; 값들: string[] }; + 그러면: { 옵션: string; 선택지?: string[]; 보이기?: boolean; 기본값?: unknown }; +} + +export interface StructDef { + 판: number; + 구조물군: Record; + 동작: Record; + types: TypeDef[]; + 종류: Record; + 표시: Record>; + 그룹: Record; + 배치: Record>; + 규칙: RuleDef[]; + [more: string]: unknown; +} + +export const NAME_MAX = 40; +/** B05 · B06 좌측 구조물 칸 = 두 칸 격자 */ +export const GRID_COLS = 2; + +/** 깊은 사본 — 고칠 사본을 서버 문서와 떼어 놓음 */ +export const clone = (value: T): T => structuredClone(value); + +/** 새 키 — 머리글 + 영문 소문자 · 숫자 6자 · 이미 있는 키와 안 겹침 */ +export function newKey(prefix: string, taken: Iterable): 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(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 = (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(); + 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 = {}, + cols = GRID_COLS, +): Map { + const out = new Map(); + const taken = new Set(); + 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[] { + const owner = new Map(); + const bad = new Set(); + 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; +} diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Def_Options.ts b/M02_MasterTemplete/StructOptions/M02_SO_Def_Options.ts new file mode 100644 index 000000000..10254bba8 --- /dev/null +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def_Options.ts @@ -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; + 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] })] : []), + ], + }); +} diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Def_Rules.ts b/M02_MasterTemplete/StructOptions/M02_SO_Def_Rules.ts new file mode 100644 index 000000000..bca6186e0 --- /dev/null +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def_Rules.ts @@ -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] })] : []), + ], + }); +} diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Def_Ui.ts b/M02_MasterTemplete/StructOptions/M02_SO_Def_Ui.ts new file mode 100644 index 000000000..837c344bd --- /dev/null +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def_Ui.ts @@ -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( + 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 })] : []), +]; diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Groups.ts b/M02_MasterTemplete/StructOptions/M02_SO_Groups.ts index 242dad8b4..410875cf4 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Groups.ts +++ b/M02_MasterTemplete/StructOptions/M02_SO_Groups.ts @@ -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): 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, diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Options.ts b/M02_MasterTemplete/StructOptions/M02_SO_Options.ts index d340d13f6..7ede2af54 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Options.ts +++ b/M02_MasterTemplete/StructOptions/M02_SO_Options.ts @@ -1,25 +1,12 @@ /* ============================================================================= * M02_SO_Options.ts - * 좌측 「구조물 옵션 보이기」 줄(구조물 집계 양식 아래 별도 · 시스템 관리자만) — 누르면 메인 칸에 - * 종류 목록(군별) → 고른 종류의 **그룹**(PLAN 46-2 · `M02_SO_Groups.ts`) + 옵션 줄 - * (보이기 체크 · 라벨 · 키 작은 글 · B06 전용 · 필수 표시 · 들어갈 자리) · [저장] · [모두 보임]. - * 체크 안 한 옵션은 B05 · B06 구조물 폼에서 숨음(정의 · 값은 그대로). - * 서버 = `M02_SO_Options.py`(`구조물옵션표시.json` · 숨긴 것만) · `M02_SO_Groups.py`(`구조물옵션그룹.json`). + * 좌측 「구조물 옵션 정의」 줄(구조물 집계 양식 아래 별도 · 시스템 층) — 누르면 메인 칸에 + * 정의 편집 화면(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 { + 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>(); - 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(); - 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(); - 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 => { - 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) }; } diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Style.css b/M02_MasterTemplete/StructOptions/M02_SO_Style.css index 2670d6cfe..80d3e4cec 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Style.css +++ b/M02_MasterTemplete/StructOptions/M02_SO_Style.css @@ -112,3 +112,200 @@ 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 > .m02-opts__kind { + flex: 1 1 auto; +} + +.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__placename { + flex: 1 1 8rem; + min-width: 0; +} + +.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); +} diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Text.ts b/M02_MasterTemplete/StructOptions/M02_SO_Text.ts index 9b4409b41..90037edd8 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Text.ts +++ b/M02_MasterTemplete/StructOptions/M02_SO_Text.ts @@ -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; export type OptionsTextKey = keyof typeof TEXT; diff --git a/resources/tester/helper_m02_so_def_model.cjs b/resources/tester/helper_m02_so_def_model.cjs new file mode 100644 index 000000000..92969f79c --- /dev/null +++ b/resources/tester/helper_m02_so_def_model.cjs @@ -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)); diff --git a/resources/tester/test_m02_so_def_model.py b/resources/tester/test_m02_so_def_model.py new file mode 100644 index 000000000..6670f0937 --- /dev/null +++ b/resources/tester/test_m02_so_def_model.py @@ -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] From 4b79e7f39cd6ae3a03d827d4d50291434856a1ff Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 16:17:00 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat(M02):=2048-2=20=EC=A7=80=EB=82=9C=20?= =?UTF-8?q?=ED=8C=90=20=EB=AA=A9=EB=A1=9D=20=C2=B7=20=ED=95=9C=20=EB=B2=8C?= =?UTF-8?q?=20=EC=9D=BD=EA=B8=B0=20=EA=B8=B8=20=C2=B7=20=EC=A0=95=EC=9D=98?= =?UTF-8?q?=20=ED=99=94=EB=A9=B4=20=EC=8B=9C=ED=97=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /api/m02/structure-definition/history · /history/{판}(로그인 누구나 · 없는 판 404) - 화면이 만드는 고침 한 벌을 서버가 받음 · 판 +1 · 403 · 422 시험(사본 정의) - 옛 옵션 보이기 · 그룹 시험의 화면 글 검사를 정의 화면 자리로 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- .../StructOptions/M02_SO_Definition.py | 22 ++++ .../StructOptions/M02_SO_Router.py | 14 ++ .../tester/test_m02_structure_def_screen.py | 123 ++++++++++++++++++ .../test_m02_structure_option_groups.py | 10 +- .../tester/test_m02_structure_options.py | 8 +- 5 files changed, 168 insertions(+), 9 deletions(-) create mode 100644 resources/tester/test_m02_structure_def_screen.py diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Definition.py b/M02_MasterTemplete/StructOptions/M02_SO_Definition.py index 640d6942a..0ba02af12 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Definition.py +++ b/M02_MasterTemplete/StructOptions/M02_SO_Definition.py @@ -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] = [] diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Router.py b/M02_MasterTemplete/StructOptions/M02_SO_Router.py index 0dbfc8a22..b18945fdb 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Router.py +++ b/M02_MasterTemplete/StructOptions/M02_SO_Router.py @@ -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 · 앞 판은 이력.""" diff --git a/resources/tester/test_m02_structure_def_screen.py b/resources/tester/test_m02_structure_def_screen.py new file mode 100644 index 000000000..c344372c5 --- /dev/null +++ b/resources/tester/test_m02_structure_def_screen.py @@ -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["판"] diff --git a/resources/tester/test_m02_structure_option_groups.py b/resources/tester/test_m02_structure_option_groups.py index 7e3ca8312..144ac9314 100644 --- a/resources/tester/test_m02_structure_option_groups.py +++ b/resources/tester/test_m02_structure_option_groups.py @@ -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") diff --git a/resources/tester/test_m02_structure_options.py b/resources/tester/test_m02_structure_options.py index 971a94575..0bf9bd334 100644 --- a/resources/tester/test_m02_structure_options.py +++ b/resources/tester/test_m02_structure_options.py @@ -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 전용 표시 From f0745f714d75ccf1542e269136616816193f3084 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 16:22:25 +0900 Subject: [PATCH 3/4] =?UTF-8?q?test(M02):=2048-2=20=EC=A0=95=EC=9D=98=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EC=8B=9C=EB=82=98=EB=A6=AC=EC=98=A4=20?= =?UTF-8?q?=C2=B7=20=EC=A2=85=EB=A5=98=20=EC=A4=84=20=C2=B7=20=EB=B0=B0?= =?UTF-8?q?=EC=B9=98=20=EC=A4=84=20=EC=95=88=20=EC=A0=91=ED=9E=98=20=C2=B7?= =?UTF-8?q?=2048-1=20=EC=8B=9C=EB=82=98=EB=A6=AC=EC=98=A4=20=EC=83=88=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EA=B3=A0=EB=A6=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- .../StructOptions/M02_SO_Style.css | 25 ++- .../tester/ui_checks/48-1_구조물정의_읽기.js | 10 +- .../ui_checks/48-2_M02_구조물정의_화면.js | 145 ++++++++++++++++++ 3 files changed, 174 insertions(+), 6 deletions(-) create mode 100644 resources/tester/ui_checks/48-2_M02_구조물정의_화면.js diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Style.css b/M02_MasterTemplete/StructOptions/M02_SO_Style.css index 80d3e4cec..a8570c86a 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Style.css +++ b/M02_MasterTemplete/StructOptions/M02_SO_Style.css @@ -156,8 +156,19 @@ 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 { @@ -219,9 +230,21 @@ align-items: start; } +.m02-sod__place { + flex-wrap: nowrap; +} + .m02-sod__placename { - flex: 1 1 8rem; + 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 { diff --git a/resources/tester/ui_checks/48-1_구조물정의_읽기.js b/resources/tester/ui_checks/48-1_구조물정의_읽기.js index 306517a2f..b66fdfe35 100644 --- a/resources/tester/ui_checks/48-1_구조물정의_읽기.js +++ b/resources/tester/ui_checks/48-1_구조물정의_읽기.js @@ -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 && diff --git a/resources/tester/ui_checks/48-2_M02_구조물정의_화면.js b/resources/tester/ui_checks/48-2_M02_구조물정의_화면.js new file mode 100644 index 000000000..c5f3b47fd --- /dev/null +++ b/resources/tester/ui_checks/48-2_M02_구조물정의_화면.js @@ -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); +})(); From 1ed154f2a4be82e37478d257dcd57a389e305a4e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 16:22:40 +0900 Subject: [PATCH 4/4] =?UTF-8?q?chore(sync):=20=EA=B3=B5=EC=9C=A0=20docs=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EB=8B=B4=EC=9D=8C=20=E2=80=94=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=20=EC=9B=90=EB=AC=B8=20=EB=8D=A7=20=EC=9A=94?= =?UTF-8?q?=EA=B5=AC(=EC=9B=90=EB=AC=B8=20=EB=B3=B4=EA=B8=B0=20=ED=91=9C?= =?UTF-8?q?=20=C2=B7=20=EC=9D=BC=EC=9B=90=ED=99=94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- docs/raw/사용자요구/2026-10-05_수정요구.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/raw/사용자요구/2026-10-05_수정요구.md b/docs/raw/사용자요구/2026-10-05_수정요구.md index 9569cdcc8..023a682e3 100644 --- a/docs/raw/사용자요구/2026-10-05_수정요구.md +++ b/docs/raw/사용자요구/2026-10-05_수정요구.md @@ -68,3 +68,7 @@ 계획서는 작성해주고 빠르게 작업해줄 내용이 M01과 M02에서 원문보기가 패널 양식을 사용하지 않고 있음. 추가로 M01페이지의 원문보기에서 md파일을 보기가 깨짐이 있음. M02는 정상. M01의 경우 흐름도와 원분은 우측 패널로 구현되어있음. 좌측 패널과 동일하게 접었다 펴는 양식 제대로 확인할 ㄱ서. 그리고 우측 패널의 내용은 메인 화면의 스크롤과 별개로 동작하게 반영 + +추가로 원문에서 표는 스크롤로 매우 낮은 공간만 보이고 스크롤 표현되어서 보기가 힘듬. 그냥 표가 길더라도 보여줄것. 어차피 상세페이지와 원문의 우측 패널의 영역은 별도의 스크롤 영역이라 무관함. + +원문은 M01과 M02에 표현되고 있는데 일원화 할것. M02가 더 가독성이 좋음.