From f822a4c36a0bc35c29a511d920ec3e6e57897d6a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 15 Nov 2026 15:52:13 +0900 Subject: [PATCH] =?UTF-8?q?feat(840-2):=20=EA=B8=B0=EA=B3=84=20=EB=B3=B4?= =?UTF-8?q?=EA=B8=B0=20=EA=B3=B5=EC=9A=A9=20=E2=80=94=20M01=20=EA=B8=B0?= =?UTF-8?q?=EA=B3=84=20=EC=83=81=EC=84=B8=20=C2=B7=20=EB=82=B4=20=EA=B8=B0?= =?UTF-8?q?=EA=B3=84=EB=A5=BC=20=EC=99=BC=EC=AA=BD=20=EC=A0=9C=EC=96=B4=20?= =?UTF-8?q?=EA=B5=AC=EC=97=AD=20+=20=EC=98=A4=EB=A5=B8=EC=AA=BD=20?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=EB=8B=B9=20=EA=B2=BD=EB=B9=84=20=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=EA=B7=BC=EA=B1=B0=EB=A1=9C=20=C2=B7=20=E3=80=8C?= =?UTF-8?q?=EB=B3=B5=EC=82=AC=ED=95=B4=20=EB=82=B4=20=EA=B8=B0=EA=B3=84?= =?UTF-8?q?=EB=A1=9C=E3=80=8D=20=EA=B1=B7=EC=9D=8C=20=C2=B7=20=EA=B8=B0?= =?UTF-8?q?=EA=B3=84=20=EC=A4=84=20=ED=92=80=EA=B8=B0=20=EC=95=88=20?= =?UTF-8?q?=EB=82=B4=EB=86=93=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기계 보기 부품(A00_Common/line_stack_machine_sheet) — 836-1 제어 구역 그대로(기계 표 꼴 훅 sheet · 잠금 lock) + 기본 정보 · 손료 근거 접이 카드 + 비슷한 기계에서 시작 · 값 바꾸면 브라우저 계산기로 즉시 - 서버 POST /api/m02/machine-sheet(M02_MyMachine_Sheet) — 식 고름 + 식 문서 + 처음 줄 값 + 연결 기록 · 쓰기 없음 · 파이썬 bind ↔ TS bindDoc 짝 - 덮음 ↔ 기계 줄(common_util_machine_sheet) — 고침 없음 = 줄 바이트 같음 · 내 기계 바로 · 직접 자리 맞춤 - 내 기계 = M01 에 없는 기계만 — 화면 만들기 · 고치기에서 M01 과 같은 이름 · 규격 409(불러오기 자동 생성은 그대로) · [새 기계] · 고침 열림 - 옛 기계 양식 부품(ui_template_machine_form) 지움 — 쓰는 곳 0(부품 모음 견본 줄도 걷음) - 낡은 시험 test_821_3 test_stack_line_copy — 836-2 자동 따라옴(내 기계 고치면 SK 금액 따라감 · 갱신 할 것 없음)에 맞춤 - 지킴 시험 test_840_2 — M01 기계 655 대 박은 식 셈 = 요소 셈 · 줄 그대로 · 굴착기 덮음 · 내 기계 꼴 · M01 같은 이름 막음 Co-Authored-By: Claude Opus 5.5 --- A00_Common/line_stack_machine_ctl.ts | 50 +- A00_Common/line_stack_machine_sheet.css | 4 + A00_Common/line_stack_machine_sheet.ts | 556 ++++++++++++++++++ A00_Common/line_stack_rows.ts | 6 +- M01_MasterData/M01_MasterData_Machine_Api.ts | 2 +- .../M01_MasterData_UI_MachineDetail.ts | 28 +- M02_MasterTemplete/M02_MyMachine.py | 20 + M02_MasterTemplete/M02_MyMachine_Api.ts | 2 +- M02_MasterTemplete/M02_MyMachine_Form.ts | 163 +++-- M02_MasterTemplete/M02_MyMachine_Look.py | 25 +- M02_MasterTemplete/M02_MyMachine_Router.py | 6 +- M02_MasterTemplete/M02_MyMachine_Sheet.py | 90 +++ M02_MasterTemplete/M02_MyMachine_Text.ts | 5 +- M02_MasterTemplete/M02_MyMachine_View.ts | 23 +- common_util/common_util_machine_sheet.ts | 185 ++++++ .../tester/helper_840_2_machine_sheet.cjs | 35 ++ .../tester/test_821_3_my_machine_screen.py | 8 +- resources/tester/test_840_2_machine_sheet.py | 200 +++++++ ui_template/ui_template_locale_machine.ts | 15 +- ui_template/ui_template_machine_form.css | 112 ---- ui_template/ui_template_machine_form.ts | 520 ---------------- ui_template/ui_template_ui_kit_parts.ts | 19 - 22 files changed, 1283 insertions(+), 791 deletions(-) create mode 100644 A00_Common/line_stack_machine_sheet.css create mode 100644 A00_Common/line_stack_machine_sheet.ts create mode 100644 M02_MasterTemplete/M02_MyMachine_Sheet.py create mode 100644 common_util/common_util_machine_sheet.ts create mode 100644 resources/tester/helper_840_2_machine_sheet.cjs create mode 100644 resources/tester/test_840_2_machine_sheet.py delete mode 100644 ui_template/ui_template_machine_form.css delete mode 100644 ui_template/ui_template_machine_form.ts diff --git a/A00_Common/line_stack_machine_ctl.ts b/A00_Common/line_stack_machine_ctl.ts index 934482289..002bcb429 100644 --- a/A00_Common/line_stack_machine_ctl.ts +++ b/A00_Common/line_stack_machine_ctl.ts @@ -6,6 +6,8 @@ * ㉢ 잡품 = 재료 줄(재료 · 유가 고르기 · 수량 · 율 % · 기본 = 밑 재료 줄 · 품셈 잡재료율) * 고침 = 문서 칸 `기계덮음`(캐시 · [저장] 때 서버가 정본으로 다시 셈) · 원본과 같으면 지움(`putCover`) * 기본값 = 덮음 없이 셈한 그 기계 식 답(`defaultsOf`) · 읽기(M01 기초) = 칸 잠김 · 단추 없음 + * 기계 표 꼴(`sheet` · 840-2 기계 보기 `line_stack_machine_sheet`) = 조종원 한 줄까지 · 잡품 = 밑 줄 고정(율 하나) · + * `lock` = 잠긴 값 칸(품셈 방식 계수 등) * ========================================================================== */ import { createBadge } from "@ui/ui_template_badge"; @@ -42,6 +44,10 @@ export interface CtlHooks { name: (key: string) => string; /** 읽기(M01 기초 · 칸 잠김) */ readOnly: boolean; + /** 기계 표 꼴(840-2) — 기계 줄 칸에 담기는 만큼만(조종원 한 줄 · 잡품 율 하나 · 재료 고르기 없음) */ + sheet?: boolean; + /** 잠긴 값 칸 */ + lock?: string[]; } /** 기계 칸 단위(기계 표 `단위` 와 같음) */ @@ -116,6 +122,7 @@ export function buildMachineCtl(h: CtlHooks, link: Link | undefined): HTMLElemen const { machine, sub, vals, base } = got; const entry: MachineCover = got.entry ?? {}; const ro = h.readOnly; + const table = !!h.sheet; const put = (next: MachineCover): void => h.change((d) => putCover(d, machine, next, base)); const parts = partsOf(sub); const lines = sub.금액 ?? []; @@ -125,6 +132,7 @@ export function buildMachineCtl(h: CtlHooks, link: Link | undefined): HTMLElemen const was = base.값[col]; const mine = entry.값?.[col]; const now = mine ?? was; + const shut = ro || !!h.lock?.includes(col); const setVal = (v: number | string | null): void => { const 값 = { ...(entry.값 ?? {}) }; if (v === null || v === "") delete 값[col]; @@ -140,11 +148,11 @@ export function buildMachineCtl(h: CtlHooks, link: Link | undefined): HTMLElemen value: String(now ?? ""), compact: true, size: 24, - disabled: ro, + disabled: shut, ariaLabel: col, onChange: (x) => setVal(x), }).root - : numBox(col, now, UNIT[col] ?? "", ro, setVal); + : numBox(col, now, UNIT[col] ?? "", shut, setVal); const row = el("div", { className: "lstk-frow", children: [ @@ -157,7 +165,7 @@ export function buildMachineCtl(h: CtlHooks, link: Link | undefined): HTMLElemen const tag = createBadge(lt("Ctl_Changed"), "info", { size: 18 }); tag.title = `${lt("Ctl_Was")} ${was === undefined ? "—" : eqText(was)}`; row.append(tag); - if (!ro) + if (!shut) row.append( createButton({ label: lt("Ctl_Back"), @@ -230,21 +238,30 @@ export function buildMachineCtl(h: CtlHooks, link: Link | undefined): HTMLElemen lineRow( lt("K_재료"), miscName(r), - ro, + ro || table, () => pickMat((p) => setMisc(swapAt(misc, i, asMat(r, p))), r.키 ? miscName(r) : ""), [ - numBox(lt("Qty"), r.수량, r.단위 ?? "", ro, (n) => { - const { 수량: _, ...rest } = r; - setMisc(swapAt(misc, i, n === null ? rest : { ...r, 수량: n })); - }, r.줄 && r.수량 === undefined ? `${baseQty(r) ?? ""} ${lt("Ctl_Follow")}` : ""), // prettier-ignore - numBox(lt("Ctl_Rate"), r.율, "%", ro, (n) => setMisc(swapAt(misc, i, { ...r, 율: n ?? 0 }))), // prettier-ignore + ...(table + ? [] + : [numBox(lt("Qty"), r.수량, r.단위 ?? "", ro, (n) => { + const { 수량: _, ...rest } = r; + setMisc(swapAt(misc, i, n === null ? rest : { ...r, 수량: n })); + }, r.줄 && r.수량 === undefined ? `${baseQty(r) ?? ""} ${lt("Ctl_Follow")}` : "")]), // prettier-ignore + numBox( + lt("Ctl_Rate"), + r.율, + "%", + ro, + (n) => + setMisc(table ? misc.map((x) => ({ ...x, 율: n ?? 0 })) : swapAt(misc, i, { ...r, 율: n ?? 0 })), // prettier-ignore + ), ], "재료비", () => setMisc(misc.filter((_, k) => k !== i)), ), ); - const head = (label: string, covered: boolean, add: () => void, reset: () => void) => + const head = (label: string, covered: boolean, add: (() => void) | null, reset: () => void) => el("div", { className: "lstk-fhead", children: [ @@ -253,7 +270,7 @@ export function buildMachineCtl(h: CtlHooks, link: Link | undefined): HTMLElemen ...(ro ? [] : [ - createIconButton({ icon: "plus", title: `${label} ${lt("Ctl_Add")}`, size: 24, onClick: add }), // prettier-ignore + ...(add ? [createIconButton({ icon: "plus", title: `${label} ${lt("Ctl_Add")}`, size: 24, onClick: add })] : []), // prettier-ignore ...(covered ? [createButton({ label: lt("Ctl_Back"), variant: "ghost", size: 24, iconStart: "undo", onClick: reset })] // prettier-ignore : []), @@ -270,14 +287,21 @@ export function buildMachineCtl(h: CtlHooks, link: Link | undefined): HTMLElemen head( lt("Ctl_Crew"), entry.조종원 !== undefined, - () => openStackPick("인력", (p) => setCrew([...crew, { 키: p.키, 이름: p.이름, 인: 1 }])), + table && crew.length + ? null + : () => + openStackPick("인력", (p) => setCrew([...crew, { 키: p.키, 이름: p.이름, 인: 1 }])), () => put({ ...entry, 조종원: undefined }), ), ...(crewRows.length ? crewRows : [el("div", { className: "lstk-empty", text: lt("Ctl_None") })]), // prettier-ignore head( lt("Ctl_Misc"), entry.잡품 !== undefined, - () => pickMat((p) => setMisc([...misc, asMat({ 율: 100 }, p)]), ""), + !table + ? () => pickMat((p) => setMisc([...misc, asMat({ 율: 100 }, p)]), "") + : parts.base.length && !misc.length + ? () => setMisc(parts.base.map((k) => ({ 줄: String(lines[k].이름 ?? ""), 율: 0 }))) + : null, () => put({ ...entry, 잡품: undefined }), ), ...(miscRows.length ? miscRows : [el("div", { className: "lstk-empty", text: lt("Ctl_None") })]), // prettier-ignore diff --git a/A00_Common/line_stack_machine_sheet.css b/A00_Common/line_stack_machine_sheet.css new file mode 100644 index 000000000..16f044378 --- /dev/null +++ b/A00_Common/line_stack_machine_sheet.css @@ -0,0 +1,4 @@ +/* 기계 보기(840-2) — 비슷한 기계 찾기 목록 높이만 · 색 · 크기는 공통 값 */ +.lstk-msheet-similar .ui-find__list { + max-height: 240px; +} diff --git a/A00_Common/line_stack_machine_sheet.ts b/A00_Common/line_stack_machine_sheet.ts new file mode 100644 index 000000000..cb512c184 --- /dev/null +++ b/A00_Common/line_stack_machine_sheet.ts @@ -0,0 +1,556 @@ +/* ============================================================================= + * line_stack_machine_sheet.ts + * 기계 보기(PLAN 840-2 · 요구 853 · 862 「기계 보기 공용」) — 기계 한 대를 단가산출 수식 보기와 닮은 꼴로: + * 왼쪽 = 기계 제어 구역(836-1 `line_stack_machine_ctl` 그대로 · 기계 표 꼴 `sheet` — 값 칸 · 조종원 인력 줄 · 잡품 줄) + + * 「기본 정보 · 손료 근거」 접이 카드(이름 · 규격 · 손료 방식 · 품셈 비율 · 식이 안 읽는 값 칸) + 비슷한 기계에서 시작(새 기계) + * 오른쪽 = 그 기계 시간당 경비 산출근거(`line_stack_out` · 노 · 재 · 경 · 계 합계 상자) — 값을 바꾸면 브라우저 계산기로 즉시 + * 서버(`POST /api/m02/machine-sheet` · `M02_MyMachine_Sheet`) = 식 고름 + 처음 줄 값을 박은 식 문서 + 연결 기록 · + * 고친 값은 덮음(`기계덮음` 꼴)으로 얹어 셈 · 캐시에 없는 연결 · 식이 바뀔 칸만 뒤에서 다시 물음(옛 요청 끊음) + * 줄 = 저장 꼴(M01 기계 줄 · 내 기계 줄) — 덮음 ↔ 줄은 `common_util_machine_sheet` · 저장은 부르는 쪽(M01 상세 · 내 기계) + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible"; +import { createInputField, createSelectField, el } from "@ui/ui_template_elements"; +import { createFindList } from "@ui/ui_template_find_list"; +import { t as L } from "@ui/ui_template_locale"; +import { attachPickSearch } from "@ui/ui_template_pick_search"; +import { createSegment } from "@ui/ui_template_segment"; +import { createText } from "@ui/ui_template_text"; +import type { FormulaDoc, Link } from "../common_util/common_util_formula_doc"; +import { calcDoc, type DocAnswer } from "../common_util/common_util_formula_doc_calc"; +import { createDocCache, type DocRecord } from "../common_util/common_util_formula_doc_cache"; +import { + COVER, + coverDoc, + coverOf, + defaultsOf, + partsOf, + putCover, + type CoverParts, + type MachineCover, +} from "../common_util/common_util_formula_doc_cover"; +import type { StackDoc } from "../common_util/common_util_line_stack"; +import { + MACHINE_COEFS, + MACHINE_INPUTS, + machineCoefOf, +} from "../common_util/common_util_machine_coef"; +import { + applyCover, + bindDoc, + coverFrom, + engineVals, + jobCode, + modeOf, + ownedCols, + rangeWarnings, + sameVal, + slotOf, + type MachineMode, + type MachinePick, + type MachineRow, + type MachineValues, +} from "../common_util/common_util_machine_sheet"; +import { machineCellName } from "../common_util/common_util_machine_symbol_name"; +import { docFoot } from "./formula_doc_foot"; +import { buildMachineCtl } from "./line_stack_machine_ctl"; +import { buildOut } from "./line_stack_out"; +import "./line_stack.css"; +import "./line_stack_machine_sheet.css"; + +export interface MachineSimilar { + key: string; + name: string; + spec: string; + /** 흐린 꼬리표(구분 · 내 기계 등) */ + tag?: string; + row: MachineRow; +} + +export interface MachineSheetOptions { + /** 처음 줄(저장본 · 새 기계 = 빈 줄) — 고친 칸 표지 · 되돌림 기준 */ + orig: MachineRow; + /** 지금 줄(초안) · 없으면 처음 줄 */ + cur?: MachineRow; + /** 내 기계(방식 바로 · 품셈 · 직접 · 그 사람 개인 층 기계 표) */ + mine: boolean; + readOnly: boolean; + lists: { fuels: MachinePick[]; jobs: MachinePick[] }; + findSimilar?: (q: string) => Promise; + onSimilar?: (item: MachineSimilar) => void; + onChange?: () => void; +} + +export interface MachineSheetState { + name: string; + spec: string; + /** 지금 줄(이름 · 규격 넣음) */ + row: MachineRow; + mode: MachineMode; + /** 숫자 아닌 칸 — 있으면 저장 막음 */ + errors: string[]; + changed: boolean; +} + +interface SheetInfo { + 키: string | null; + 이름?: string; + 까닭?: string; + 문서: FormulaDoc | null; + /** 처음 줄 셈 꼴 값(식에 박을 값) */ + 값?: MachineValues; + 연결?: DocRecord; + 직종: Record; +} + +/** 늘 보이는 기계 줄 칸(값이 없어도) — 그 밖 칸은 줄에 있으면 */ +const BASE_KEYS = [ + "가격", + "손료계수", + "주연료", + "주연료종류", + "잡재료율", + "조종원", + "조종원직종", + "전력", +]; +const UNIT: Record = { + 가격: "천원", 손료계수: "10⁻⁷/hr", 내용시간: "시간", 연간표준가동시간: "시간", 주연료: "ℓ/hr", + 잡재료율: "%", 조종원: "인", 전력: "kWh", +}; // prettier-ignore +const PICKS = ["주연료종류", "조종원직종"]; +/** 식이 바뀔 수 있는 칸(서버 식 고름) */ +const PICKY = ["주연료", "조종원", "조종원직종", "전력", "손료계수", ...MACHINE_COEFS]; + +const clone = (v: T): T => JSON.parse(JSON.stringify(v ?? null)) as T; +const text = (v: unknown): string => (v === null || v === undefined ? "" : String(v)); +const label = (k: string): string => machineCellName(k); + +export function createMachineSheet(o: MachineSheetOptions) { + const mine = o.mine; + const ro = o.readOnly; + let orig: MachineRow = clone(o.orig); + let cur: MachineRow = clone(o.cur ?? o.orig); + let name = text(cur.이름); + let spec = text(cur.규격); + const start = JSON.stringify([name, spec, cur]); + const no = text(cur.원문번호) || text(cur.키) || "새 기계"; + const given = { 기계: no }; + const link: Link = { 갈래: "단가산출", 입력: { 기계: `'${no.replace(/'/g, "")}'` } }; + const holder = { 꼴: "수식문서", 변수: [], 식: [], 금액: [] } as unknown as StackDoc; + const cache = createDocCache(); + const opened = new Set(); + const errors = new Map(); + let info: SheetInfo | null = null; + let jobs: Record = {}; + let parts: CoverParts | null = null; + let base: Required = { 값: {}, 조종원: [], 잡품: [] }; + + const left = el("div", { className: "lstk__left" }); + const right = el("div", { className: "lstk__right" }); + const foot = el("div", { className: "lstk__foot" }); + const notice = el("div"); + const warn = el("div"); + const ctlBox = el("div"); + const baseBox = el("div"); + const root = el("div", { + className: "lstk", + attrs: { "data-edit": "1", "data-machine-sheet": no }, + children: [el("div", { className: "lstk__areas", children: [left, right] }), foot], + }); + attachCollapsible(baseBox); + + const sub = (): FormulaDoc | null => info?.문서 ?? null; + const entry = (): MachineCover => coverOf(holder)[no] ?? {}; + /** 값 박은 셈(덮음 얹은 뒤 · 제어 구역 기본값) */ + const plain = (d: FormulaDoc, g: Record): DocAnswer => + raw(bindDoc(d, info?.값 ?? {}), g); + const raw = (d: FormulaDoc, g: Record): DocAnswer => + calcDoc(d, cache.links, g, [], new Map(), {}); + + /** 식이 바뀌거나 처음 줄 · 방식이 바뀐 뒤 — 덮음을 처음 줄 대 지금 줄로 다시 지음 */ + const rebuild = (): void => { + delete (holder as { [COVER]?: unknown })[COVER]; + const d = sub(); + parts = d ? partsOf(d) : null; + if (!d || !parts) return; + base = defaultsOf(d, (plain(d, given).값 ?? {}) as Record); + putCover(holder, no, coverFrom(parts, d.금액 ?? [], orig, cur, mine), base); + }; + + // ── 서버(뒤에서) ── + let seq = 0; + let timer: number | undefined; + let ctl: AbortController | null = null; + const ask = (now = false): void => { + window.clearTimeout(timer); + timer = window.setTimeout(() => void run(), now ? 0 : 300); + }; + const run = async (): Promise => { + ctl?.abort(); + ctl = new AbortController(); + const mineSeq = ++seq; + const body = { 줄: rowOut(), 본: orig, 덮음: coverOf(holder)[no] ?? null, 개인: mine }; + try { + const r = await fetch(`${API_BASE_URL}/m02/machine-sheet`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + signal: ctl.signal, + }); + const got = (await r.json().catch(() => ({}))) as SheetInfo & { detail?: unknown }; + if (mineSeq !== seq) return; + if (!r.ok) throw new Error(text(got.detail) || r.statusText); + cache.merge(got.연결); + jobs = got.직종 ?? jobs; + if (!info || got.키 !== info.키) { + info = got; + rebuild(); + drawAll(); + } else drawLive(); + } catch (e) { + if (mineSeq !== seq || (e as Error).name === "AbortError") return; + info = { 키: null, 까닭: e instanceof Error ? e.message : "", 문서: null, 직종: jobs }; + parts = null; + drawAll(); + } + }; + + const rowOut = (): MachineRow => ({ ...cur, 이름: name.trim(), 규격: spec.trim() }); + const changed = (refit = false): void => { + o.onChange?.(); + drawLive(); + ask(refit); + }; + + // ── 품셈 비율 → 계수(같으면 처음 값 · 제어 구역 칸은 덮음으로) ── + const recoef = (): void => { + if (modeOf(cur, mine) !== "품셈") return; + const v = (cur.값 ??= {}); + const same = MACHINE_INPUTS.every((k) => sameVal(v[k], orig.값?.[k])); + const got = same ? null : machineCoefOf(v); + if (!same && !got) return; + const owned = parts ? ownedCols(parts) : []; + const e = clone(entry()); + for (const k of MACHINE_COEFS) { + const want = same ? orig.값?.[k] : got![k]; + if (owned.includes(k)) { + e.값 = { ...(e.값 ?? {}) }; + if (want === undefined || same) delete e.값[k]; + else e.값[k] = want as number; + } else if (want === undefined) delete v[k]; + else if (!sameVal(v[k], want)) v[k] = want; + } + if (parts) { + putCover(holder, no, e, base); + cur = applyCover(parts, orig, cur, entry(), mine, jobs); + } + }; + + // ── 왼쪽 ── + const drawCtl = (): void => { + const d = sub(); + if (!d || !info?.키) return void ctlBox.replaceChildren(); + const mode = modeOf(cur, mine); + const lock = mode === "품셈" ? [...MACHINE_COEFS] : mine && mode === "바로" ? ["정비비계수", "관리비계수"] : []; // prettier-ignore + const zone = buildMachineCtl( + { + doc: holder, + answer: { 값: {} } as unknown as DocAnswer, + subDoc: () => d, + calc: plain, + change: (fn) => { + fn(holder); + const e = entry(); + if (e.조종원) { + e.조종원 = e.조종원.map((r) => ({ ...r, 키: `LB:${jobCode(r.키, jobs)}` })); + putCover(holder, no, e, base); + } + if (parts) cur = applyCover(parts, orig, cur, entry(), mine, jobs); + changed(); + }, + name: (k) => cache.names.get(k) ?? "", + readOnly: ro, + sheet: true, + lock, + }, + { ...link, 키: info.키 }, + ); + if (zone && lock.length && mode === "품셈") zone.title = L("MS_Locked"); + ctlBox.replaceChildren(...(zone ? [zone] : [])); + }; + + const numField = (k: string, read = false): HTMLElement => { + const v = cur.값?.[k]; + const f = createInputField({ label: label(k), value: text(v), size: 28, ...(UNIT[k] ? { unit: UNIT[k] } : {}) }); // prettier-ignore + f.input.inputMode = "decimal"; + f.input.dataset.key = k; + f.input.readOnly = ro || read; + f.root.classList.toggle("is-changed", !sameVal(v, orig.값?.[k])); + f.input.addEventListener("change", () => { + const t = f.input.value.replace(/,/g, "").trim(); + const box = (cur.값 ??= {}); + const n = Number(t); + if (t && !Number.isFinite(n)) { + errors.set(k, `${label(k)} · ${L("MF_NumberOnly")}`); + f.setError(L("MF_NumberOnly")); + return o.onChange?.(); + } + errors.delete(k); + f.setError(); + if (!t) { + if (k in (orig.값 ?? {})) box[k] = null; + else delete box[k]; + } else box[k] = n; + if ((MACHINE_INPUTS as readonly string[]).includes(k)) recoef(); + f.root.classList.toggle("is-changed", !sameVal(box[k], orig.값?.[k])); + changed(PICKY.includes(k)); + if ((MACHINE_INPUTS as readonly string[]).includes(k)) drawBase(); + }); + return f.root; + }; + + const pickField = (k: string): HTMLElement => { + const list = k === "주연료종류" ? o.lists.fuels : o.lists.jobs; + const now = text(cur.값?.[k]); + const options = [ + { value: "", text: L("MF_None") }, + ...list.map((p) => ({ value: p.value, text: p.text })), + ]; + if (now && !list.some((p) => p.value === now)) options.push({ value: now, text: `${now} (${L("MF_NoPrice")})` }); // prettier-ignore + const f = createSelectField({ label: label(k), options, value: now, size: 28 }); + f.select.dataset.key = k; + f.select.disabled = ro; + attachPickSearch(f.select, { placeholder: L("MF_PickFind") }); + f.root.classList.toggle("is-changed", !sameVal(now, orig.값?.[k])); + f.select.addEventListener("change", () => { + const box = (cur.값 ??= {}); + if (f.select.value) box[k] = f.select.value; + else if (k in (orig.값 ?? {})) box[k] = orig.값![k] === "" ? "" : null; + else delete box[k]; + f.root.classList.toggle("is-changed", !sameVal(box[k], orig.값?.[k])); + changed(true); + }); + return f.root; + }; + + const setMode = (m: MachineMode): void => { + const v = (cur.값 ??= {}); + const was = modeOf(cur, mine); + if (m === was) return; + if (m === "품셈") for (const k of MACHINE_INPUTS) if (orig.값?.[k] !== undefined) v[k] = clone(orig.값[k]); // prettier-ignore + if (m === "바로" && !mine) for (const k of MACHINE_INPUTS) delete v[k]; + if (mine) { + if (m === "바로" && blankNum(v.손료계수)) { + const e = engineVals(cur, true); + v.손료계수 = ["상각비계수", "정비비계수", "관리비계수"].reduce( + (s, k) => s + (Number(e[k]) || 0), + 0, + ); + } + if (m === "직접" && !cur.시간당) cur.시간당 = Object.fromEntries((["노무비", "재료비", "경비"] as const).map((k) => [k, Math.round(lastAnswer?.[k] ?? 0)])); // prettier-ignore + cur.방식 = m; + } + recoef(); + rebuild(); + drawAll(); + changed(true); + }; + const blankNum = (x: unknown): boolean => typeof x !== "number" || !x; + + const drawBase = (): void => { + const keep = baseBox.firstElementChild?.classList.contains("is-collapsed") ?? false; + const mode = modeOf(cur, mine); + const owned = new Set( + (parts && info?.키 ? ownedCols(parts) : []) + .map((c) => slotOf(c, mode, mine)) + .flatMap((s) => (s && s[0] === "값" ? [s[1]] : [])), + ); + const pumsem = mode === "품셈"; + const keys = + mode === "직접" + ? [] + : [...new Set([...BASE_KEYS, ...Object.keys(cur.값 ?? {})])].filter( + (k) => !owned.has(k) && !(MACHINE_INPUTS as readonly string[]).includes(k) && !(pumsem && (MACHINE_COEFS as readonly string[]).includes(k)), // prettier-ignore + ); + const textBox = ( + lab: string, + get: string, + was: string, + set: (v: string) => void, + ): HTMLElement => { + const f = createInputField({ label: lab, value: get, size: 28 }); + f.input.readOnly = ro; + f.root.classList.toggle("is-changed", get !== was); + f.input.addEventListener("input", () => { + set(f.input.value); + f.root.classList.toggle("is-changed", f.input.value !== was); + o.onChange?.(); + }); + return f.root; + }; + const seg = createSegment({ + options: [ + { value: "바로", label: L("MF_CoefDirect") }, + { value: "품셈", label: L("MF_CoefPumsem") }, + ...(mine ? [{ value: "직접", label: L("MF_HourlyDirect") }] : []), + ], + value: mode, + size: 24, + ariaLabel: L("MS_Mode"), + onChange: (v) => setMode(v as MachineMode), + }); + if (ro) seg.root.querySelectorAll("button").forEach((b) => (b.disabled = true)); + const row = (children: HTMLElement[]): HTMLElement => el("div", { className: "lstk-frow", children }); // prettier-ignore + const card = createPanelCard(L("MS_Base"), keep); + card.append( + row([ + textBox(L("MF_Name"), name, text(orig.이름), (v) => (name = v)), + textBox(L("MF_Spec"), spec, text(orig.규격), (v) => (spec = v)), + ]), + row([el("span", { className: "lstk-kind", text: L("MS_Mode") }), seg.root]), + ...(pumsem + ? [ + row(MACHINE_INPUTS.map((k) => numField(k))), + row([ + el("span", { className: "lstk-kind", text: L("MS_Coefs") }), + ...MACHINE_COEFS.filter((k) => !owned.has(k)).map((k) => numField(k, true)), + ]), + ] + : []), + ...(keys.length + ? [row(keys.map((k) => (PICKS.includes(k) ? pickField(k) : numField(k))))] + : []), + ); + baseBox.replaceChildren(card); + }; + + const similarBox = (): HTMLElement[] => { + if (!o.findSimilar || ro) return []; + const find = createFindList({ placeholder: L("MF_SimilarFind") }); + const card = createPanelCard(L("MF_Similar"), !!(orig.이름 || orig.값 && Object.keys(orig.값).length)); // prettier-ignore + card.classList.add("lstk-msheet-similar"); + card.append(find.root); + attachCollapsible(card); + let n = 0; + let wait: number | undefined; + const look = async (): Promise => { + const me = ++n; + const got = await o.findSimilar!(find.search.value.trim()).catch(() => []); + if (me !== n) return; + find.hint.textContent = got.length ? "" : L("MF_SimilarNone"); + find.setRows( + got.map((m) => ({ + key: m.key, + name: `${m.name} ${m.spec}`.trim(), + tag: m.tag, + sub: m.key, + onPick: () => { + orig = clone(m.row); + cur = clone(m.row); + name = m.name; + spec = m.spec; + errors.clear(); + info = null; + parts = null; + delete (holder as { [COVER]?: unknown })[COVER]; + card.classList.add("is-collapsed"); + setNotice(L("MF_SimilarCopied").replace("{value}", `${m.name} ${m.spec}`.trim())); + o.onSimilar?.(m); + drawAll(); + changed(true); + }, + })), + ); + }; + find.search.addEventListener("input", () => { + window.clearTimeout(wait); + wait = window.setTimeout(() => void look(), 250); + }); + void look(); + return [card]; + }; + + // ── 오른쪽 · 경고 ── + let lastAnswer: DocAnswer | null = null; + const drawRight = (): void => { + const d = sub(); + const head = el("div", { + className: "lstk__bar", + children: [ + el("strong", { text: L("MS_Out") }), + ...(info?.키 ? [el("span", { className: "ui-text ui-text--muted", text: `${info.키} ${info.이름 ?? ""}`.trim() })] : []), // prettier-ignore + ], + }); + if (!info) { + lastAnswer = null; + foot.replaceChildren(); + return void right.replaceChildren(head, createText(L("MS_Loading"), "muted")); + } + if (!d) { + lastAnswer = null; + foot.replaceChildren(); + const why = info.까닭 ? ` — ${info.까닭}` : ""; + return void right.replaceChildren(head, createText(`${L("MS_NoFormula")}${why}`, "warning")); + } + const shaped = coverDoc(d, given, coverOf(holder)); + const a = plain(shaped, given); + if (cache.takeMissing()) ask(); + lastAnswer = a; + right.replaceChildren( + head, + ...(info.까닭 ? [createText(info.까닭, "warning")] : []), + buildOut(bindDoc(shaped, info.값 ?? {}) as StackDoc, a, { + cache, + open: opened, + toggle: (at) => { + if (opened.has(at)) opened.delete(at); + else opened.add(at); + drawRight(); + }, + miss: () => ask(), + redraw: () => drawRight(), + }), + ); + foot.replaceChildren(docFoot(a)); + }; + const drawWarn = (): void => { + const vals: MachineValues = { ...engineVals(cur, mine), ...(cur.값 ?? {}) }; + warn.replaceChildren( + ...rangeWarnings(vals).map(([k, r]) => + createText(L("MS_Range").replace("{name}", label(k)).replace("{value}", r), "warning"), + ), + ); + }; + const drawLive = (): void => { + drawCtl(); + drawRight(); + drawWarn(); + }; + const drawAll = (): void => { + drawBase(); + drawLive(); + }; + const setNotice = (t: string): void => { + notice.replaceChildren(...(t ? [createText(t, "warning")] : [])); + }; + + left.append(...similarBox(), notice, warn, ctlBox, baseBox); + drawAll(); + ask(true); + + return { + root, + setNotice, + state: (): MachineSheetState => ({ + name: name.trim(), + spec: spec.trim(), + row: rowOut(), + mode: modeOf(cur, mine), + errors: [...errors.values()], + changed: JSON.stringify([name, spec, cur]) !== start, + }), + }; +} diff --git a/A00_Common/line_stack_rows.ts b/A00_Common/line_stack_rows.ts index 3011151b8..38086baee 100644 --- a/A00_Common/line_stack_rows.ts +++ b/A00_Common/line_stack_rows.ts @@ -5,7 +5,7 @@ * 고침(804-7 · 요구 687-14) = [식 고치기] 걷음 — 이름 · 식 글을 누르면 그 줄만 그 자리에서 이름 · 식 · 단위 칸 + 지움 · * 바꾸면 닫힘 · 머리 [+] = 식 줄 더함 — 이름 바꿈 = 문서 전부(`rename`) · 식 = 식 줄 · 수 = 숫자 연결 * 만약 안 탄 갈래 기호(`hide` · 요구 687-10) = 안 보임(식 글은 그대로) - * 틀 = 단가산출 결과 문서(굴착기 Q 등)를 이 문서 식으로 옮김(`unfold`) — 꼴이 바뀌면 「내 틀 (틀 이름 에서 변경)」 · + * 틀 = 단가산출 결과 문서(굴착기 Q 등)를 이 문서 식으로 옮김(`unfold` · 입력에 기계 있는 줄은 안 함 840-2) — 꼴이 바뀌면 「내 틀 (틀 이름 에서 변경)」 · * 빈 틀 = [찾아 넣기](M01 단가산출 결과 — 작업량 등) · [틀로 저장] · 내 틀 목록 걷음(요구 685) · 표 값만 = 머리 「표 값」 * 표 값(804-3) = [표 · 칸] 단추 → 표 고르기 창(`line_stack_pick.ts`) · 값 칸 누름 = 그 줄 · 칸(`pickCell`) * ========================================================================== */ @@ -23,6 +23,7 @@ import { type FormulaDoc, } from "../common_util/common_util_formula_doc"; import { isNum } from "../common_util/common_util_formula_doc_calc"; +import { machineKey } from "../common_util/common_util_formula_doc_cover"; import { decide, hasIf, tryParse } from "../common_util/common_util_formula_doc_view"; import { machineText } from "./formula_doc_options"; import { @@ -262,7 +263,8 @@ export function buildBlock( const srcTag = src === "원파일" ? lt("Src_Native") : src === "엑셀" ? lt("Src_Excel") : src; const tag = expr ? null : srcTag ? clipTag(srcTag) : sourceTag(h, id, link); const tools: HTMLElement[] = []; - if (sub?.결과 && link?.키) + // 기계 줄(입력에 기계) = 풀기 안 내놓음(840-2 · 요구 864 — 기계 값 고침은 제어 구역 한 길 · 풀린 옛 줄은 그대로 열림) + if (sub?.결과 && link?.키 && !machineKey(link.입력)) tools.push( createButton({ label: lt("Unfold"), diff --git a/M01_MasterData/M01_MasterData_Machine_Api.ts b/M01_MasterData/M01_MasterData_Machine_Api.ts index 8270f5854..b86155c3a 100644 --- a/M01_MasterData/M01_MasterData_Machine_Api.ts +++ b/M01_MasterData/M01_MasterData_Machine_Api.ts @@ -5,7 +5,7 @@ * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; -import type { MachinePick } from "@ui/ui_template_machine_form"; +import type { MachinePick } from "../common_util/common_util_machine_sheet"; import type { Row } from "./M01_MasterData_Api_Fetch"; export interface MachineLists { diff --git a/M01_MasterData/M01_MasterData_UI_MachineDetail.ts b/M01_MasterData/M01_MasterData_UI_MachineDetail.ts index 2ab3d096d..e398dcde7 100644 --- a/M01_MasterData/M01_MasterData_UI_MachineDetail.ts +++ b/M01_MasterData/M01_MasterData_UI_MachineDetail.ts @@ -1,6 +1,7 @@ /* ============================================================================= * M01_MasterData_UI_MachineDetail.ts - * M01 기계 상세 페이지(PLAN 821-2 · 요구 790-2 · 791) — 기계 양식 부품 + 원문번호 · 원문 · 구분 · + * M01 기계 상세 페이지(PLAN 821-2 · 840-2 · 요구 790-2 · 791 · 862) — 기계 보기(`line_stack_machine_sheet` · + * 왼쪽 제어 구역 + 오른쪽 시간당 경비 산출근거 · 고침 = M01 기계 줄 정본 값) + 원문번호 · 원문 · 구분 · * 상세구분 · 별칭 · 비고 · 쓰는 계산식(822-2 · 서버 식 고름) · 쓰는 곳 · 지우기(쓰는 곳 보이고 확인). * 고침은 초안(sessionStorage)에 바로 쌓임 → 페이지 [저장] 한 번에 서버로(자동저장 없음) · * 서버가 계수를 계수 짝으로 다시 셈 · 단위 · 출처 칸을 채움(`M01_MasterData_Machine.fix`). @@ -16,8 +17,8 @@ import { showToast, } from "@ui/ui_template_elements"; import { t as L } from "@ui/ui_template_locale"; -import { createMachineForm, type MachineSimilar } from "@ui/ui_template_machine_form"; import { createText } from "@ui/ui_template_text"; +import { createMachineSheet, type MachineSimilar } from "../A00_Common/line_stack_machine_sheet"; import { setLeaveGuard } from "../A00_Common/router"; import type { Row } from "./M01_MasterData_Api_Fetch"; import { addRow, editRow, peek, removeAdd, setAdd, toggleDelete } from "./M01_MasterData_Draft"; @@ -96,22 +97,19 @@ async function build(host: HTMLElement, opts: MachineDetailOptions, alive: () => name: text(r["이름"]), spec: text(r["규격"]), tag: text(r["상세구분"] || r["구분"]), - values: (r["값"] ?? {}) as Row, + row: { 이름: r["이름"], 규격: r["규격"], 값: (r["값"] ?? {}) as Row }, }; }); }; const notice = el("div"); - const form = createMachineForm({ - name: text(cur["이름"]), - spec: text(cur["규격"]), - values: (cur["값"] ?? {}) as Row, - coefMode: "pumsem", - fuels: lists.fuels, - jobs: lists.jobs, - power: lists.power, - hour: lists.hour, - findSimilar, + const form = createMachineSheet({ + orig: (orig ?? cur) as Row, + cur, + mine: false, + readOnly: false, + lists, + findSimilar: isNew ? findSimilar : undefined, onSimilar: (item) => { const r = found.get(item.key); if (!r || !isNew) return; @@ -131,7 +129,7 @@ async function build(host: HTMLElement, opts: MachineDetailOptions, alive: () => ]; notice.replaceChildren(...errors.map((e) => createText(e, "error"))); if (errors.length) return; - const row: Row = { ...base, ...cur, 이름: s.name, 규격: s.spec, 값: s.values }; + const row: Row = { ...base, ...cur, 이름: s.name, 규격: s.spec, 값: s.row.값 ?? {} }; for (const m of META) { if (orig && meta[m] === metaOrig[m]) { if (m in orig) row[m] = orig[m]; @@ -305,8 +303,6 @@ async function build(host: HTMLElement, opts: MachineDetailOptions, alive: () => }), ); paintFormula(); - if (opts.target === "new") - host.querySelector("details")?.setAttribute("open", ""); } /** 새 기계 바탕 — 칸 차례는 기계 줄과 같음 · 단위 · 출처는 서버가 채움 */ diff --git a/M02_MasterTemplete/M02_MyMachine.py b/M02_MasterTemplete/M02_MyMachine.py index 1992dcd6a..4d8f2f673 100644 --- a/M02_MasterTemplete/M02_MyMachine.py +++ b/M02_MasterTemplete/M02_MyMachine.py @@ -349,6 +349,26 @@ def _user() -> str: return user +def not_m01(raw: Any, skip: str = "") -> None: + """화면 만들기 · 고치기(840-2) — 이름 · 규격이 M01 기계와 같으면 409(내 기계 = M01 에 없는 기계만 · + 불러오기 자동 생성은 이 검사 없이 `create`).""" + raw = raw if isinstance(raw, dict) else {} + if any(h["출처"] == "M01" for h in same(raw.get("이름"), raw.get("규격"), skip)): + raise _bad( + "M01 에 있는 기계 — 값은 단가산출 안에서 고침 · 내 기계는 M01 에 없는 기계만", 409 + ) + + +def create_own(raw: Any = None, base: str | None = None) -> dict[str, Any]: + not_m01(raw) + return create(raw, base) + + +def update_own(key: str, raw: Any) -> dict[str, Any]: + not_m01(raw, key) + return update(key, raw) + + def create(raw: Any = None, base: str | None = None) -> dict[str, Any]: _user() seed = _seed(base) if base else {} diff --git a/M02_MasterTemplete/M02_MyMachine_Api.ts b/M02_MasterTemplete/M02_MyMachine_Api.ts index 7abc91b76..93fdb88f3 100644 --- a/M02_MasterTemplete/M02_MyMachine_Api.ts +++ b/M02_MasterTemplete/M02_MyMachine_Api.ts @@ -5,7 +5,7 @@ * 양식 목록 · 비슷한 기계 찾기 · 내 품목 = `M02_MyMachine_Look`(읽기만) * ========================================================================== */ -import type { MachinePick, MachineValues } from "@ui/ui_template_machine_form"; +import type { MachinePick, MachineValues } from "../common_util/common_util_machine_sheet"; import type { DocRecord } from "../common_util/common_util_formula_doc_cache"; import { call } from "./M02_MasterTemplete_Api_Fetch"; import type { M01Element } from "./M02_MasterTemplete_Find_Pick"; diff --git a/M02_MasterTemplete/M02_MyMachine_Form.ts b/M02_MasterTemplete/M02_MyMachine_Form.ts index ff48de821..6eeab3f11 100644 --- a/M02_MasterTemplete/M02_MyMachine_Form.ts +++ b/M02_MasterTemplete/M02_MyMachine_Form.ts @@ -1,26 +1,26 @@ /* ============================================================================= * M02_MyMachine_Form.ts - * M02 내 기계 양식 한 장(PLAN 821-3 · 요구서 796 1 · 3 장) — 기계 양식 부품(`ui_template_machine_form` · 821-2) + - * [저장] · [지우기] · 「개인 값」 표지 · 같은 이름 · 규격 알림 · 서버 정본 시간당 · 쓰는 곳. - * 기본 = 손료계수 「바로 넣기」 · 「시간당 값 직접」 꼴 · 직종 · 연료 = M01 목록 고르기 · 옆 계산식 즉시(계수 짝 TS) + * M02 내 기계 한 대(PLAN 821-3 · 840-2 · 요구 862) — 기계 보기(`A00_Common/line_stack_machine_sheet` · M01 기계 상세와 + * 같은 꼴: 왼쪽 제어 구역 + 기본 정보 · 손료 근거 · 오른쪽 시간당 경비 산출근거) + [저장] · [지우기] · 「개인 값」 표지 · + * 같은 이름 · 규격 알림 · 서버 정본 시간당 · 쓰는 곳. + * 내 기계 = M01 에 없는 기계를 등록하는 자리 — 이름 · 규격이 M01 기계와 같으면 알림 + 서버가 저장 막음 · + * M01 기계 값 고침은 단가산출 안 기계 제어 구역(836-1) · 「복사해 내 기계로」 걷음(840-2) * [저장] 때만 서버(자동저장 없음) — 서버가 정본으로 다시 셈한 시간당을 받아 보임 - * 새 기계 = 「비슷한 기계에서 시작」(M01 · 내 기계 · 고른 키 = 서버 `본뜬`) 또는 빈 양식 · `seed` = 품셈 기계 복사 + * 새 기계 = 「비슷한 기계에서 시작」(M01 · 내 기계 · 고른 키 = 서버 `본뜬`) 또는 빈 줄 * 지우기 = 쓰는 단가산출 이름 보이고 확인 + 「이미 만든 단가산출은 마지막 값 유지」 - * 부르는 곳 = 내 기계 화면(`M02_MyMachine_View` · 827-1 = 읽기 전용 · 지우기만) — 만들기 · 고치기는 - * 단가산출 기계 창(`A00_Common/line_stack_machine` · 초안 → 단가산출 [저장]) * ========================================================================== */ import { createBadge } from "@ui/ui_template_badge"; import { createButton } from "@ui/ui_template_button"; import { el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; -import { - createMachineForm, - HOURLY_KEY, - type MachineFormState, - type MachineSimilar, - type MachineValues, -} from "@ui/ui_template_machine_form"; +import { t as L } from "@ui/ui_template_locale"; import { createText } from "@ui/ui_template_text"; +import { + createMachineSheet, + type MachineSheetState, + type MachineSimilar, +} from "../A00_Common/line_stack_machine_sheet"; +import type { MachineRow } from "../common_util/common_util_machine_sheet"; import { createMyMachine, deleteMyMachine, @@ -29,7 +29,6 @@ import { readMyMachine, updateMyMachine, type MyHourly, - type MyMachine, type MyMachineBody, type MyMachineOne, type SimilarRow, @@ -41,12 +40,6 @@ export interface MyMachineFormOptions { owner: string | null; /** 고칠 내 기계 키 — 없으면 새 기계 */ key?: string; - /** 새 기계 바탕(복사해 내 기계로) — 값 · 이름 · 규격 · 본뜬 키 */ - seed?: SimilarRow; - /** 새 기계면 「비슷한 기계에서 시작」 을 펼침 */ - openSimilar?: boolean; - /** 827-1 보기만(양식 읽기 전용 · [저장] 없음 · [지우기] 그대로) */ - readOnly?: boolean; onSaved: (got: MyMachineOne) => void; onDeleted?: () => void; onBack?: () => void; @@ -60,29 +53,32 @@ export interface MyMachineFormHandle { const won = (v: number | undefined): string => `${(v ?? 0).toLocaleString("ko-KR", { maximumFractionDigits: 2 })}${mt("Won")}`; +const RATIOS = ["내용시간", "연간표준가동시간", "상각비율", "정비비율", "연간관리비율"]; -/** 줄(저장 꼴) → 양식 값 묶음 — 직접 = 시간당 칸 하나 */ -export function valuesOf(row: Pick): MachineValues { - return row.방식 === "직접" ? { [HOURLY_KEY]: { ...(row.시간당 ?? {}) } } : { ...row.값 }; +/** 찾은 줄 → 내 기계 줄 꼴(M01 줄 = 비율 다섯 있으면 품셈 · 없으면 바로 — 손료계수 = 계수 셋 합) */ +export function rowOfSimilar(r: SimilarRow): MachineRow { + if (r.출처 === "개인") return { 이름: r.이름, 규격: r.규격, 방식: r.방식 ?? "바로", 값: { ...(r.값 ?? {}) }, 시간당: r.시간당 }; // prettier-ignore + const v = { ...(r.값 ?? {}) }; + const how = RATIOS.every((k) => typeof v[k] === "number" && v[k]) ? "품셈" : "바로"; + if (how === "바로" && typeof v.손료계수 !== "number") + v.손료계수 = ["상각비계수", "정비비계수", "관리비계수"].reduce( + (s, k) => s + (Number(v[k]) || 0), + 0, + ); + return { 이름: r.이름, 규격: r.규격, 방식: how, 값: v }; } -/** 양식 상태 → 보낼 몸(틀림 = 글 목록) — 내 기계 양식 · 기계 창 [확인] 한 벌 */ -export function bodyOfForm(s: MachineFormState): { body: MyMachineBody | null; errors: string[] } { +/** 보기 상태 → 보낼 몸(틀림 = 글 목록) */ +export function bodyOfSheet(s: MachineSheetState): { + body: MyMachineBody | null; + errors: string[]; +} { const errors = [...s.errors, ...(s.name ? [] : [mt("Name_Needed")])]; if (errors.length) return { body: null, errors }; - if (s.hourly) - return { - body: { - 이름: s.name, - 규격: s.spec, - 방식: "직접", - 시간당: (s.values[HOURLY_KEY] ?? {}) as MyMachineBody["시간당"], - }, - errors, - }; - const { [HOURLY_KEY]: _drop, ...vals } = s.values; - const how = s.mode === "pumsem" ? "품셈" : "바로"; - return { body: { 이름: s.name, 규격: s.spec, 방식: how, 값: vals }, errors }; + const head = { 이름: s.name, 규격: s.spec }; + if (s.mode === "직접") + return { body: { ...head, 방식: "직접", 시간당: (s.row.시간당 ?? {}) as MyMachineBody["시간당"] }, errors }; // prettier-ignore + return { body: { ...head, 방식: s.mode, 값: s.row.값 ?? {} }, errors }; } /** 서버 시간당 한 줄 글 */ @@ -100,41 +96,30 @@ export async function mountMyMachineForm( opt.key ? readMyMachine(opt.owner, opt.key) : Promise.resolve(null), ]); const row = one?.줄; - const seed = opt.seed; - let base = seed && seed.출처 === "M01" ? seed.키 : undefined; // 서버 `본뜬` - const found = new Map(); + let base: string | undefined; // 서버 `본뜬` const notice = el("div", { className: "m02-mymach__notice" }); const server = el("div", { className: "m02-mymach__server" }); const usesBox = el("div", { className: "m02-mymach__uses" }); + const start: MachineRow = row + ? { 키: row.키, 원문번호: row.원문번호, 이름: row.이름, 규격: row.규격, 방식: row.방식, 값: row.값, ...(row.시간당 ? { 시간당: row.시간당 } : {}) } // prettier-ignore + : { 이름: "", 규격: "", 방식: "바로", 값: {} }; - const form = createMachineForm({ - name: row?.이름 ?? seed?.이름 ?? "", - spec: row?.규격 ?? seed?.규격 ?? "", - values: row - ? valuesOf(row) - : seed - ? valuesOf({ 방식: seed.방식 ?? "바로", 값: seed.값 ?? {}, 시간당: seed.시간당 }) - : {}, - coefMode: "direct", - allowHourly: true, - fuels: lists.fuels, - jobs: lists.jobs, - power: lists.power, - hour: lists.hour, - readOnly: opt.readOnly, + let m01Same = false; + const sheet = createMachineSheet({ + orig: start, + mine: true, + readOnly: false, + lists, findSimilar: row ? undefined : async (q): Promise => - (await findSimilar(opt.owner, q)).map((r) => { - found.set(r.키, r); - return { - key: r.키, - name: r.이름, - spec: r.규격, - tag: r.출처 === "개인" ? mt("Mine") : r.구분, - values: valuesOf({ 방식: r.방식 ?? "바로", 값: r.값 ?? {}, 시간당: r.시간당 }), - }; - }), + (await findSimilar(opt.owner, q)).map((r) => ({ + key: r.키, + name: r.이름, + spec: r.규격, + tag: r.출처 === "개인" ? mt("Mine") : r.구분, + row: rowOfSimilar(r), + })), onSimilar: (item) => { base = item.key; checkSame(); @@ -146,42 +131,42 @@ export async function mountMyMachineForm( }, }); - let saved = !opt.key && seed ? false : true; - const dirty = (): boolean => form.state().changed || !saved; + let saved = true; + const dirty = (): boolean => sheet.state().changed || !saved; - /* --- 같은 이름 · 규격 알림(M01 · 내 기계) --- */ + /* --- 같은 이름 · 규격 알림(M01 = 막음 · 내 기계 = 알림) --- */ let timer: number | undefined; let seq = 0; const sameText = (hits: { 키: string; 출처: string }[]): string => hits.length - ? mt("Same").replace("{value}", hits.map((h) => `${h.출처} ${h.키}`).join(" · ")) + ? [ + ...(hits.some((h) => h.출처 === "M01") ? [L("MS_M01Same")] : []), + mt("Same").replace("{value}", hits.map((h) => `${h.출처} ${h.키}`).join(" · ")), + ].join(" — ") : ""; const checkSame = (): void => { window.clearTimeout(timer); timer = window.setTimeout(async () => { const mine = ++seq; - const s = form.state(); + const s = sheet.state(); const rows = s.name ? await findSimilar(opt.owner, s.name).catch(() => []) : []; if (mine !== seq) return; const hits = rows.filter( (r) => r.키 !== opt.key && r.이름.trim() === s.name && (r.규격 ?? "").trim() === s.spec, ); - form.setNotice(sameText(hits)); + m01Same = hits.some((h) => h.출처 === "M01"); + saveBtn.disabled = m01Same; + sheet.setNotice(sameText(hits)); }, 300); }; /* --- 저장 --- */ - const bodyOf = (): MyMachineBody | null => { - const { body, errors } = bodyOfForm(form.state()); - notice.replaceChildren(...errors.map((e) => createText(e, "error"))); - return body; - }; const saveBtn = createButton({ label: mt("Save"), variant: "filled", size: 28 }); saveBtn.dataset.mymachSave = ""; - saveBtn.hidden = !!opt.readOnly; saveBtn.addEventListener("click", async () => { - const body = bodyOf(); - if (!body) return; + const { body, errors } = bodyOfSheet(sheet.state()); + notice.replaceChildren(...errors.map((e) => createText(e, "error"))); + if (!body || m01Same) return; saveBtn.disabled = true; try { const got = opt.key @@ -196,7 +181,7 @@ export async function mountMyMachineForm( createText(error instanceof Error ? error.message : mt("Failed"), "error"), ); } finally { - saveBtn.disabled = false; + saveBtn.disabled = m01Same; } }); @@ -238,7 +223,11 @@ export async function mountMyMachineForm( server.replaceChildren( ...(one ? [createText(`${mt("Server_Hourly")} ${hourlyText(one.시간당)}`)] : []), ); - if (one?.같은이름.length) form.setNotice(sameText(one.같은이름)); + if (one?.같은이름.length) { + m01Same = one.같은이름.some((h) => h.출처 === "M01"); + saveBtn.disabled = m01Same; + sheet.setNotice(sameText(one.같은이름)); + } const uses = one?.쓰는곳 ?? []; usesBox.replaceChildren( el("strong", { text: `${mt("Uses")} ${uses.length}` }), @@ -261,19 +250,13 @@ export async function mountMyMachineForm( delBtn, ], }), - createText(mt(opt.readOnly ? "View_Note" : "Own_Note"), "muted"), + createText(mt("Own_Note"), "muted"), notice, server, - form.root, + sheet.root, ...(opt.key ? [usesBox] : []), ], }); host.replaceChildren(root); - if (!row && (opt.openSimilar ?? !seed)) - root.querySelector("details")?.setAttribute("open", ""); - if (seed) { - form.setNotice(mt("Copied").replace("{value}", `${seed.이름} ${seed.규격}`.trim())); - opt.onDirty?.(true); - } return { root, dirty }; } diff --git a/M02_MasterTemplete/M02_MyMachine_Look.py b/M02_MasterTemplete/M02_MyMachine_Look.py index e288f4955..4b5638572 100644 --- a/M02_MasterTemplete/M02_MyMachine_Look.py +++ b/M02_MasterTemplete/M02_MyMachine_Look.py @@ -2,12 +2,14 @@ GET `/api/m02/my-machine-lists` = 양식 고르기(연료 · 직종 · 전력 단가 · 시간환산) — M01 기계 화면과 같은 값(`M01_MasterData_Machine.lists` · 그 길은 시스템 관리자만). -GET `/api/m02/layers/{층}/my-machine-find?q=&owner=` = 「비슷한 기계에서 시작」 · - 「복사해 내 기계로」 — 그 층 내 기계(앞) + M01 기계(`M01_MasterData_Machine.find`) · +GET `/api/m02/layers/{층}/my-machine-find?q=&owner=` = 「비슷한 기계에서 시작」 · 같은 이름 알림 — + 그 층 내 기계(앞) + M01 기계(`M01_MasterData_Machine.find`) · q 가 M01 원문번호와 같으면 그 기계 먼저. GET `/api/m02/layers/{층}/my-items?owner=` = 내 품목 — 그 층 단가산출 수식 문서 재료비 줄 가운데 단가가 우리 자재에 안 이어진 것(단가 변수 연결 = 숫자 · 입력) · (이름 · 규격 · 단위 · 값) 묶음마다 쓰는 단가산출. +POST `/api/m02/machine-sheet?owner=` 몸 {줄, 본?, 덮음?, 개인?} = 기계 보기(840-2 · `M02_MyMachine_Sheet` · 쓰기 없음 · + 개인 = 그 개인 층 · 아니면 M01 만). 권한 = 내 기계 길과 같음(`M02_MyMachine_Router._folder` — 개인 층 · 본인 · 맡은 회사 관리자 읽기). ⚠ 등록은 `main.py`(로그인만). """ @@ -18,6 +20,7 @@ import json from typing import Any from fastapi import APIRouter, Depends +from pydantic import BaseModel from common_util.common_util_auth import verify_session from M01_MasterData import M01_MasterData_Store as m01 @@ -125,3 +128,21 @@ def get_items( layer: str, owner: str | None = None, session: Session = Depends(verify_session) ) -> dict: return {"rows": _call(_folder(session, layer, owner), items)} + + +class SheetBody(BaseModel): + 줄: dict[str, Any] + 본: dict[str, Any] | None = None + 덮음: dict[str, Any] | None = None + 개인: bool = False + + +@router.post("/machine-sheet") +def post_sheet( + body: SheetBody, owner: str | None = None, session: Session = Depends(verify_session) +) -> dict: + """기계 보기(840-2 · `M02_MyMachine_Sheet`) — 개인 = 내 기계(그 층) · 아니면 M01 기계만 · 쓰기 없음.""" + from M02_MasterTemplete import M02_MyMachine_Sheet as ms + + folder = _folder(session, "personal", owner) if body.개인 else store.FOLDER + return _call(folder, ms.sheet, body.줄, body.본, body.덮음, body.개인) diff --git a/M02_MasterTemplete/M02_MyMachine_Router.py b/M02_MasterTemplete/M02_MyMachine_Router.py index 45efc3d96..25a9c78fd 100644 --- a/M02_MasterTemplete/M02_MyMachine_Router.py +++ b/M02_MasterTemplete/M02_MyMachine_Router.py @@ -1,7 +1,7 @@ """M02 내 기계 길(PLAN 821-1 · `M02_MyMachine`) — `/api/m02/layers/personal/my-machines`(?owner=). GET 목록(줄마다 시간당 · 쓰는곳수) · GET `/{키}` 하나(+시간당 · 쓰는곳 · 같은이름) · -POST 만들기 몸 {줄?, 본뜬?: M01 · 내 기계 키(「비슷한 기계에서 시작」 · 「M01 기계 복사해 만들기」)} · +POST 만들기 몸 {줄?, 본뜬?: M01 · 내 기계 키(「비슷한 기계에서 시작」)} · 만들기 · 고치기 = M01 과 같은 이름 · 규격 409(840-2) · PUT `/{키}` 몸 {줄} · DELETE `/{키}`(답 = 쓰는곳 — 그 단가산출은 사본의 마지막 값 그대로) · POST `/draft` 몸 {줄, 키?, 본뜬?} = 단가산출 기계 창 [확인](827-1 · 쓰기 없음 · 층 쓰기 권한) → {줄, 셈값, 시간당}. 권한 = 층 권한 한 벌(`Router_Items._root` — 본인 개인 층 · 맡은 회사 관리자) · 개인 층만(그 밖 404). @@ -66,7 +66,7 @@ def my_machine_create( owner: str | None = None, session: Session = Depends(verify_session), ): - return _call(_folder(session, layer, owner, True), mym.create, body.줄, body.본뜬) + return _call(_folder(session, layer, owner, True), mym.create_own, body.줄, body.본뜬) @router.put(PATH + "/{key}") @@ -77,7 +77,7 @@ def my_machine_update( owner: str | None = None, session: Session = Depends(verify_session), ): - return _call(_folder(session, layer, owner, True), mym.update, key, body.줄) + return _call(_folder(session, layer, owner, True), mym.update_own, key, body.줄) @router.delete(PATH + "/{key}") diff --git a/M02_MasterTemplete/M02_MyMachine_Sheet.py b/M02_MasterTemplete/M02_MyMachine_Sheet.py new file mode 100644 index 000000000..fe0b254ea --- /dev/null +++ b/M02_MasterTemplete/M02_MyMachine_Sheet.py @@ -0,0 +1,90 @@ +"""기계 보기 서버 몫(PLAN 840-2 · 요구 853 · 862) — M01 기계 상세 · 내 기계가 같이 씀 · 쓰기 없음. + +`sheet(줄, 본, 덮음, 개인)` → {키, 이름, 까닭, 문서, 값, 연결, 직종} +- 식 고름 = `M02_Machine_Hourly.pick`(지금 줄 · 내 기계 = 셈 꼴 `M02_MyMachine.engine`) · 못 고름 = 키 없음 + 까닭 +- 문서 = 그 식 수식 문서(그대로) · 값 = `본`(처음 줄 · 없으면 줄)의 셈 꼴 값 칸 — 셈 차례 = 덮음(`기계덮음` 꼴 · + 836-1) 얹음 → 값 박음(`bind` — 남은 요소 `EQ:{S_기계}` 칸 → 숫자 · 저장 전 새 기계 · 내 기계도 같은 계산기로 셈) +- 파이썬 · TS 짝 — `bind` ↔ `common_util/common_util_machine_sheet.ts` `bindDoc` · 거울 시험 test_840_2_machine_sheet.py +- 연결 = 덮음 얹은 문서 셈이 쓴 요소 · 표 · 문서(`master_doc_record`) — 화면이 같은 계산기로 즉시 다시 셈 +- 직종 = 노임 요소 코드 → 인력 원문번호(조종원 줄 → 기계 줄 `조종원직종`) +""" + +from __future__ import annotations + +import copy +import json +from decimal import Decimal +from typing import Any + +from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_MyMachine as mym + + +def _plain(v: Any) -> Any: + if isinstance(v, Decimal): + return int(v) if v == v.to_integral_value() else float(v) + return v + + +def bind(doc: dict, vals: dict) -> dict: + """식 문서 → 기계 값 칸을 숫자로 박은 새 문서(그 칸이 없는 줄은 요소 그대로).""" + out = copy.deepcopy(doc) + for v in out.get("변수") or []: + link = v.get("연결") or {} + key = str(link.get("키") or "") + col = link.get("칸") + if ( + link.get("갈래") == "요소" + and key.startswith("EQ:") + and "{S_기계}" in key + and col in vals + ): + v["연결"] = {"갈래": "숫자", "값": vals[col]} + return out + + +def _shape(row: dict, mine: bool) -> dict: + if mine: + return mym.engine(row) + return {**row, "값": m01._dec(row.get("값") or {})} + + +def _jobs() -> dict[str, str]: + rows = (m01.loaded()[0].get("인력.json") or {}).get("줄") or [] + return {str(r["키"]): str(r.get("원문번호")) for r in rows if r.get("키")} + + +def sheet(row: Any, base: Any = None, cover: Any = None, mine: bool = False) -> dict: + import master_doc_calc as mc + import master_doc_cover as mcv + import master_doc_link as ml + import master_doc_record as mr + + from M01_MasterData import M01_MasterData_Doc as m01doc + from M02_MasterTemplete import M02_Machine_Hourly as mh + + if not isinstance(row, dict): + raise m01.StoreError(400, "기계 줄이 묶음 아님") + cur = _shape(row, mine) + logic = mh.pick(cur) + out: dict[str, Any] = {"직종": _jobs()} + doc = m01doc.docs().get(str(logic["키"])) if logic else None + if doc is None: + why = mh.why(cur, logic) or ("식 문서 없음" if logic else "식 없음") + return out | {"키": None, "까닭": why, "문서": None, "값": {}, "연결": {}} + vals = _shape(base if isinstance(base, dict) else row, mine).get("값") or {} + no = str(row.get("원문번호") or row.get("키") or "새 기계") + given = {"기계": no} + entry = cover if isinstance(cover, dict) and cover else None + try: + covered = mcv.cover_doc(doc, given, mcv.clean({no: m01._dec(entry)})) if entry else doc + except ValueError as e: + raise m01.StoreError(400, str(e)) from None + shaped = bind(covered, vals) + links = ml.Links(mym.whole(), m01doc.docs()) + rec = mr.Rec(links) + rec.prefetch(shaped) + mc.calc(shaped, rec, given) + got = {"키": logic["키"], "이름": logic.get("이름"), "까닭": mh.why(cur, logic), "문서": doc, + "값": vals, "연결": rec.out()} # fmt: skip + return json.loads(json.dumps(out | got, ensure_ascii=False, default=_plain)) diff --git a/M02_MasterTemplete/M02_MyMachine_Text.ts b/M02_MasterTemplete/M02_MyMachine_Text.ts index b4f313ab9..2461e0a48 100644 --- a/M02_MasterTemplete/M02_MyMachine_Text.ts +++ b/M02_MasterTemplete/M02_MyMachine_Text.ts @@ -18,7 +18,7 @@ const TEXT = { Uses_Count: "쓰는 곳", Made: "넣은 날", Edited: "고친 날", - None: "내 기계 없음 — 단가산출의 기계 줄에서 [고쳐 쓰기] 로 만듦", + None: "내 기계 없음 — [새 기계] 로 M01 에 없는 기계를 등록", Save: "저장", Saved: "내 기계 저장 — 서버가 시간당을 다시 셈", Delete: "지우기", @@ -50,7 +50,8 @@ const TEXT = { // 기계 고르기 창 · 줄 쌓기 Pick_Group: "내 기계", Pick_Direct: "시간당 경비 식이 없는 기계 — 줄 쌓기에 못 넣음", - View_Note: "보기 · 쓰는 곳 · 지우기만 — 만들기 · 고치기는 단가산출의 기계 줄에서", + View_Note: + "M01 에 없는 기계를 등록하는 자리 · 줄 누름 = 열고 고침 · M01 기계 값은 단가산출 안 기계 제어 구역에서 고침", // 단가산출 기계 창(827-1) Win_Title: "기계", Win_Kind: "구분", diff --git a/M02_MasterTemplete/M02_MyMachine_View.ts b/M02_MasterTemplete/M02_MyMachine_View.ts index d0dcd6a0f..7c777a8a2 100644 --- a/M02_MasterTemplete/M02_MyMachine_View.ts +++ b/M02_MasterTemplete/M02_MyMachine_View.ts @@ -2,11 +2,12 @@ * M02_MyMachine_View.ts * M02 「내 기계」 화면(PLAN 821-3 · 요구서 796 3 장) — 좌측 「개인관리」 › [기계] 단추로 가운데에 뜸(개인 층). * 목록 = 이름 · 규격 · 방식 · 시간당 경비(서버 셈) · 쓰는 곳 수 · 넣은 날 · 고친 날 · 찾기 - * 줄 누름 = 양식 한 장(`M02_MyMachine_Form` · 읽기 전용) · 쓰는 곳 · [지우기] → 목록 다시 - * 만들기 · 고치기 없음(827-1) — 단가산출 기계 줄의 기계 창 [고쳐 쓰기] → 단가산출 [저장] 때 내 기계로 + * 줄 누름 = 기계 보기 한 장(`M02_MyMachine_Form` · 840-2 M01 기계 상세와 같은 꼴) · 고침 · [저장] · 쓰는 곳 · [지우기] + * [새 기계] = M01 에 없는 기계 등록(비슷한 기계에서 시작 · M01 과 같은 이름 · 규격 = 막음) · 불러오기 자동 생성은 그대로 * ========================================================================== */ import { createDataTable } from "@ui/ui_template_data_table"; +import { createButton } from "@ui/ui_template_button"; import { createInputField, el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; import { createText } from "@ui/ui_template_text"; import { listMyMachines, type MyMachineRow } from "./M02_MyMachine_Api"; @@ -71,7 +72,20 @@ export function buildMyMachineView(opt: MyMachineViewOptions): HTMLElement { const list = el("div", { className: "m02-mymach-view__list", children: [ - el("div", { className: "ui-toolbar", children: [search.root, hint] }), + el("div", { + className: "ui-toolbar", + children: [ + search.root, + hint, + createButton({ + label: mt("New"), + variant: "filled", + size: 28, + iconStart: "plus", + onClick: () => void openForm(), + }), + ], + }), createText(mt("View_Note"), "muted"), fail, table.root, @@ -94,14 +108,13 @@ export function buildMyMachineView(opt: MyMachineViewOptions): HTMLElement { draw(); } - async function openForm(key: string): Promise { + async function openForm(key?: string): Promise { const slot = el("div"); host.replaceChildren(slot); try { const h = await mountMyMachineForm(slot, { owner: opt.owner, key, - readOnly: true, onSaved: (got) => void openForm(got.줄.키), onDeleted: () => void showList(), onBack: async () => { diff --git a/common_util/common_util_machine_sheet.ts b/common_util/common_util_machine_sheet.ts new file mode 100644 index 000000000..8ca59f4bf --- /dev/null +++ b/common_util/common_util_machine_sheet.ts @@ -0,0 +1,185 @@ +/* ============================================================================= + * common_util_machine_sheet.ts + * 기계 보기 셈 꼴(PLAN 840-2 · 요구 853 · 862) — 기계 줄(저장 꼴) ↔ 식이 읽는 값 · 덮음(836-1 `기계덮음` 꼴) ↔ 줄 + * 셈 꼴(`engineVals`) = M01 = 값 그대로 · 내 기계 = 바로 → 상각비계수 ← 손료계수 · 정비 · 관리 0 · + * 직접 → 노무비 · 재료비 · 경비 · 빈 연료 · 조종원 칸 = 0(서버 `M02_MyMachine.engine` 과 같은 꼴) + * 덮음 ← 줄 차이(`coverFrom` — 처음 줄 대 지금 줄) · 지금 줄 ← 덮음(`applyCover` — 제어 구역이 맡은 칸만 · + * 덮음이 없는 칸 = 처음 줄 값 그대로 = 안 고치고 닫으면 줄 바이트 같음) + * 제어 구역이 맡은 칸(`ownedCols`) = 식이 읽는 값 칸 + 조종원 · 조종원직종 + (잡품 줄 있는 식) 잡재료율 + * 값 박음(`bindDoc`) = 식 문서 요소 `EQ:{S_기계}` 칸 → 숫자(처음 줄 값) — 셈 차례 = 덮음 얹음(`coverDoc`) → 값 박음 + * 파이썬 · TS 짝(`bindDoc`) — 짝 = M02_MasterTemplete/M02_MyMachine_Sheet.py `bind` · 거울 시험 test_840_2_machine_sheet.py + * · 화면 · DOM · fetch 없음 + * ========================================================================== */ + +import type { FormulaDoc, MoneyLine } from "./common_util_formula_doc"; +import type { CoverParts, MachineCover } from "./common_util_formula_doc_cover"; +import { MACHINE_INPUTS } from "./common_util_machine_coef"; + +export type MachineValues = Record; +/** 고르기 목록 한 칸 — 연료(값 = 종류) · 직종(값 = 인력 원문번호) */ +export interface MachinePick { + value: string; + text: string; + price: number | null; +} +export type MachineMode = "바로" | "품셈" | "직접"; +/** 기계 줄(M01 기계 줄 · 내 기계 줄 꼴) — 이름 · 규격 · 값 · (내 기계) 방식 · 시간당 */ +export interface MachineRow { + 이름?: unknown; + 규격?: unknown; + 값?: MachineValues; + 방식?: MachineMode; + 시간당?: MachineValues; + [k: string]: unknown; +} + +export const HOURLY = ["노무비", "재료비", "경비"] as const; +const CREW = ["조종원", "조종원직종"]; +const MISC = "잡재료율"; + +/** 보통 범위(옛 기계 양식과 같음) — 밖이면 경고만 */ +export const RANGE: Record = { + 가격: [0, 5_000_000], 손료계수: [1, 100_000], 내용시간: [100, 100_000], 연간표준가동시간: [100, 8_760], + 상각비율: [0, 1], 정비비율: [0, 3], 연간관리비율: [0, 1], 주연료: [0, 2_000], 잡재료율: [0, 100], + 조종원: [0, 20], 전력: [0, 10_000], +}; // prettier-ignore + +const clone = (v: T): T => JSON.parse(JSON.stringify(v ?? null)) as T; +const blank = (v: unknown): boolean => v === undefined || v === null || v === ""; + +/** 식 문서 → 기계 값 칸을 숫자로 박은 새 문서(그 칸이 없는 줄은 요소 그대로) */ +export function bindDoc(doc: FormulaDoc, vals: MachineValues): FormulaDoc { + const out = clone(doc); + for (const v of out.변수 ?? []) { + const l = v.연결 ?? {}; + const key = String(l.키 ?? ""); + const col = l.칸; + if ( + l.갈래 === "요소" && + key.startsWith("EQ:") && + key.includes("{S_기계}") && + col && + col in vals + ) + v.연결 = { 갈래: "숫자", 값: vals[col] as number | string }; + } + return out; +} + +/** 같은 값 — 수는 작은 차이 무시 · 글은 글로 */ +export function sameVal(a: unknown, b: unknown): boolean { + if (blank(a) && blank(b)) return true; + if (typeof a === "number" && typeof b === "number") + return Math.abs(a - b) <= 1e-9 * Math.max(1, Math.abs(a), Math.abs(b)); + return String(a ?? "") === String(b ?? ""); +} + +export function modeOf(row: MachineRow, mine: boolean): MachineMode { + if (mine) return row.방식 ?? "바로"; + return MACHINE_INPUTS.some((k) => typeof row.값?.[k] === "number") ? "품셈" : "바로"; +} + +/** 식이 읽는 값 묶음(셈 꼴) */ +export function engineVals(row: MachineRow, mine: boolean): MachineValues { + const v: MachineValues = { ...(row.값 ?? {}) }; + if (!mine) return v; + const how = row.방식 ?? "바로"; + if (how === "직접") return Object.fromEntries(HOURLY.map((k) => [k, row.시간당?.[k] ?? 0])); + if (how === "바로") + Object.assign(v, { 상각비계수: v.손료계수 || 0, 정비비계수: 0, 관리비계수: 0 }); + return { 주연료: 0, 잡재료율: 0, 조종원: 0, 주연료종류: "", 조종원직종: "", ...v }; +} + +/** 셈 꼴 칸 → 저장 꼴 자리 · null = 저장 안 함(내 기계 바로의 정비 · 관리 = 늘 0) */ +export function slotOf( + col: string, + mode: MachineMode, + mine: boolean, +): ["값" | "시간당", string] | null { + if (!mine) return ["값", col]; + if (mode === "직접") return (HOURLY as readonly string[]).includes(col) ? ["시간당", col] : null; + if (mode === "바로" && col === "상각비계수") return ["값", "손료계수"]; + if (mode === "바로" && (col === "정비비계수" || col === "관리비계수")) return null; + return ["값", col]; +} + +/** 제어 구역이 맡은 칸(셈 꼴 이름) */ +export const ownedCols = (parts: CoverParts): string[] => [ + ...parts.fields.map((f) => f.칸), + ...CREW, + ...(parts.misc !== null ? [MISC] : []), +]; + +/** 처음 줄 대 지금 줄 → 덮음(조종원 = 「LB:직종」 한 줄 · 잡품 = 밑 줄마다 같은 율) — 정리는 `putCover` 몫 */ +export function coverFrom( + parts: CoverParts, + lines: MoneyLine[], + orig: MachineRow, + cur: MachineRow, + mine: boolean, +): MachineCover { + const o = engineVals(orig, mine); + const c = engineVals(cur, mine); + const out: MachineCover = {}; + const 값: Record = {}; + for (const { 칸 } of parts.fields) + if (!blank(c[칸]) && !sameVal(c[칸], o[칸])) 값[칸] = c[칸] as number | string; + if (Object.keys(값).length) out.값 = 값; + if (CREW.some((k) => !sameVal(c[k], o[k]))) { + const n = Number(c.조종원) || 0; + out.조종원 = n > 0 && !blank(c.조종원직종) ? [{ 키: `LB:${String(c.조종원직종)}`, 인: n }] : []; + } + if (parts.misc !== null && !sameVal(c[MISC], o[MISC])) { + const r = Number(c[MISC]) || 0; + out.잡품 = r ? parts.base.map((i) => ({ 줄: String(lines[i]?.이름 ?? ""), 율: r })) : []; + } + return out; +} + +/** 노임 요소 키 → 인력 원문번호(`LB:1048` → 1048 · `LB000048` → 직종 표) */ +export const jobCode = (key: string, jobs: Record): string => + key.startsWith("LB:") ? key.slice(3) : (jobs[key] ?? key); + +/** 덮음 → 지금 줄(제어 구역이 맡은 칸만 · 덮음 없는 칸 = 처음 줄 저장 값으로) — 새 줄 */ +export function applyCover( + parts: CoverParts, + orig: MachineRow, + cur: MachineRow, + entry: MachineCover, + mine: boolean, + jobs: Record, +): MachineRow { + const mode = modeOf(cur, mine); + const next = clone(cur); + const put = (col: string, v: unknown): void => { + const at = slotOf(col, mode, mine); + if (!at) return; + const [part, key] = at; + const box = (next[part] ??= {}) as MachineValues; + if (v === undefined) { + const was = (orig[part] ?? {}) as MachineValues; + if (key in was) box[key] = clone(was[key]); + else delete box[key]; + } else if (!(key in box) || !sameVal(box[key], v)) box[key] = v; + }; + for (const { 칸 } of parts.fields) put(칸, entry.값?.[칸]); + if (entry.조종원 === undefined) CREW.forEach((k) => put(k, undefined)); + else { + const r = entry.조종원[0]; + put("조종원", r ? r.인 : 0); + put("조종원직종", r ? jobCode(r.키, jobs) : undefined); + } + if (parts.misc !== null) + put(MISC, entry.잡품 === undefined ? undefined : (entry.잡품[0]?.율 ?? 0)); + return next; +} + +/** 범위 밖 칸 — [칸, 범위 글] */ +export function rangeWarnings(values: MachineValues): [string, string][] { + return Object.entries(RANGE).flatMap(([k, [lo, hi]]) => { + const v = values[k]; + return typeof v === "number" && (v < lo || v > hi) + ? [[k, `${lo}~${hi}`] as [string, string]] + : []; + }); +} diff --git a/resources/tester/helper_840_2_machine_sheet.cjs b/resources/tester/helper_840_2_machine_sheet.cjs new file mode 100644 index 000000000..e83f17167 --- /dev/null +++ b/resources/tester/helper_840_2_machine_sheet.cjs @@ -0,0 +1,35 @@ +/* 840-2 기계 보기 셈 꼴 헬퍼 — common_util_machine_sheet.ts · common_util_formula_doc_cover.ts 를 그 자리에서 트랜스파일해 Node 로 돌림. + * stdin = {문서들: {식 키: 식 문서}, 대상: [{식, 처음, 지금?, 덮음?, 개인, 직종?, 박음?: 값}]} + * stdout = [{덮음: coverFrom(처음, 지금 | 처음), 줄: applyCover(처음, 지금 | 처음, 덮음 | coverFrom), 박음?: bindDoc(식, 박음)}] */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const cache = new Map(); +function load(at) { + if (cache.has(at)) return cache.get(at); + const js = ts.transpileModule(fs.readFileSync(at, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(at, box.exports); + const need = (p) => + p.startsWith(".") ? load(path.resolve(path.dirname(at), `${p}.ts`)) : require(p); + new Function("exports", "module", "require", js)(box.exports, box, need); + return box.exports; +} +const root = path.join(__dirname, "..", "..", "common_util"); +const cover = load(path.join(root, "common_util_formula_doc_cover.ts")); +const sheet = load(path.join(root, "common_util_machine_sheet.ts")); + +const ask = JSON.parse(fs.readFileSync(0, "utf8")); +const out = ask.대상.map((t) => { + const doc = ask.문서들[t.식]; + const parts = cover.partsOf(doc); + const cur = t.지금 ?? t.처음; + const made = sheet.coverFrom(parts, doc.금액 ?? [], t.처음, cur, t.개인); + const use = t.덮음 ?? made; + const row = sheet.applyCover(parts, t.처음, cur, use, t.개인, t.직종 ?? {}); + return { 덮음: made, 줄: row, ...(t.박음 ? { 박음: sheet.bindDoc(doc, t.박음) } : {}) }; +}); +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_821_3_my_machine_screen.py b/resources/tester/test_821_3_my_machine_screen.py index 10039bce0..eb6f50add 100644 --- a/resources/tester/test_821_3_my_machine_screen.py +++ b/resources/tester/test_821_3_my_machine_screen.py @@ -111,7 +111,7 @@ def _stack_doc(key, no): def test_stack_line_copy(world): """⑤ 줄 쌓기로 고른 내 기계 — 미리 셈 됨 · 저장 때 값 사본에 박힘 → - 고쳐도 금액 그대로 + 단가새값 → [단가 갱신] 바뀐 자원 1 → 지워도 마지막 값 + 지운키.""" + 고치면 자동 따라옴(836-2 · 갱신 할 것 없음) → 지워도 저장한 사본 값 + 지운키.""" from M02_MasterTemplete import M02_Doc_Sk_Path as path from M02_MasterTemplete import M02_Labor_Ref as refs @@ -131,10 +131,10 @@ def test_stack_line_copy(world): copy = _at(me, lambda: store.read("sangeun", sk)["문서"]["수식"]) assert key in [r["키"] for r in copy["단가"]] # 값 사본에 박힘 _at(me, mym.update, key, {**made["줄"], "값": {**made["줄"]["값"], "가격": 3000}}) - now = _at(me, path.read, sk) - assert now["셈"]["계"] == was and now["사본"]["단가새값"] is True + now = _at(me, path.read, sk) # 836-2 프로젝트 밖 SK = 자동 따라옴(새 값 배지 · 갱신 할 것 없음) + assert now["셈"]["계"] > was and now["사본"]["자동"] and not now["사본"]["단가새값"] job = _at(me, path.preview, sk, {"할일": "단가갱신"}) - assert [x["키"] for x in job["바뀐자원"]] == [key] and job["후"]["계"] > was + assert job["바뀐자원"] == [] and job["후"]["계"] == now["셈"]["계"] _at(me, mym.delete, key) gone = _at(me, path.read, sk) assert gone["셈"]["계"] == was and gone["사본"]["지운키"] == [key] diff --git a/resources/tester/test_840_2_machine_sheet.py b/resources/tester/test_840_2_machine_sheet.py new file mode 100644 index 000000000..f9ae5bf88 --- /dev/null +++ b/resources/tester/test_840_2_machine_sheet.py @@ -0,0 +1,200 @@ +"""840-2 기계 보기 지킴 시험 — 값 박음 · 덮음 ↔ 기계 줄 · M01 같은 이름 막음 · 쓰기 없음. + +짝 = `M02_MasterTemplete/M02_MyMachine_Sheet.py` `bind` ↔ `common_util/common_util_machine_sheet.ts` `bindDoc` +(+ 덮음 ↔ 줄 `coverFrom` · `applyCover` — 화면 몫 · Node 로 돌림). +① M01 기계 655 대 전수 — 식 고름 → 값 박은 식 셈 = 기계 표 요소로 읽는 셈(노 · 재 · 경 · 계 같음) · 식 없음 = 까닭. +② 655 대 전수(TS) — 고침 없음 = 덮음 없음 · 줄 JSON 같음(열고 닫아도 M01 기계 파일 그대로) · 박음 거울. +③ 굴착기 1.0㎥ — 가격 · 조종원(2 인 · 직종 · 뺌) · 잡품 율 덮음 → 줄 칸 · 덮음 얹고 박은 셈 = 줄 고친 셈. +④ 내 기계 꼴 — 바로(상각비계수 → 손료계수) · 직접(시간당) · 처음 값으로 되돌리면 줄 같음. +⑤ M01 과 같은 이름 · 규격 = 409 · 다른 이름 = 통과(검사만 · 쓰기 없음). +""" + +import copy +import importlib +import json +import os +import subprocess +from decimal import Decimal as D + +import pytest + +from M01_MasterData import M01_MasterData_Store as m01 # scripts 길을 둠 +from M01_MasterData import M01_MasterData_Doc as m01doc +from M02_MasterTemplete import M02_Machine_Hourly as mh +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_MyMachine as mym +from M02_MasterTemplete import M02_MyMachine_Sheet as ms + +mc = importlib.import_module("master_doc_calc") +ml = importlib.import_module("master_doc_link") +mcv = importlib.import_module("master_doc_cover") + +HERE = os.path.dirname(os.path.abspath(__file__)) +COSTS = ("노무비", "재료비", "경비", "계") +DIG = "EQ000022" # 굴착기(무한궤도) 1.0㎥ 0201-0100 · GC000267 + + +def _plain(v): + return json.loads(json.dumps(v, ensure_ascii=False, default=ms._plain)) + + +@pytest.fixture(scope="module") +def world(): + """M01 기계 줄 · 식 고름 · 식 문서 — 한 번만(마스터 층 · 쓰기 없음).""" + with store.at(store.FOLDER): + rows = m01.loaded()[0]["기계.json"]["줄"] + docs = m01doc.docs() + links = ml.Links(m01.loaded()[1], docs) + picks = {} + for r in rows: + got = mh.pick({**r, "값": m01._dec(r.get("값") or {})}) + picks[r["키"]] = str(got["키"]) if got and str(got["키"]) in docs else None + yield {"rows": rows, "docs": docs, "links": links, "picks": picks} + + +def _node(payload): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_840_2_machine_sheet.cjs")], + input=json.dumps(_plain(payload), ensure_ascii=False), + capture_output=True, + text=True, + encoding="utf-8", + check=True, + ) + return json.loads(proc.stdout) + + +def _money(a): + return {k: a.get(k) for k in COSTS} + + +# ── ① 655 대 금액 ────────────────────────────────────────────────────── +def test_655대_박은_식_셈_같음(world): + links, docs = world["links"], world["docs"] + same = none = 0 + for r in world["rows"]: + key = world["picks"][r["키"]] + if key is None: + none += 1 + continue + given = {"기계": str(r.get("원문번호"))} + before = mc.calc(docs[key], links, given) + after = mc.calc(ms.bind(docs[key], m01._dec(r.get("값") or {})), links, given) + assert _money(after) == _money(before), (r["키"], key) + same += before["셈됨"] + assert len(world["rows"]) == 655 and same >= 600 and none <= 44 + + +def test_식_없는_기계_까닭(world): + with store.at(store.FOLDER): + for r in world["rows"]: + if world["picks"][r["키"]] is None: + got = ms.sheet(_plain(r)) + assert got["키"] is None and got["문서"] is None and got["까닭"] + return + pytest.skip("식 없는 기계 없음") + + +# ── ② 655 대 덮음 ↔ 줄(TS) ────────────────────────────────────────────── +def test_655대_고침_없음_줄_같음_박음_거울(world): + docs, targets, want = {}, [], [] + for r in world["rows"]: + key = world["picks"][r["키"]] + if key is None: + continue + docs[key] = docs.get(key) or _plain(world["docs"][key]) + bind = r["키"] in (DIG, "EQ000001", world["rows"][-1]["키"]) + targets.append({"식": key, "처음": _plain(r), "개인": False, + **({"박음": _plain(r["값"])} if bind else {})}) # fmt: skip + want.append(_plain(ms.bind(world["docs"][key], r["값"])) if bind else None) + got = _node({"문서들": docs, "대상": targets}) + for t, g, w in zip(targets, got, want): + assert g["덮음"] == {}, t["처음"]["키"] + assert json.dumps(g["줄"], ensure_ascii=False) == json.dumps(t["처음"], ensure_ascii=False) + if w is not None: + assert g["박음"] == w + + +# ── ③ 굴착기 덮음 → 줄 · 셈 ──────────────────────────────────────────── +def test_굴착기_덮음_줄_칸_셈(world): + row = _plain(next(r for r in world["rows"] if r["키"] == DIG)) + doc = world["docs"]["GC000267"] + given = {"기계": row["원문번호"]} + jobs = ms._jobs() + lb = next(k for k, v in jobs.items() if v == "1002") + cases = [ + ({"값": {"가격": 150000}}, {"가격": 150000}), + ({"조종원": [{"키": "LB:1048", "인": 2}]}, {"조종원": 2}), + ({"조종원": [{"키": lb, "인": 1}]}, {"조종원직종": "1002"}), + ({"조종원": []}, {"조종원": 0}), + ({"잡품": [{"줄": "주연료", "율": 30}]}, {"잡재료율": 30}), + ] + targets = [ + {"식": "GC000267", "처음": row, "덮음": c, "개인": False, "직종": jobs} for c, _ in cases + ] + got = _node({"문서들": {"GC000267": _plain(doc)}, "대상": targets}) + for (cover, change), g in zip(cases, got): + want = copy.deepcopy(row) + want["값"].update(change) + assert g["줄"] == want, cover + shaped = ms.bind( + mcv.cover_doc(doc, given, {row["원문번호"]: m01._dec(cover)}), m01._dec(row["값"]) + ) + edited = ms.bind(doc, m01._dec(g["줄"]["값"])) + a, b = mc.calc(shaped, world["links"], given), mc.calc(edited, world["links"], given) + assert a["셈됨"] and _money(a) == _money(b), cover + # 지금 줄(초안) → 덮음 — 처음 줄 대비 고친 칸만 + cur = copy.deepcopy(row) + cur["값"].update({"가격": 150000, "조종원": 2, "잡재료율": 30}) + made = _node({"문서들": {"GC000267": _plain(doc)}, + "대상": [{"식": "GC000267", "처음": row, "지금": cur, "개인": False}]})[0] # fmt: skip + assert made["덮음"] == { + "값": {"가격": 150000}, + "조종원": [{"키": "LB:1048", "인": 2}], + "잡품": [{"줄": "주연료", "율": 30}], + } + assert made["줄"] == cur + + +# ── ④ 내 기계 꼴 ─────────────────────────────────────────────────────── +def test_내기계_바로_직접(world): + easy = {"이름": "t", "규격": "", "방식": "바로", + "값": {"가격": 1000, "손료계수": 2000, "주연료": 5, "주연료종류": "경유"}} # fmt: skip + hour = {"이름": "t", "규격": "", "방식": "직접", "값": {}, + "시간당": {"노무비": 1, "재료비": 2, "경비": 3}} # fmt: skip + with store.at(store.FOLDER): + a, b = ms.sheet(easy, mine=True), ms.sheet(hour, mine=True) + assert a["키"] == "GC000272" and b["키"] == "GC001199" + assert a["값"]["상각비계수"] == 2000 and a["값"]["정비비계수"] == 0 + docs = {a["키"]: a["문서"], b["키"]: b["문서"]} + targets = [ + {"식": a["키"], "처음": easy, "개인": True}, + {"식": a["키"], "처음": easy, "개인": True, "덮음": {"값": {"상각비계수": 2500}}}, + {"식": b["키"], "처음": hour, "개인": True}, + {"식": b["키"], "처음": hour, "개인": True, "덮음": {"값": {"경비": 10}}}, + ] + got = _node({"문서들": docs, "대상": targets}) + assert got[0]["덮음"] == {} and got[0]["줄"] == easy + assert got[1]["줄"]["값"] == {**easy["값"], "손료계수": 2500} + assert got[2]["덮음"] == {} and got[2]["줄"] == hour + assert got[3]["줄"]["시간당"] == {"노무비": 1, "재료비": 2, "경비": 10} + + +# ── ⑤ M01 같은 이름 막음 ─────────────────────────────────────────────── +def test_M01_같은_이름_막음(world): + dig = next(r for r in world["rows"] if r["키"] == DIG) + with store.at(store.FOLDER): + with pytest.raises(store.StoreError) as e: + mym.not_m01({"이름": dig["이름"], "규격": dig["규격"]}) + assert e.value.status == 409 + mym.not_m01({"이름": "sub3임시 없는 기계", "규격": "1"}) + + +def test_서버_덮음_몸_틀림_400(): + with store.at(store.FOLDER), pytest.raises(m01.StoreError) as e: + ms.sheet({"원문번호": "0201-0100", "값": {}}, None, {"값": {"가격": -1}}) + assert e.value.status == 400 + + +def test_수_꼴(): + assert ms._plain(D("1.50")) == 1.5 and ms._plain(D("3")) == 3 diff --git a/ui_template/ui_template_locale_machine.ts b/ui_template/ui_template_locale_machine.ts index 3650252ae..3ac2833a4 100644 --- a/ui_template/ui_template_locale_machine.ts +++ b/ui_template/ui_template_locale_machine.ts @@ -1,6 +1,6 @@ /* ============================================================================= * ui_template_locale_machine.ts - * 기계 양식 부품(`ui_template_machine_form.ts`) · M01 기계 목록 · 상세 사전(PLAN 821-2) — 신규 문구는 최하단에. + * 기계 보기(`A00_Common/line_stack_machine_sheet.ts` · 840-2 · 옛 기계 양식 걷음) · M01 기계 목록 · 상세 사전(PLAN 821-2) — 신규 문구는 최하단에. * ========================================================================== */ export const ui_locales_machine = { @@ -78,4 +78,17 @@ export const ui_locales_machine = { M01_MachineErrors: ["고칠 칸이 있음", "Fix the marked fields"], M01_MachineDetailGroup: ["상세구분", "Sub-group"], M01_MachineSourceNo: ["원문번호", "Source no."], + // 기계 보기(840-2 · `A00_Common/line_stack_machine_sheet`) + MS_Base: ["기본 정보 · 손료 근거", "Basics · ownership basis"], + MS_Mode: ["손료 방식", "Ownership method"], + MS_Out: ["시간당 경비 산출근거", "Hourly cost breakdown"], + MS_NoFormula: ["시간당 경비 식 없음", "No hourly formula"], + MS_Loading: ["식 불러오는 중", "Loading formula"], + MS_Locked: ["품셈 방식 — 손료 근거 비율로 셈", "By standard — computed from the ratios"], + MS_Coefs: ["계수(비율로 셈)", "Coefficients (from ratios)"], + MS_M01Same: [ + "M01 에 있는 기계 — 값은 단가산출 안에서 고침 · 내 기계는 M01 에 없는 기계만", + "Already in M01 — change its values inside a unit-cost sheet · my machines are for machines not in M01", + ], + MS_Range: ["{name} 보통 범위 {value} 밖", "{name} outside usual range {value}"], } as const; diff --git a/ui_template/ui_template_machine_form.css b/ui_template/ui_template_machine_form.css deleted file mode 100644 index 7d8681fa8..000000000 --- a/ui_template/ui_template_machine_form.css +++ /dev/null @@ -1,112 +0,0 @@ -/* 기계 양식(821-2) — 왼쪽 칸 · 오른쪽 시간당 계산식 · 좁은 폭(720 아래)은 한 줄로 */ -.ui-machine-form { - display: grid; - grid-template-columns: minmax(0, 1.4fr) minmax(280px, 1fr); - gap: var(--spacing-16); - align-items: start; -} - -.ui-machine-form__fields { - display: flex; - flex-direction: column; - gap: var(--spacing-12); - min-width: 0; -} - -.ui-machine-form__row { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); - gap: var(--spacing-8) var(--spacing-12); -} - -.ui-machine-form__group { - display: flex; - flex-direction: column; - gap: var(--spacing-8); -} - -.ui-machine-form__head { - display: flex; - align-items: center; - gap: var(--spacing-12); -} - -.ui-machine-form__coefs { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); - gap: var(--spacing-8); -} - -.ui-machine-form__coefs .ui-input { - background: var(--color-surface-sunken); - color: var(--color-text-secondary); -} - -.ui-machine-form .is-changed .ui-input, -.ui-machine-form .is-changed .ui-select { - background: color-mix(in srgb, var(--color-warning) 28%, var(--color-canvas)); -} - -.ui-machine-form__warn { - min-height: 0; - color: var(--color-warning); - font-size: var(--text-caption); -} - -.ui-machine-form__warn:empty { - display: none; -} - -.ui-machine-form__notice:empty { - display: none; -} - -.ui-machine-form__similar { - border: 1px solid var(--color-border); - border-radius: var(--radius-lg); - padding: var(--spacing-8) var(--spacing-12); -} - -.ui-machine-form__similar > summary { - cursor: pointer; - color: var(--color-text-secondary); - font-size: var(--text-body-sm); -} - -.ui-machine-form__similar .ui-find__list { - max-height: 240px; - overflow: auto; -} - -.ui-machine-form__preview { - display: flex; - flex-direction: column; - gap: var(--spacing-4); - padding: var(--spacing-12); - border: 1px solid var(--color-border); - border-radius: var(--radius-lg); - background: var(--color-surface); - position: sticky; - top: 0; -} - -.ui-machine-form__preview .ui-calc-row--formula { - grid-template-columns: 64px minmax(0, 1fr) 96px; -} - -.ui-machine-form__rule { - border: 0; - border-top: 1px solid var(--color-border); - margin: var(--spacing-4) 0; - width: 100%; -} - -@media (max-width: 720px) { - .ui-machine-form { - grid-template-columns: minmax(0, 1fr); - } - - .ui-machine-form__preview { - position: static; - } -} diff --git a/ui_template/ui_template_machine_form.ts b/ui_template/ui_template_machine_form.ts deleted file mode 100644 index 7060d5757..000000000 --- a/ui_template/ui_template_machine_form.ts +++ /dev/null @@ -1,520 +0,0 @@ -/* ============================================================================= - * ui_template_machine_form.ts - * 부품 「기계 양식」(PLAN 821-2 · 요구서 796 1 장) — M01 기계 상세 · M02 내 기계 · 불러오기가 같이 씀. - * - * 칸 = 이름 · 규격 / 가격(천원) / 손료계수(바로 넣기 · 품셈 방식) / 주연료(종류 고르기 + ℓ/hr) / - * 잡품 %(주연료의) / 조종원(직종 고르기 + 인) / 전력(kWh · 선택) · 「시간당 값 직접」 꼴(allowHourly) - * 옆 = 시간당 경비 계산식 즉시(`common_util_machine_coef` · 정본은 서버 식) - * 찾기 = 「비슷한 기계에서 시작」(findSimilar 를 주면) — 고르면 값 · 이름 · 규격 복사 - * 상태 = 숫자만(아니면 오류) · 범위 밖 경고 · 고친 칸 노랑 · 같은 이름 알림 자리(setNotice) · readOnly - * 자료 = 목록(연료 · 직종 · 단가) · 찾기 · 저장은 부르는 쪽이 줌 — 이 부품은 M01 · M02 에 안 묶임 - * 값 묶음 = 기계 줄 `값` 꼴 그대로(칸 이름 같음) · 안 보이는 칸(선원 등)은 손대지 않음 - * ========================================================================== */ - -import { - MACHINE_COEFS, - MACHINE_INPUTS, - machineCoefOf, - machineHourly, -} from "../common_util/common_util_machine_coef"; -import { createCalcFormula, createCalcFormulaRow } from "./ui_template_calc_row"; -import { el, createInputField, createSelectField } from "./ui_template_elements"; -import { createFindList } from "./ui_template_find_list"; -import { attachPickSearch } from "./ui_template_pick_search"; -import { t as L } from "./ui_template_locale"; -import { createSegment } from "./ui_template_segment"; -import { createText } from "./ui_template_text"; -import "./ui_template_machine_form.css"; - -export type MachineValues = Record; - -/** 고르기 목록 한 칸 — 연료(값 = 종류 · 값 단가 원/ℓ) · 직종(값 = 코드 · 단가 원/일) */ -export interface MachinePick { - value: string; - text: string; - price: number | null; -} - -export interface MachineSimilar { - key: string; - name: string; - spec: string; - /** 흐린 꼬리표(구분 · 내 기계 등) */ - tag?: string; - values: MachineValues; -} - -export interface MachineFormOptions { - name?: string; - spec?: string; - values?: MachineValues; - /** 손료계수 방식 기본 — 값에 품셈 입력 다섯이 있으면 늘 품셈 방식 */ - coefMode?: "direct" | "pumsem"; - /** 「시간당 값 직접」 꼴 허용(내 기계) */ - allowHourly?: boolean; - fuels: MachinePick[]; - jobs: MachinePick[]; - /** 전력 단가(원/kWh) · 시간환산(조종원 하루 → 한 시간) */ - power?: number; - hour?: number; - readOnly?: boolean; - findSimilar?: (q: string) => Promise; - onSimilar?: (item: MachineSimilar) => void; - onChange?: () => void; -} - -export interface MachineFormState { - name: string; - spec: string; - values: MachineValues; - /** 숫자 아닌 칸 등 — 있으면 저장 막음 */ - errors: string[]; - changed: boolean; - /** 손료계수 방식 · 「시간당 값 직접」 꼴인지(내 기계 저장 방식) */ - mode: "direct" | "pumsem"; - hourly: boolean; -} - -export interface MachineFormHandle { - root: HTMLElement; - state: () => MachineFormState; - /** 같은 이름 · 규격 알림 등(빈 글 = 지움) */ - setNotice: (text: string) => void; -} - -/** 시간당 값 직접 칸 · 비목 */ -export const HOURLY_KEY = "시간당"; -const HOURLY_COSTS = ["노무비", "재료비", "경비"] as const; -const HOURLY_LABEL = { 노무비: "MF_Labor", 재료비: "MF_Material", 경비: "MF_Expense" } as const; - -interface NumSpec { - key: string; - label: string; - unit: string; - min: number; - max: number; -} -const n = (key: string, label: string, unit: string, min: number, max: number): NumSpec => ({ - key, - label, - unit, - min, - max, -}); -const PRICE = n("가격", "MF_Price", "천원", 0, 5_000_000); -const COEF = n("손료계수", "MF_Coef", "10⁻⁷/hr", 1, 100_000); -const PUMSEM = [ - n("내용시간", "MF_Life", "시간", 100, 100_000), - n("연간표준가동시간", "MF_Yearly", "시간", 100, 8_760), - n("상각비율", "MF_Dep", "", 0, 1), - n("정비비율", "MF_Rep", "", 0, 3), - n("연간관리비율", "MF_Mgmt", "", 0, 1), -]; -const FUEL = n("주연료", "MF_Fuel", "ℓ/hr", 0, 2_000); -const MISC = n("잡재료율", "MF_Misc", "%", 0, 100); -const CREW = n("조종원", "MF_Crew", "인", 0, 20); -const POWER = n("전력", "MF_Power", "kWh", 0, 10_000); -const COEF_LABEL: Record = { - 상각비계수: "MF_DepCoef", - 정비비계수: "MF_RepCoef", - 관리비계수: "MF_MgmtCoef", - 손료계수: "MF_Coef", -}; - -const tr = (key: string): string => L(key as "MF_Name"); -const fmt = (v: number): string => v.toLocaleString("ko-KR", { maximumFractionDigits: 4 }); -const shown = (v: unknown): string => (v === null || v === undefined ? "" : String(v)); -const same = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b); - -/** 글 → 수 · 빈 글 = null · 수 아님 = NaN */ -function parseNum(text: string): number | null { - const s = text.replace(/,/g, "").trim(); - if (!s) return null; - return /^-?\d+(\.\d+)?$/.test(s) ? Number(s) : Number.NaN; -} - -export function createMachineForm(opts: MachineFormOptions): MachineFormHandle { - const init: MachineValues = structuredClone(opts.values ?? {}); - let values: MachineValues = structuredClone(init); - let name = opts.name ?? ""; - let spec = opts.spec ?? ""; - const startName = name; - const startSpec = spec; - const hasPumsem = MACHINE_INPUTS.some((k) => typeof init[k] === "number"); - let mode: "direct" | "pumsem" = hasPumsem ? "pumsem" : (opts.coefMode ?? "direct"); - let hourlyMode = init[HOURLY_KEY] !== undefined && Boolean(opts.allowHourly); - const errors = new Map(); - - const root = el("div", { className: "ui-machine-form" }); - const fields = el("div", { className: "ui-machine-form__fields" }); - const side = el("aside", { className: "ui-machine-form__preview" }); - const notice = el("div", { className: "ui-machine-form__notice" }); - - /** 칸 값을 값 묶음에 — 빈 값은 원래 칸이 있었으면 null · 없었으면 칸을 뺌 */ - const put = (key: string, value: unknown): void => { - if (value === null || value === "") { - if (key in init) values[key] = null; - else delete values[key]; - } else values[key] = value; - }; - - const changed = (): boolean => name !== startName || spec !== startSpec || !same(values, init); - - /** 품셈 입력이 처음과 같으면 저장된 계수 그대로(원문 표 값) · 다르면 계수 짝으로 셈 */ - const syncCoefs = (): void => { - if (mode !== "pumsem") return; - const sameInputs = MACHINE_INPUTS.every((k) => same(values[k], init[k])); - const got = sameInputs ? null : machineCoefOf(values); - for (const k of MACHINE_COEFS) { - if (sameInputs) put(k, init[k] ?? null); - else put(k, got ? got[k] : null); - } - }; - - const fire = (): void => { - syncCoefs(); - paintPreview(); - paintCoefs(); - opts.onChange?.(); - }; - - /** 숫자 칸 — 숫자만 · 범위 밖 경고 · 고친 칸 노랑 */ - const numField = (spec_: NumSpec, read = false, hold?: MachineValues): HTMLElement => { - const bag = hold ?? values; - const field = createInputField({ - label: tr(spec_.label), - value: shown(bag[spec_.key]), - unit: spec_.unit || undefined, - size: 28, - }); - field.input.inputMode = "decimal"; - field.input.dataset.key = spec_.key; - const warn = el("span", { className: "ui-machine-form__warn" }); - field.root.append(warn); - const mark = (): void => { - const now = hold ? hold[spec_.key] : values[spec_.key]; - const was = hold - ? (init[HOURLY_KEY] as MachineValues | undefined)?.[spec_.key] - : init[spec_.key]; - field.root.classList.toggle("is-changed", !same(now ?? null, was ?? null)); - }; - if (read || opts.readOnly) field.input.readOnly = true; - field.input.addEventListener("input", () => { - const v = parseNum(field.input.value); - warn.textContent = ""; - if (Number.isNaN(v)) { - errors.set(spec_.key, `${tr(spec_.label)} · ${L("MF_NumberOnly")}`); - field.setError(L("MF_NumberOnly")); - } else { - errors.delete(spec_.key); - field.setError(); - if (v !== null && (v < spec_.min || v > spec_.max)) - warn.textContent = L("MF_Range").replace( - "{value}", - `${fmt(spec_.min)}~${fmt(spec_.max)}`, - ); - if (hold) { - if (v === null) delete hold[spec_.key]; - else hold[spec_.key] = v; - values[HOURLY_KEY] = { ...hold }; - } else put(spec_.key, v); - } - mark(); - fire(); - }); - mark(); - return field.root; - }; - - /** 고르기 칸 — 없음 + 목록 · 목록에 없는 지금 값도 한 칸(단가 없음) */ - const pickField = (key: string, label: string, list: MachinePick[]): HTMLElement => { - const now = shown(values[key]); - const options = [ - { value: "", text: L("MF_None") }, - ...list.map((p) => ({ value: p.value, text: p.text })), - ]; - if (now && !list.some((p) => p.value === now)) - options.push({ value: now, text: `${now} (${L("MF_NoPrice")})` }); - const field = createSelectField({ label: tr(label), options, value: now, size: 28 }); - field.select.dataset.key = key; - field.select.disabled = Boolean(opts.readOnly); - attachPickSearch(field.select, { placeholder: L("MF_PickFind") }); // 항목 많은 고르기(직종) = 찾기 판(801-2) - const mark = (): void => { - field.root.classList.toggle("is-changed", !same(values[key] ?? null, init[key] ?? null)); - }; - field.select.addEventListener("change", () => { - put(key, field.select.value || null); - mark(); - fire(); - }); - mark(); - return field.root; - }; - - const textField = ( - label: string, - get: () => string, - set: (v: string) => void, - was: string, - ): HTMLElement => { - const field = createInputField({ label: tr(label), value: get(), size: 28 }); - field.input.readOnly = Boolean(opts.readOnly); - field.input.addEventListener("input", () => { - set(field.input.value); - field.root.classList.toggle("is-changed", field.input.value !== was); - opts.onChange?.(); - }); - return field.root; - }; - - /* --- 손료계수 꼴 --- */ - const coefBox = el("div", { className: "ui-machine-form__group" }); - const coefOut = el("div", { className: "ui-machine-form__coefs" }); - const paintCoefs = (): void => { - if (mode !== "pumsem") return; - coefOut.replaceChildren( - ...MACHINE_COEFS.map((k) => { - const f = createInputField({ label: tr(COEF_LABEL[k]), value: shown(values[k]), size: 28 }); - f.input.readOnly = true; - f.input.tabIndex = -1; - f.root.classList.toggle("is-changed", !same(values[k] ?? null, init[k] ?? null)); - return f.root; - }), - ); - }; - const paintCoefBox = (): void => { - const pumsem = mode === "pumsem"; - coefBox.replaceChildren( - el("div", { - className: "ui-machine-form__row", - children: pumsem ? PUMSEM.map((s) => numField(s)) : [numField(COEF)], - }), - ...(pumsem ? [coefOut] : []), - ); - paintCoefs(); - }; - const coefSeg = createSegment({ - options: [ - { value: "direct", label: L("MF_CoefDirect") }, - { value: "pumsem", label: L("MF_CoefPumsem") }, - ], - value: mode, - size: 24, - ariaLabel: L("MF_Coef"), - onChange: (v) => { - mode = v as "direct" | "pumsem"; - if (mode === "direct") { - for (const k of [...MACHINE_INPUTS, ...MACHINE_COEFS.slice(0, 3)]) delete values[k]; - if (values["손료계수"] === undefined && init["손료계수"] !== undefined) - values["손료계수"] = init["손료계수"]; - } else for (const k of MACHINE_INPUTS) if (init[k] !== undefined) values[k] = init[k]; - paintCoefBox(); - fire(); - }, - }); - if (opts.readOnly) coefSeg.root.querySelectorAll("button").forEach((b) => (b.disabled = true)); - - /* --- 구성 / 시간당 값 직접 --- */ - const body = el("div", { className: "ui-machine-form__body" }); - const paintBody = (): void => { - if (hourlyMode) { - const hold = { ...((values[HOURLY_KEY] as MachineValues | undefined) ?? {}) }; - body.replaceChildren( - el("div", { - className: "ui-machine-form__row", - children: HOURLY_COSTS.map((k) => - numField(n(k, HOURLY_LABEL[k], "원/hr", 0, 10_000_000), false, hold), - ), - }), - ); - return; - } - paintCoefBox(); - body.replaceChildren( - numField(PRICE), - el("div", { - className: "ui-machine-form__group", - children: [ - el("div", { - className: "ui-machine-form__head", - children: [el("strong", { text: L("MF_Coef") }), coefSeg.root], - }), - coefBox, - ], - }), - el("div", { - className: "ui-machine-form__row", - children: [ - pickField("주연료종류", "MF_FuelKind", opts.fuels), - numField(FUEL), - numField(MISC), - ], - }), - el("div", { - className: "ui-machine-form__row", - children: [pickField("조종원직종", "MF_Job", opts.jobs), numField(CREW), numField(POWER)], - }), - ); - }; - const kindSeg = opts.allowHourly - ? createSegment({ - options: [ - { value: "build", label: L("MF_Build") }, - { value: "hourly", label: L("MF_HourlyDirect") }, - ], - value: hourlyMode ? "hourly" : "build", - size: 24, - onChange: (v) => { - hourlyMode = v === "hourly"; - if (hourlyMode) values[HOURLY_KEY] ??= {}; - else if (init[HOURLY_KEY] === undefined) delete values[HOURLY_KEY]; - paintBody(); - fire(); - }, - }) - : null; - - /* --- 비슷한 기계에서 시작 --- */ - const similarBox = (): HTMLElement[] => { - if (!opts.findSimilar || opts.readOnly) return []; - const find = createFindList({ placeholder: L("MF_SimilarFind") }); - const wrap = el("details", { - className: "ui-machine-form__similar", - children: [el("summary", { text: L("MF_Similar") }), find.root], - }); - let seq = 0; - let timer: number | undefined; - const look = async (): Promise => { - const mine = ++seq; - const got = await opts.findSimilar!(find.search.value.trim()).catch(() => []); - if (mine !== seq) return; - find.hint.textContent = got.length ? "" : L("MF_SimilarNone"); - find.setRows( - got.map((m) => ({ - key: m.key, - name: `${m.name} ${m.spec}`.trim(), - tag: m.tag, - sub: m.key, - onPick: () => { - values = { ...structuredClone(m.values) }; - name = m.name; - spec = m.spec; - mode = MACHINE_INPUTS.some((k) => typeof values[k] === "number") ? "pumsem" : mode; - coefSeg.set(mode); - hourlyMode = values[HOURLY_KEY] !== undefined && Boolean(opts.allowHourly); - kindSeg?.set(hourlyMode ? "hourly" : "build"); - wrap.open = false; - paintAll(); - setNotice(L("MF_SimilarCopied").replace("{value}", `${m.name} ${m.spec}`.trim())); - opts.onSimilar?.(m); - fire(); - }, - })), - ); - }; - find.search.addEventListener("input", () => { - window.clearTimeout(timer); - timer = window.setTimeout(() => void look(), 250); - }); - wrap.addEventListener("toggle", () => wrap.open && void look()); - return [wrap]; - }; - - /* --- 미리보기 --- */ - const paintPreview = (): void => { - const out = (v: number, key = false): HTMLElement => - el("span", { className: `ui-calc-row__out${key ? " is-key" : ""}`, text: fmt(v) }); - const line = (label: string, text: string, v: number, key = false): HTMLElement => - createCalcFormulaRow(label, text ? createCalcFormula(text) : null, out(v, key)); - const head = [ - el("strong", { text: L("MF_Preview") }), - createText(L("MF_PreviewNote"), "muted"), - ]; - if (hourlyMode) { - const h = (values[HOURLY_KEY] as Record | undefined) ?? {}; - const [a, b, c] = HOURLY_COSTS.map((k) => Number(h[k]) || 0); - side.replaceChildren( - ...head, - line(L("MF_Labor"), "", a), - line(L("MF_Material"), "", b), - line(L("MF_Expense"), "", c), - line(L("MF_Total"), "", a + b + c, true), - ); - return; - } - const fuel = opts.fuels.find((f) => f.value === values["주연료종류"])?.price ?? 0; - const wage = opts.jobs.find((j) => j.value === String(values["조종원직종"] ?? ""))?.price ?? 0; - const hour = opts.hour ?? 1 / 8; - const p = machineHourly(values, fuel, wage, opts.power ?? 0, hour); - const num = (k: string): string => fmt(Number(values[k]) || 0); - const rows = [ - line(L("MF_Loss"), `${num("가격")}천원 × 1,000 × ${num("손료계수")} × 10^-7^`, p.손료), - line(L("MF_Fuel"), `${num("주연료")}ℓ × ${fmt(fuel)}원`, p.주연료), - ...(values["전력"] - ? [line(L("MF_Power"), `${num("전력")}kWh × ${fmt(opts.power ?? 0)}원`, p.전력)] - : []), - line(L("MF_Misc"), `${fmt(p.주연료 + p.전력)} × ${num("잡재료율")}%`, p.잡품), - line(L("MF_Crew"), `${num("조종원")}인 × ${fmt(wage)}원 × ${fmt(hour)}`, p.조종원), - ]; - side.replaceChildren( - ...head, - ...rows, - el("hr", { className: "ui-machine-form__rule" }), - line(L("MF_Labor"), "", p.노무비), - line(L("MF_Material"), "", p.재료비), - line(L("MF_Expense"), "", p.경비), - line(L("MF_Total"), "", p.계, true), - ); - }; - - const setNotice = (text: string): void => { - notice.replaceChildren(...(text ? [createText(text, "warning")] : [])); - }; - - const top = el("div", { className: "ui-machine-form__row" }); - const paintAll = (): void => { - errors.clear(); - syncCoefs(); - top.replaceChildren( - textField( - "MF_Name", - () => name, - (v) => (name = v), - startName, - ), - textField( - "MF_Spec", - () => spec, - (v) => (spec = v), - startSpec, - ), - ); - paintBody(); - paintPreview(); - }; - fields.append(...similarBox(), notice, top, ...(kindSeg ? [kindSeg.root] : []), body); - root.append(fields, side); - root.classList.toggle("is-readonly", Boolean(opts.readOnly)); - paintAll(); - - return { - root, - state: () => ({ - name: name.trim(), - spec: spec.trim(), - // 칸 차례 = 처음 값 묶음 차례(방식을 바꿨다 되돌려도 같은 줄) · 새 칸은 뒤에 - values: structuredClone( - Object.fromEntries( - [...Object.keys(init), ...Object.keys(values)] - .filter((k, i, all) => all.indexOf(k) === i && k in values) - .map((k) => [k, values[k]]), - ), - ), - errors: [...errors.values()], - changed: changed(), - mode, - hourly: hourlyMode, - }), - setNotice, - }; -} diff --git a/ui_template/ui_template_ui_kit_parts.ts b/ui_template/ui_template_ui_kit_parts.ts index aeca68566..32643e158 100644 --- a/ui_template/ui_template_ui_kit_parts.ts +++ b/ui_template/ui_template_ui_kit_parts.ts @@ -54,7 +54,6 @@ import { createRangeBar } from "./ui_template_range_bar"; import { createStat, statGrid } from "./ui_template_stat"; import { toastNode } from "./ui_template_toast"; import { FRAME_DEMOS } from "./ui_template_ui_kit_frames"; -import { createMachineForm } from "./ui_template_machine_form"; /** 상태 줄 다섯 칸 */ export const KIT_STATES = ["default", "hover", "active", "disabled", "error"] as const; @@ -322,22 +321,6 @@ function tabsDemo(s: KitState): HTMLElement | null { return box; } -/** 기계 양식 견본 — 굴착기 1.0㎥ 값(821-2) */ -const kitMachine = (readOnly: boolean): HTMLElement => - createMachineForm({ - name: "굴착기(무한궤도)", - spec: "1.0㎥", - values: { - ...{ 가격: 142069, 내용시간: 10000, 연간표준가동시간: 1250, 상각비율: 0.9, 정비비율: 0.7 }, - ...{ 연간관리비율: 0.1, 상각비계수: 900, 정비비계수: 700, 관리비계수: 485, 손료계수: 2085 }, - ...{ 주연료: 19.5, 주연료종류: "경유", 잡재료율: 22, 조종원: 1, 조종원직종: "1048" }, - }, - fuels: [{ value: "경유", text: "경유", price: 1846.39 }], - jobs: [{ value: "1048", text: "건설기계운전사", price: 283297 }], - readOnly, - findSimilar: async () => [], - }).root; - // prettier-ignore export const KIT_PARTS: KitPart[] = [ // ── 552-2 기본 ───────────────────────────────────────────── @@ -403,8 +386,6 @@ export const KIT_PARTS: KitPart[] = [ demo: (s) => (s === "default" ? col(createKv([{ label: "연장", value: "1,080 m", big: true }, { label: "종단 경사", value: "12 %", ok: true }, { label: "최소 곡선반경", value: "12 m", ok: false }, { label: "비고", value: null }]), createKv([{ label: kitKeys("Ctrl", "Z"), value: "되돌리기" }, { label: kitKeys("휠 끌기"), value: "화면 옮기기" }, { label: kitKeys("Esc") }], { rule: true })) : null) }, { key: "calc-row", name: "계산 줄", group: "compound", kinds: "값 줄(이름 · 값 112 · 덧칸) · 식 줄(이름 · 식 · 값) · 시트 줄(이름 · 식 · 값 · 꼬리) · 위 · 아래첨자 · 중요 결과 굵게", file: "ui_template_calc_row.ts", demo: (s) => (s === "default" ? kitCalc() : null) }, - { key: "machine-form", name: "기계 양식", group: "compound", kinds: "이름 · 규격 · 가격 · 손료계수(바로 넣기 · 품셈 방식) · 주연료 · 잡품 · 조종원 · 전력 · 시간당 값 직접 · 옆 시간당 계산식 · 비슷한 기계에서 시작 · 고친 칸 · 숫자만", file: "ui_template_machine_form.ts", - demo: (s) => (s === "default" || s === "disabled" ? kitMachine(s === "disabled") : null) }, { key: "equation", name: "수식", group: "compound", kinds: "분수(가로 줄 안 세로 쌓기) · 위 · 아래첨자 · × ÷ · 괄호 · % · 조건 칩 · 하위 로직 블록(접힘) · 한 줄 = 결과명 = 이름 식 = 값 식 = 결과 + 변수 풀이", file: "ui_template_equation.ts", demo: (s) => (s === "default" ? kitEquation() : null) }, { key: "hopyo-grid", name: "일위대가 격자", group: "compound", kinds: "호표 머리(이름 · 규격 · 단위 · 1 단위 합계) · 구성 줄(품명 · 규격 · 수량 · 단위 · 비목 단가 / 금액 · 비고) · 줄 꼬리표 품셈 · 고정 · 합계 줄 빈 줄 붉게", file: "ui_template_hopyo_grid.ts", demo: (s) => (s === "default" ? createHopyoGrid([{ key: "D", no: 17, name: "돌기슭막이 H=2.0", unit: "m", total: { sum: 121748.6, labor: 94447.5, material: 0, expense: 27301.1 }, lines: [{ name: "돌쌓기 찰쌓기", spec: "뒷길이 35cm", qty: 2.09, unit: "㎡", prices: { sum: 52995, labor: 41250, expense: 11745 }, amounts: { sum: 110759.6, labor: 86212.5, expense: 24547.1 }, note: "산근 1호표", tag: "품셈" }, { name: "야면석 채집 운반", spec: "계곡 내", qty: 0.45, unit: "㎥", prices: { sum: 24420, labor: 18300, expense: 6120 }, amounts: { sum: 10989, labor: 8235, expense: 2754 }, tag: "고정" }, { name: "잡재료", spec: "노무비의", qty: null, unit: "%", prices: {}, amounts: {}, note: "수량 없음", tag: "품셈" }] }]).root : null) },