/* ============================================================================= * M01_MasterData_UI_LogicLab_Cover.ts * 단가산출 상세 줄 「그 자리 고침」(PLAN 722-2 · 내역 16 · 17-2) — 줄 모달 안 수량 · 단가 칸. * 고침 꼴 = M02 단가산출 덮개 칸과 같음(`qty` · `labor` · `material` · `expense` — `M02_Sangeun_Doc._covered` 규칙 중 품 · 단가) * M01 = 시험 값만 — 고친 값은 화면 메모리(로직 줄 객체 열쇠)에만 · 셈 = 줄 사본에 덮어 서버로(`/calc` · `/text` row) * · 정본 · 요소(노임 · 자재) · 캐시(초안) 안 바뀜 · 로직을 다시 열면 사라짐 * M02(724) = 같은 고침 칸 · 같은 꼴을 고침 고리(`HoEdit.set`)로 받아 덮개로 보관 * 서버 셈 = 줄 `단가`(손 단가) · 로직 줄 `값묶음`(노 · 재 · 경) · `수량` 글 — `master_pick._priced` · `bundled` 가 읽음 * ========================================================================== */ import { createButton, createInputField, el } from "@ui/ui_template_elements"; import { currentLanguageIndex } from "@ui/ui_template_locale"; import { createText } from "@ui/ui_template_text"; /* ── 순수 셈 ── */ /** 줄 하나 고침 — 품(수량) · 비목 단가(로직 줄은 셋 · 그 밖은 그 줄 비목 하나) */ export interface LineCover { qty?: number; labor?: number; material?: number; expense?: number; } export const COVER_PARTS = { labor: "노무비", material: "재료비", expense: "경비" } as const; export type CoverPart = keyof typeof COVER_PARTS; /** 지수 꼴 없는 수 글(엔진 식 칸) — 1e-7 → 0.0000001 */ const plainNum = (v: number): string => v.toLocaleString("en-US", { useGrouping: false, maximumFractionDigits: 20 }); /** 호표 줄(차례 열쇠)에 고침을 덮은 줄 사본 — 원본은 안 바뀜 · 덧줄은 고침 대상 밖 */ export function applyCovers( row: R, covers: Record, ): R { const ho = (row.호표 ?? []).map((raw, i) => { const c = covers[i]; if (!c) return raw; const item = raw as Record; const out: Record = { ...item }; if (c.qty !== undefined) out.수량 = plainNum(c.qty); const parts = (Object.keys(COVER_PARTS) as CoverPart[]).filter((k) => c[k] !== undefined); if (parts.length && item.종류 === "로직") { const given = parts.map((k) => [COVER_PARTS[k], c[k]]); out.값묶음 = { ...((item.값묶음 as object) ?? {}), ...Object.fromEntries(given) }; } else if (parts.length) out.단가 = c[parts[0]]; return out; }); return { ...row, 호표: ho }; } /* ── 순수 셈 끝 ── */ /** 시험 고침 — 로직 줄 객체마다(다시 열면 새 객체 = 사라짐) */ const trials = new WeakMap>(); export const trial = { of(row: object): Record { let got = trials.get(row); if (!got) trials.set(row, (got = {})); return got; }, has: (row: object | null): boolean => !!row && Object.keys(trials.get(row) ?? {}).length > 0, /** 시험 계산에 넘길 줄 — 고침 없으면 원본 그대로(같은 객체) */ apply(row: R): R { return row && trial.has(row) ? (applyCovers(row, trials.get(row)!) as R) : row; }, }; const TEXT = { Head: ["그 자리 고침 (시험 값)", "Edit here (trial value)"], Note: [ "정본 · 마스터 값(노임 · 자재)은 안 바뀜 · 로직을 다시 열면 사라짐", "Source and master prices stay unchanged · cleared when reopened", ], NoteM02: [ "고친 값은 이 단가산출의 변경 항목으로 저장 — M01 은 안 바뀜", "Saved as this item's override — M01 stays unchanged", ], Qty: ["수량(품)", "Quantity"], Price: ["단가", "Unit price"], Apply: ["적용", "Apply"], Undo: ["되돌림", "Undo"], Bad: ["0 이상 수만", "Numbers ≥ 0 only"], } as const satisfies Record; export const tc = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0]; export interface CoverFormOptions { /** 지금 값(덮기 전 셈 값) — 칸 처음 값 */ qty: number | null; /** 단가 칸 — 로직 줄 = 셋(1 단위 비목 값) · 그 밖 = 그 줄 비목 하나 */ prices: Partial>; /** 지금 든 고침(있으면 칸 처음 값 · [되돌림]) */ cover?: LineCover; /** 「m02」 = 덮개 보관 안내 */ scope?: "m01" | "m02"; onApply: (cover: LineCover | null) => void; } const num = (raw: string): number | null => { const v = Number(raw.replaceAll(",", "").trim()); return raw.trim() !== "" && Number.isFinite(v) && v >= 0 ? v : null; }; /** 고침 칸 — 수량 · 단가(들) · [적용] [되돌림] · 바뀐 칸만 고침으로 넘김 */ export function coverForm(o: CoverFormOptions): HTMLElement { const parts = Object.keys(o.prices) as CoverPart[]; const field = (label: string, raw: number | null, mine?: number) => { const now = raw === null ? null : Number(raw.toPrecision(10)); // 칸 글 = 유효 10 자리 · 손대지 않으면 고침 아님 const box = createInputField({ label, value: String(mine ?? now ?? ""), compact: true, type: "text", }); box.input.inputMode = "decimal"; return { box, now }; }; const qty = field(tc("Qty"), o.qty, o.cover?.qty); const prices = parts.map((k) => ({ k, ...field(parts.length > 1 ? COVER_PARTS[k] : tc("Price"), o.prices[k] ?? null, o.cover?.[k]), })); const error = createText("", "error"); error.hidden = true; const apply = (): void => { const next: LineCover = {}; let bad = false; const take = (f: { box: { input: HTMLInputElement }; now: number | null }, was?: number) => { const v = num(f.box.input.value); if (v === null) return f.box.input.value.trim() === "" ? undefined : ((bad = true), undefined); return v !== f.now || was !== undefined ? v : undefined; // 셈 값 그대로면 고침 아님 }; const q = take(qty, o.cover?.qty); if (q !== undefined) next.qty = q; for (const p of prices) { const v = take(p, o.cover?.[p.k]); if (v !== undefined) next[p.k] = v; } error.textContent = tc("Bad"); error.hidden = !bad; if (!bad) o.onApply(Object.keys(next).length ? next : null); }; const actions = el("div", { className: "m01lab-cover__actions", children: [ createButton({ label: tc("Apply"), variant: "filled", size: 32, onClick: apply }), ...(o.cover ? [ createButton({ label: tc("Undo"), variant: "ghost", size: 32, onClick: () => o.onApply(null), }), ] : []), ], }); return el("section", { className: "m01lab-cover", attrs: { "data-cover-form": "" }, children: [ el("strong", { text: tc("Head") }), createText(tc(o.scope === "m02" ? "NoteM02" : "Note"), "hint"), el("div", { className: "m01lab-cover__fields", children: [qty.box.root, ...prices.map((p) => p.box.root)], }), error, actions, ], }); }