From 03e6b6511e2c35eb45ee4599965033ccfac21c65 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 20 Sep 2026 22:14:22 +0900 Subject: [PATCH] =?UTF-8?q?feat(M01):=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BB=A8=ED=85=8C=EC=9D=B4=EB=84=88=20=EA=B3=A0=EB=A5=B4?= =?UTF-8?q?=EA=B8=B0=20=EB=B6=80=ED=92=88=20=EA=B3=B5=EC=9A=A9=ED=99=94=20?= =?UTF-8?q?=E2=80=94=20=EA=B5=AC=EB=B6=84=20=E2=86=92=20=EC=83=81=EC=84=B8?= =?UTF-8?q?=EA=B5=AC=EB=B6=84=20=E2=86=92=20=ED=9B=84=EB=B3=B4=20=EA=B1=B0?= =?UTF-8?q?=EB=A6=84=20=C2=B7=20B=C2=B7C=20=EC=97=90=EB=8F=84=20=EC=9E=AC?= =?UTF-8?q?=EB=A3=8C=20=EB=B0=94=EA=BF=94=20=EB=B3=B4=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_016WRFJmmhPi4exAzHgPZHMT --- M01_MasterData/M01_MasterData_UI_Logic_Api.ts | 3 + M01_MasterData/M01_MasterData_UI_Test_A.ts | 85 +--------- M01_MasterData/M01_MasterData_UI_Test_B.ts | 34 +++- M01_MasterData/M01_MasterData_UI_Test_C.ts | 15 +- M01_MasterData/M01_MasterData_UI_Test_Pick.ts | 146 ++++++++++++++++++ .../M01_MasterData_UI_Test_Style.css | 12 ++ M01_MasterData/M01_MasterData_UI_Test_Text.ts | 2 + resources/master_data/_화면_계약.md | 3 +- 8 files changed, 216 insertions(+), 84 deletions(-) create mode 100644 M01_MasterData/M01_MasterData_UI_Test_Pick.ts diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Api.ts b/M01_MasterData/M01_MasterData_UI_Logic_Api.ts index 1816dda7..fb180409 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Api.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Api.ts @@ -165,6 +165,9 @@ export const searchElements = ( ): Promise<{ total: number; items: ElementBrief[] }> => request(`/elements?${query({ group, q, limit: "100" })}`); +export const searchPrice = (q: string): Promise<{ total: number; items: ElementBrief[] }> => + request(`/pick?${query({ kind: "price", q, limit: "100" })}`); + export const fetchMaterials = (cond: { sub: string; detail?: string; diff --git a/M01_MasterData/M01_MasterData_UI_Test_A.ts b/M01_MasterData/M01_MasterData_UI_Test_A.ts index d3642662..8ca8a183 100644 --- a/M01_MasterData/M01_MasterData_UI_Test_A.ts +++ b/M01_MasterData/M01_MasterData_UI_Test_A.ts @@ -7,23 +7,19 @@ import { createButton, createSelectField, el, showToast } from "@ui/ui_template_elements"; import { fetchLogic, - fetchMaterials, runCalc, - searchElements, type CalcAnswer, type ElementBrief, - type HoLine, type LogicInput, type LogicOne, } from "./M01_MasterData_UI_Logic_Api"; import { formatNumber } from "./M01_MasterData_UI_Logic_Edit"; +import { hasPickable, pickPanel, swappedRow } from "./M01_MasterData_UI_Test_Pick"; import { tt } from "./M01_MasterData_UI_Test_Text"; type Step = { kind: "job" } | { kind: "ask"; spec: LogicInput } | { kind: "pick" } | { kind: "result" }; -const PICKABLE = ["재료", "기계"]; - export function render(host: HTMLElement, logicKey: string): void { host.replaceChildren(el("p", { className: "m01-test__muted", text: tt("Loading") })); fetchLogic(logicKey) @@ -35,15 +31,13 @@ function wizard(host: HTMLElement, one: LogicOne): void { const { logic } = one; const values: Record = {}; const swaps = new Map(); - const lines = (logic.호표 ?? []) - .map((line, i) => ({ line, i })) - .filter((x) => PICKABLE.includes(x.line.종류)); const steps: Step[] = [ { kind: "job" }, ...(logic.입력 ?? []).map((spec): Step => ({ kind: "ask", spec })), - ...(lines.length ? [{ kind: "pick" } as Step] : []), + ...(hasPickable(logic) ? [{ kind: "pick" } as Step] : []), { kind: "result" }, ]; + const picker = pickPanel(one, swaps, () => undefined); let at = 0; const go = (next: number): void => { @@ -127,67 +121,7 @@ function wizard(host: HTMLElement, one: LogicOne): void { }; const drawPick = (): void => { - const rows = lines.map(({ line, i }) => pickRow(line, i)); - frame( - tt("Mat_Title"), - tt("Mat_Note"), - rows.length ? rows : [el("p", { text: tt("Mat_None") })], - true, - ); - }; - - const pickRow = (line: HoLine, i: number): HTMLElement => { - const price = one.prices[line.요소]?.값; - const found = new Map(); - const select = createSelectField({ - options: [{ value: "", text: tt("Mat_Default") }], - value: "", - onChange: (ref) => { - const item = found.get(ref); - if (item) swaps.set(i, item); - else swaps.delete(i); - }, - }); - // 재료 고르기 조건({구분, 상세구분, 규격}) 이면 새 API 로 조건 안 후보 · 아니면 이름 찾기 - const cond = line.요소 as unknown; - const found_ = - cond && typeof cond === "object" - ? fetchMaterials({ - sub: String((cond as Record)["구분"] ?? ""), - detail: String((cond as Record)["상세구분"] ?? ""), - spec: String((cond as Record)["규격"] ?? ""), - region: "울진", - }) - : searchElements(line.종류, line.이름 ?? ""); - void found_ - .then((r) => { - r.items.forEach((it) => found.set(it.ref, it)); - select.setOptions( - [ - { value: "", text: tt("Mat_Default") }, - ...r.items.map((it) => ({ - value: it.ref, - text: [`${it.이름 ?? ""} ${it.규격 ?? ""}`.trim(), formatNumber(it.값)] - .filter(Boolean) - .join(" · "), - })), - ], - swaps.get(i)?.ref ?? "", - ); - }) - .catch(() => undefined); - const spec = line.규격 ? ` ${line.규격}` : ""; - const unit = line.단위 ?? ""; - const cost = - price !== undefined && price !== null ? ` · ${tt("Mat_Price")} ${formatNumber(price)}` : ""; - return el("div", { - className: "m01-test__pick", - children: [ - el("strong", { text: `${line.종류} · ${line.이름 ?? line.요소}${spec}` }), - el("span", { className: "m01-test__muted", text: `${unit}${cost}` }), - select.root, - ], - }); + frame(tt("Mat_Title"), tt("Mat_Note"), [picker], true); }; const drawResult = async (): Promise => { @@ -199,16 +133,7 @@ function wizard(host: HTMLElement, one: LogicOne): void { inputs[spec.이름] = option !== undefined ? option : Number.isNaN(Number(raw)) ? raw : Number(raw); } - // 바꿔 본 재료·기계가 있으면 복사본으로만 셈 — 정본 로직은 그대로 - const row = swaps.size - ? { - ...logic, - 호표: (logic.호표 ?? []).map((l, i) => { - const s = swaps.get(i); - return s ? { ...l, 요소: s.ref, 이름: s.이름 ?? l.이름, 규격: s.규격 ?? l.규격 } : l; - }), - } - : undefined; + const row = swappedRow(logic, swaps); // 바꿔 본 것이 있으면 복사본으로만 셈 let answer: CalcAnswer; try { answer = await runCalc({ key: logic.키, inputs, ...(row ? { row, file: one.file } : {}) }); diff --git a/M01_MasterData/M01_MasterData_UI_Test_B.ts b/M01_MasterData/M01_MasterData_UI_Test_B.ts index 294ee19d..262cdf21 100644 --- a/M01_MasterData/M01_MasterData_UI_Test_B.ts +++ b/M01_MasterData/M01_MasterData_UI_Test_B.ts @@ -26,6 +26,8 @@ import { type NamedFormula, } from "./M01_MasterData_UI_Logic_Api"; import { formatNumber } from "./M01_MasterData_UI_Logic_Edit"; +import { hasPickable, pickPanel, swappedRow, type Swaps } from "./M01_MasterData_UI_Test_Pick"; +import { tt } from "./M01_MasterData_UI_Test_Text"; /* ── 원문 표(소요량·계수) 미리보기 — /elements 로 파일을 찾고 /table 로 통째 읽음 ── */ @@ -35,6 +37,7 @@ interface TableRow { 이름?: string; 원문번호?: string; 출처?: string; + 용도?: { 공종?: string; 대상?: string[]; 로직키?: string[] }; 조건?: Record; 값칸?: Record; 줄?: Record[]; @@ -218,13 +221,24 @@ interface State { answer: CalcAnswer | null; selected: number; calcSeq: number; + swaps: Swaps; + picker: HTMLElement; } export function render(host: HTMLElement, logicKey: string): void { host.replaceChildren(el("p", { className: "m01-logic__muted", text: "불러오는 중…" })); void fetchLogic(logicKey) .then((one) => { - const state: State = { one, values: {}, answer: null, selected: 0, calcSeq: 0 }; + const swaps: Swaps = new Map(); + const state: State = { + one, + values: {}, + answer: null, + selected: 0, + calcSeq: 0, + swaps, + picker: pickPanel(one, swaps, () => void recalc(host, state)), + }; paint(host, state); void recalc(host, state); }) @@ -249,7 +263,7 @@ async function recalc(host: HTMLElement, state: State): Promise { const answer = await runCalc({ key: state.one.logic.키, inputs, - row: state.one.logic, + row: swappedRow(state.one.logic, state.swaps) ?? state.one.logic, file: state.one.file, }); if (seq !== state.calcSeq) return; // 최신 응답만 채택 @@ -309,6 +323,14 @@ function paint(host: HTMLElement, state: State): void { main.append( el("h3", { text: `${row.원문번호} ${row.이름}` }), inputWrap, + ...(hasPickable(state.one.logic) + ? [ + el("details", { + attrs: { open: "" }, + children: [el("summary", { text: tt("Mat_Title") }), state.picker], + }), + ] + : []), buildHoTable(host, state), ); if (state.answer && !state.answer.ok) { @@ -434,6 +456,14 @@ async function buildCard(side: HTMLElement, state: State): Promise { className: "m01-logic__muted", text: `${r.source} · ${table.원문번호 ?? table.키} ${table.이름 ?? ""} (${table.출처 ?? ""})`, }), + ...(table.용도 + ? [ + el("p", { + className: "m01-logic__muted", + text: `${tt("Use_Of")} — ${[table.용도.공종, (table.용도.대상 ?? []).join("·")].filter(Boolean).join(" · ")}`, + }), + ] + : []), el("table", { className: "m01-logic__grid", children: [ diff --git a/M01_MasterData/M01_MasterData_UI_Test_C.ts b/M01_MasterData/M01_MasterData_UI_Test_C.ts index 11fef122..808410da 100644 --- a/M01_MasterData/M01_MasterData_UI_Test_C.ts +++ b/M01_MasterData/M01_MasterData_UI_Test_C.ts @@ -23,6 +23,8 @@ import { type FlowColumn, type FlowKind, } from "./M01_MasterData_UI_Test_C_Model"; +import { hasPickable, pickPanel, swappedRow, type Swaps } from "./M01_MasterData_UI_Test_Pick"; +import { tt } from "./M01_MasterData_UI_Test_Text"; import "./M01_MasterData_UI_Test_C_Style.css"; const TEXT = { @@ -69,6 +71,8 @@ function mount(host: HTMLElement, one: LogicOne): void { for (const spec of row.입력 ?? []) { if (spec.고르기?.length) values[spec.이름] = String(spec.고르기[0]); } + const swaps: Swaps = new Map(); + const picker = pickPanel(one, swaps, () => run()); const open = new Set(); const subs = new Map(); let answer: CalcAnswer | null = null; @@ -178,7 +182,8 @@ function mount(host: HTMLElement, one: LogicOne): void { inputs[spec.이름] = option !== undefined ? option : Number.isNaN(Number(raw)) ? raw : Number(raw); } - void runCalc({ key: row.키, inputs }) + const swapped = swappedRow(row, swaps); + void runCalc({ key: row.키, inputs, ...(swapped ? { row: swapped, file: one.file } : {}) }) .then((got) => { answer = got; redraw(); @@ -208,6 +213,14 @@ function mount(host: HTMLElement, one: LogicOne): void { ? [el("p", { className: "m01c__bad", text: one.reasons.join(" · ") })] : []), stopped, + ...(hasPickable(row) + ? [ + el("details", { + attrs: { open: "" }, + children: [el("summary", { text: tt("Mat_Title") }), picker], + }), + ] + : []), el("div", { className: "m01c__flow", children: [inputColumn(row, values, later), rest], diff --git a/M01_MasterData/M01_MasterData_UI_Test_Pick.ts b/M01_MasterData/M01_MasterData_UI_Test_Pick.ts new file mode 100644 index 00000000..3b46c80a --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_Test_Pick.ts @@ -0,0 +1,146 @@ +/* ============================================================================= + * M01_MasterData_UI_Test_Pick.ts + * 재료·기계 바꿔 고르기 부품 — 방식 A·B·C 가 같이 씀 · 구분 → 상세구분 → 후보 순으로 좁힘 + * 바꾼 것은 시험 계산용 복사본에만 — 정본 로직은 그대로 + * ========================================================================== */ + +import { createSelectField, el } from "@ui/ui_template_elements"; +import { + fetchMaterials, + searchElements, + searchPrice, + type ElementBrief, + type HoLine, + type LogicOne, + type LogicRow, +} from "./M01_MasterData_UI_Logic_Api"; +import { formatNumber } from "./M01_MasterData_UI_Logic_Edit"; +import { tt } from "./M01_MasterData_UI_Test_Text"; + +export type Swaps = Map; + +const PICKABLE = ["재료", "기계"]; + +/** 고를 수 있는 줄이 있는지 */ +export const hasPickable = (logic: LogicRow): boolean => + (logic.호표 ?? []).some((l) => PICKABLE.includes(l.종류)); + +/** 바꿔 본 줄이 있으면 고친 복사본 — 없으면 undefined(정본 그대로 셈) */ +export function swappedRow(logic: LogicRow, swaps: Swaps): LogicRow | undefined { + if (!swaps.size) return undefined; + return { + ...logic, + 호표: (logic.호표 ?? []).map((l, i) => { + const s = swaps.get(i); + return s ? { ...l, 요소: s.ref, 이름: s.이름 ?? l.이름, 규격: s.규격 ?? l.규격 } : l; + }), + }; +} + +type Brief = ElementBrief & { 구분?: string; 상세구분?: string }; + +const distinct = (items: Brief[], key: "구분" | "상세구분"): string[] => [ + ...new Set(items.map((i) => i[key]).filter((v): v is string => !!v)), +]; + +const label = (it: Brief): string => + [`${it.이름 ?? ""} ${it.규격 ?? ""}`.trim(), formatNumber(it.값)].filter(Boolean).join(" · "); + +function pickRow(one: LogicOne, line: HoLine, i: number, swaps: Swaps, onChange: () => void) { + const cond = + typeof line.요소 === "object" ? (line.요소 as unknown as Record) : null; + const price = cond ? undefined : one.prices[line.요소]?.값; + let all: Brief[] = []; + let sub = ""; + let detail = String(cond?.["상세구분"] ?? ""); + + const subBox = createSelectField({ + options: [], + value: "", + compact: true, + onChange: (v) => ((sub = v), (detail = ""), fill()), + }); + const detailBox = createSelectField({ + options: [], + value: "", + compact: true, + onChange: (v) => ((detail = v), fill()), + }); + const itemBox = createSelectField({ + options: [{ value: "", text: tt("Mat_Default") }], + value: "", + onChange: (ref) => { + const item = all.find((it) => it.ref === ref); + if (item) swaps.set(i, item); + else swaps.delete(i); + onChange(); + }, + }); + const subRow = el("div", { + className: "m01-test__filters", + children: [subBox.root, detailBox.root], + }); + + // 구분 → 상세구분 → 후보 — 앞 단을 고르면 뒷 단 목록이 좁혀짐(구분 칸이 없는 후보면 그 단은 숨김) + const fill = (): void => { + const subs = distinct(all, "구분"); + const inSub = all.filter((it) => !sub || it.구분 === sub); + const details = distinct(inSub, "상세구분"); + const inDetail = inSub.filter((it) => !detail || it.상세구분 === detail); + const all_ = { value: "", text: tt("Pick_All") }; + subBox.root.hidden = subs.length < 1; + detailBox.root.hidden = details.length < 1; + subBox.setOptions([all_, ...subs.map((v) => ({ value: v, text: v }))], sub); + detailBox.setOptions([all_, ...details.map((v) => ({ value: v, text: v }))], detail); + itemBox.setOptions( + [ + { value: "", text: tt("Mat_Default") }, + ...inDetail.map((it) => ({ value: it.ref, text: label(it) })), + ], + swaps.get(i)?.ref ?? "", + ); + }; + + // 고르기 조건({구분, 상세구분, 규격}) 줄 = 조건 안 후보 · 옛 줄 = 이름으로 찾은 후보 + const found = cond + ? fetchMaterials({ + sub: String(cond["구분"] ?? ""), + spec: String(cond["규격"] ?? ""), + region: "울진", + }) + : line.종류 === "재료" + ? searchPrice((line.이름 ?? "").split(/[\s(·]/)[0]) // 옛 줄 — 이름 첫 낱말로 자재품목(구분 붙음) + : searchElements(line.종류, line.이름 ?? ""); + void found + .then((r) => { + all = r.items as Brief[]; + if (cond) sub = String(cond["구분"] ?? ""); + fill(); + }) + .catch(() => undefined); + + const spec = line.규격 ? ` ${line.규격}` : ""; + const cost = + price !== undefined && price !== null ? ` · ${tt("Mat_Price")} ${formatNumber(price)}` : ""; + return el("div", { + className: "m01-test__pick", + children: [ + el("strong", { text: `${line.종류} · ${line.이름 ?? String(line.요소)}${spec}` }), + el("span", { className: "m01-test__muted", text: `${line.단위 ?? ""}${cost}` }), + subRow, + itemBox.root, + ], + }); +} + +/** 재료·기계 줄마다 한 칸씩 — 한 번 만들어 두고 다시 그릴 때 같은 노드를 씀(고른 것이 안 날아감) */ +export function pickPanel(one: LogicOne, swaps: Swaps, onChange: () => void): HTMLElement { + const rows = (one.logic.호표 ?? []) + .map((line, i) => ({ line, i })) + .filter((x) => PICKABLE.includes(x.line.종류)) + .map((x) => pickRow(one, x.line, x.i, swaps, onChange)); + return el("div", { + className: "m01-test__picks", + children: rows.length ? rows : [el("p", { text: tt("Mat_None") })], + }); +} diff --git a/M01_MasterData/M01_MasterData_UI_Test_Style.css b/M01_MasterData/M01_MasterData_UI_Test_Style.css index fa9f7842..dcf76091 100644 --- a/M01_MasterData/M01_MasterData_UI_Test_Style.css +++ b/M01_MasterData/M01_MasterData_UI_Test_Style.css @@ -103,3 +103,15 @@ background: var(--color-surface); color: var(--color-danger, #c0392b); } + +.m01-test__picks { + display: flex; + flex-direction: column; + gap: var(--spacing-12); +} + +.m01-test__filters { + display: flex; + gap: var(--spacing-8); + flex-wrap: wrap; +} diff --git a/M01_MasterData/M01_MasterData_UI_Test_Text.ts b/M01_MasterData/M01_MasterData_UI_Test_Text.ts index 29b87d46..80a3337a 100644 --- a/M01_MasterData/M01_MasterData_UI_Test_Text.ts +++ b/M01_MasterData/M01_MasterData_UI_Test_Text.ts @@ -34,6 +34,8 @@ const TEXT = { ], Mat_None: ["이 공종은 재료·기계를 따로 고르지 않습니다", "No materials or machines to pick"], Mat_Default: ["기본 (원문 그대로)", "Default (as in source)"], + Pick_All: ["(전체)", "(All)"], + Use_Of: ["이 표의 용도", "Used for"], Mat_Price: ["단가", "Unit price"], Result_Title: ["결과", "Result"], Result_Stopped: ["계산이 멈춤", "Calculation stopped"], diff --git a/resources/master_data/_화면_계약.md b/resources/master_data/_화면_계약.md index ff7a6fe5..0690bf43 100644 --- a/resources/master_data/_화면_계약.md +++ b/resources/master_data/_화면_계약.md @@ -34,7 +34,8 @@ - `blocked` = 로직 검사(`check_master.py 로직`)에 걸림 · `reasons` = 걸린 까닭 글. - `prices` = 호표 `요소` → `{ref, 이름, 규격, 단위, 값}`(단가 자동 칸) · 없는 요소는 `null` · `{이름}` 이 낀 원문번호·하위 로직 줄은 빠짐(계산 때 정해짐). 품셈재료는 `값` = 연결을 따라간 낮은 값(시험 계산용) · `출처` = 그 값의 열(`<자재품목 키>.<열>`) · 자재지역 후보는 `값` null(계산 때 정해짐). - `elements` = 요소 찾기 창 — 그룹 전체에서 `q` 찾기 · `ref` = 식에 넣는 키 · 표형 그룹은 `값칸` 이 옴. -- `materials` = 재료 고르기 단계 — 로직 재료 줄 `요소` 가 조건 `{구분, 상세구분, 규격}` 이면 이 API 로 후보(방식 A). `값` 이 비어 있어도 후보로 둠. +- `materials` = 재료 고르기 단계 — 로직 재료 줄 `요소` 가 조건 `{구분, 상세구분, 규격}` 이면 이 API 로 후보(방식 A·B·C 가 부품 `M01_MasterData_UI_Test_Pick.ts` 하나를 같이 씀 · 구분 → 상세구분 → 후보 순으로 좁힘). `값` 이 비어 있어도 후보로 둠. 옛 재료 줄(품셈재료 키)은 이름 첫 낱말로 `pick?kind=price` 후보. +- 소요량·계수 표의 `용도`({공종, 대상, 로직키}) = 방식 B 원문 표 미리보기 머리에 안내 · 칸이 없으면 안 보임. - `pick` = 고르기 창 — `price` = `재료_자재품목.json`(값 = 값 열 다섯 가운데 낮은 값 · `관급` = 관급 값 있음) ref 키 · `job` = `인력.json` 값 있는 공표 직종(구분 「미확보」 빼고) ref 키. ## 3. 시험 계산