/* ============================================================================= * line_stack_machine_ctl.ts * 줄 쌓기 기계 제어 구역(PLAN 836-1 · 요구 846 · 849 · 850 · 853) — 왼쪽 기계 줄 아래 접는 구역 * ㉠ 값 칸 = 그 기계 식이 읽는 기계 칸(단위 · 기본 = 사본 값 · 고친 칸 = 「고침」 + [원래 값]) * ㉡ 조종원 = 인력 줄(노임 고르기 · 인/일 · 더하기 · 빼기 · 기본 = 기계 값 조종원 · 직종) * ㉢ 잡품 = 재료 줄(재료 · 유가 고르기 · 수량 · 율 % · 기본 = 밑 재료 줄 · 품셈 잡재료율) * 고침 = 문서 칸 `기계덮음`(캐시 · [저장] 때 서버가 정본으로 다시 셈) · 원본과 같으면 지움(`putCover`) * 기본값 = 덮음 없이 셈한 그 기계 식 답 + 기계 줄 인력 · 잡품 목록(`defaultsOf` · `rowEntry` · 842-3) · * 읽기(M01 기초) = 칸 잠김 · 단추 없음 * 기계 보기(840-2 `line_stack_machine_sheet` · M01 기계 상세 · 내 기계) = 같은 구역(인력 · 잡품 여러 줄 · 842-3) · * `lock` = 잠긴 값 칸(품셈 방식 계수 등) * ========================================================================== */ import { createBadge } from "@ui/ui_template_badge"; import { createButton, createIconButton } from "@ui/ui_template_button"; import { eqText } from "@ui/ui_template_equation"; import { el, showToast } from "@ui/ui_template_elements"; import { createInputField, createSelectField } from "@ui/ui_template_input"; import { varId, type FormulaDoc, type Link } from "../common_util/common_util_formula_doc"; import type { DocAnswer } from "../common_util/common_util_formula_doc_calc"; import { coverOf, defaultsOf, machineKey, partsOf, putCover, rowEntry, type CrewRow, type MachineCover, type MiscRow, } from "../common_util/common_util_formula_doc_cover"; import { subInputs, type StackDoc } from "../common_util/common_util_line_stack"; import type { Picked } from "../common_util/common_util_line_stack_pick"; import { machineCellName } from "../common_util/common_util_machine_symbol_name"; import { openFindPicker } from "../M02_MasterTemplete/M02_MasterTemplete_Find_Pick"; import { openStackPick } from "./line_stack_pick"; import { lt } from "./line_stack_text"; export interface CtlHooks { doc: StackDoc; answer: DocAnswer; subDoc: (key: string) => FormulaDoc | null; /** 덮음 없이 그 기계 식 셈(원본 = 기본값) */ calc: (doc: FormulaDoc, given: Record) => DocAnswer; change: (fn: (d: StackDoc) => void) => void; name: (key: string) => string; /** 읽기(M01 기초 · 칸 잠김) */ readOnly: boolean; /** 그 기계 줄 값(인력 · 잡품 목록 — 기본값 바탕 · 842-3) */ lists?: (given: Record) => Record | null; /** 잠긴 값 칸 */ lock?: string[]; } /** 기계 칸 단위(기계 표 `단위` 와 같음) */ const UNIT: Record = { 가격: "천원", 상각비계수: "10⁻⁷/hr", 정비비계수: "10⁻⁷/hr", 관리비계수: "10⁻⁷/hr", 손료계수: "10⁻⁷/hr", 주연료: "ℓ/hr", 전력: "kWh/hr", 노무비: "원/hr", 재료비: "원/hr", 경비: "원/hr", }; // prettier-ignore const FUELS = ["경유", "휘발유", "실내등유"]; /** 그 줄 기계 열쇠 — 부를 입력을 지금 값으로 셈(옵션 · 만약 식 기호도) · 기계 줄 아님 = "" */ export const machineOfLine = (answer: DocAnswer, link: Link | undefined): string => link?.갈래 === "단가산출" ? machineKey(subInputs(link, (answer.값 ?? {}) as Record)) : ""; /** 그 기계 줄의 기계 열쇠 · 식 · 원본 기본값 — 기계 식이 아니면 null */ export function ctlOf(h: CtlHooks, link: Link | undefined) { if (link?.갈래 !== "단가산출" || !link.키) return null; const given = subInputs(link, (h.answer.값 ?? {}) as Record); const machine = machineKey(given); const sub = h.subDoc(link.키); if (!machine || !sub) return null; const orig = h.calc(sub, given); const vals = (orig.값 ?? {}) as Record; const base = defaultsOf(sub, vals, rowEntry(sub, h.lists?.(given) ?? null)); return { machine, sub, vals, base, entry: coverOf(h.doc)[machine] }; } const num = (t: string): number | null => { const n = Number(t.trim().replace(/,/g, "")); return t.trim() !== "" && Number.isFinite(n) ? n : null; }; /** 수 칸 — 빈 글 = 원래대로(null) · 음수 · 글 = 알림 */ function numBox( label: string, value: unknown, unit: string, ro: boolean, set: (n: number | null) => void, hint = "", ): HTMLElement { const f = createInputField({ value: value === undefined || value === null ? "" : String(value), placeholder: hint, compact: true, size: 24, ...(unit ? { unit } : {}), }); f.root.classList.add("lstk-f-s"); f.input.setAttribute("aria-label", label); f.input.title = label; f.input.disabled = ro; f.input.addEventListener("change", () => { const t = f.input.value; const n = num(t); if (t.trim() === "") return set(null); if (n === null || n < 0) { f.input.value = value === undefined || value === null ? "" : String(value); return void showToast(lt("Ctl_Bad"), "warning"); } set(n); }); return f.root; } const kind = (text: string): HTMLElement => el("span", { className: "lstk-kind", text }); /** 기계 제어 구역 — `link` = 그 기계 줄 단가 기호 연결 */ export function buildMachineCtl(h: CtlHooks, link: Link | undefined): HTMLElement | null { const got = ctlOf(h, link); if (!got) return null; const { machine, sub, vals, base } = got; const entry: MachineCover = got.entry ?? {}; const ro = h.readOnly; const put = (next: MachineCover): void => h.change((d) => putCover(d, machine, next, base)); const parts = partsOf(sub); const lines = sub.금액 ?? []; // ㉠ 값 칸 const values = parts.fields.map(({ 칸: col }) => { 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]; else 값[col] = v; put({ ...entry, 값 }); }; const box = col === "주연료종류" ? createSelectField({ options: [...new Set([...FUELS, String(now ?? "")])] .filter(Boolean) .map((x) => ({ value: x, text: x })), value: String(now ?? ""), compact: true, size: 24, disabled: shut, ariaLabel: col, onChange: (x) => setVal(x), }).root : numBox(col, now, UNIT[col] ?? "", shut, setVal); const row = el("div", { className: "lstk-frow", children: [ el("b", { className: "lstk-sym", text: machineCellName(col), attrs: { title: col } }), "=", box, ], }); if (mine !== undefined) { const tag = createBadge(lt("Ctl_Changed"), "info", { size: 18 }); tag.title = `${lt("Ctl_Was")} ${was === undefined ? "—" : eqText(was)}`; row.append(tag); if (!shut) row.append( createButton({ label: lt("Ctl_Back"), variant: "ghost", size: 24, iconStart: "undo", onClick: () => setVal(null), }), ); } row.dataset.mctlField = col; return row; }); // ㉡ 조종원 — 인력 줄 const crew: CrewRow[] = entry.조종원 ?? base.조종원; const setCrew = (rows: CrewRow[]): void => put({ ...entry, 조종원: rows }); const crewName = (r: CrewRow, i: number): string => { if (r.이름) return r.이름; const at = parts.crew[i] ?? parts.crew[0]; const sym = at === undefined ? "" : String(lines[at]?.단가 ?? ""); const v = (sub.변수 ?? []).find((x) => varId(x) === sym); return (v?.이름 ?? "").replace(/노임$/, "") || h.name(r.키) || r.키; }; const crewRows = crew.map((r, i) => lineRow( lt("K_인력"), crewName(r, i), ro, () => openStackPick("인력", (p) => setCrew(swapAt(crew, i, { ...r, 키: p.키, 이름: p.이름 })), undefined, crewName(r, i)), // prettier-ignore [numBox(lt("Ctl_Crew_Qty"), r.인, lt("Ctl_Per_Day"), ro, (n) => setCrew(swapAt(crew, i, { ...r, 인: n ?? 0 })))], // prettier-ignore "노무비", () => setCrew(crew.filter((_, k) => k !== i)), ), ); // ㉢ 잡품 — 재료 줄 const misc: MiscRow[] = entry.잡품 ?? base.잡품; const setMisc = (rows: MiscRow[]): void => put({ ...entry, 잡품: rows }); const baseQty = (r: MiscRow): number | null => { const b = lines.find((l) => String(l.이름 ?? "") === r.줄); const q = b?.수량 ? (vals[String(b.수량)] ?? Number(b.수량)) : null; return typeof q === "number" && Number.isFinite(q) ? q : null; }; const miscName = (r: MiscRow): string => { if (r.이름) return r.이름; if (r.키) return h.name(r.키) || r.키; const fuel = r.줄 === "주연료" ? vals.S_연료 : undefined; return [r.줄, fuel].filter(Boolean).join(" · "); }; const pickMat = (done: (p: Picked) => void, query: string): void => openFindPicker({ layer: "system", owner: null, title: `${lt("Find_Title")} — ${lt("Ctl_Misc")}`, kinds: ["material", "oil"], query: query || undefined, element: (e) => done({ 키: e.코드, 이름: e.이름, 단위: e.단위, ...(e.규격 ? { 규격: e.규격 } : {}) }), // prettier-ignore oil: (e) => done({ 키: e.코드, 이름: e.이름, 단위: e.단위 }), logic: () => undefined, }); const asMat = (r: MiscRow, p: Picked): MiscRow => ({ 키: p.키, 이름: [p.이름, p.규격].filter(Boolean).join(" "), ...(p.단위 ? { 단위: p.단위 } : {}), 수량: r.수량 ?? baseQty(r) ?? 1, 율: r.율, }); const miscRows = misc.map((r, i) => lineRow( lt("K_재료"), miscName(r), ro, () => 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 })), ), ], "재료비", () => setMisc(misc.filter((_, k) => k !== i)), ), ); const head = (label: string, covered: boolean, add: (() => void) | null, reset: () => void) => el("div", { className: "lstk-fhead", children: [ el("span", { className: "lstk-kind", text: label }), ...(covered ? [createBadge(lt("Ctl_Changed"), "info", { size: 18 })] : []), ...(ro ? [] : [ ...(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 : []), ]), ], }); const box = el("div", { className: "lstk-mctl lstk-wide", attrs: { "data-mctl": machine }, children: [ ...(values.length ? [el("div", { className: "lstk-fhead", children: [kind(lt("Ctl_Values"))] }), ...values] : []), head( lt("Ctl_Crew"), entry.조종원 !== undefined, () => 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)]), ""), () => put({ ...entry, 잡품: undefined }), ), ...(miscRows.length ? miscRows : [el("div", { className: "lstk-empty", text: lt("Ctl_None") })]), // prettier-ignore ], }); return box; } const swapAt = (rows: T[], i: number, one: T): T[] => rows.map((r, k) => (k === i ? one : r)); /** 줄 꼴(단가산출 줄과 닮음) — [종류][이름 단추][칸들][비목][X] */ function lineRow( label: string, name: string, ro: boolean, pick: () => void, boxes: HTMLElement[], cost: string, drop: () => void, ): HTMLElement { const b = createButton({ label: name || "—", variant: "ghost", tone: "link", size: 24, iconStart: "search", disabled: ro, onClick: pick, }); b.classList.add("lstk-res"); b.title = `${lt("Repick")} — ${name}`; return el("div", { className: "lstk-line k-ctl", children: [ kind(label), b, ...boxes, el("span", { className: "lstk-hint", text: cost }), ...(ro ? [] : [createIconButton({ icon: "x", title: lt("Del"), size: 24, onClick: drop })]), ], }); }