/* ============================================================================= * 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 { 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; } 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); 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.주연료), line(L("MF_Misc"), `${fmt(p.주연료)} × ${num("잡재료율")}%`, p.잡품), line(L("MF_Crew"), `${num("조종원")}인 × ${fmt(wage)}원 × ${fmt(hour)}`, p.조종원), ...(values["전력"] ? [line(L("MF_Power"), `${num("전력")}kWh × ${fmt(opts.power ?? 0)}원`, 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(values), errors: [...errors.values()], changed: changed(), }), setNotice, }; }