/* ============================================================================= * line_stack_machine_sheet.ts * 기계 보기(PLAN 840-2 · 요구 853 · 862 「기계 보기 공용」) — 기계 한 대를 단가산출 수식 보기와 닮은 꼴로: * 왼쪽 = 기계 제어 구역(836-1 `line_stack_machine_ctl` 그대로 — 값 칸 · 인력 줄 · 잡품 재료 줄 여럿 · 842-3) + * 「기본 정보 · 손료 근거」 접이 카드(이름 · 규격 · 손료 방식 · 품셈 비율 · 식이 안 읽는 값 칸) + 비슷한 기계에서 시작(새 기계) * 오른쪽 = 그 기계 시간당 경비 산출근거(`line_stack_out` · 노 · 재 · 경 · 계 합계 상자) — 값을 바꾸면 브라우저 계산기로 즉시 * 서버(`POST /api/m02/machine-sheet` · `M02_MyMachine_Sheet`) = 식 고름 + 처음 줄 값을 박은 식 문서 + 연결 기록 · * 고친 값은 덮음(`기계덮음` 꼴)으로 얹어 셈 · 캐시에 없는 연결 · 식이 바뀔 칸만 뒤에서 다시 물음(옛 요청 끊음) * 시간당 작업량(846-2) = 왼쪽 접이 카드(작업량 식 변수 = 기계 값) · 오른쪽 풀이(`line_stack_machine_sheet_work`) * 줄 = 저장 꼴(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, rowEntry, 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 { crewView } from "../common_util/common_util_machine_crew"; import { applyCover, bindDoc, coverFrom, crewKey, engineVals, modeOf, ownedCols, rangeWarnings, sameVal, slotOf, type MachineMode, type MachinePick, type MachineRow, type MachineValues, } from "../common_util/common_util_machine_sheet"; import { MACHINE_UNIT, machineUnit, unitText, varUnit, } from "../common_util/common_util_line_stack_unit"; import { machineCellName } from "../common_util/common_util_machine_symbol_name"; import { docFoot } from "./formula_doc_foot"; import { buildMachineCtl } from "./line_stack_machine_ctl"; import { sheetWorkCard, sheetWorkOut, type SheetWorkOpts } from "./line_stack_machine_sheet_work"; 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 PICKS = ["주연료종류"]; /** 식이 바뀔 수 있는 칸(서버 식 고름) */ const PICKY = ["주연료", "전력", "손료계수", ...MACHINE_COEFS]; const clone = (v: T): T => JSON.parse(JSON.stringify(v ?? null)) as T; /** 값 박은 식(그림용) — 박기 전 기계 칸 단위를 남김(박으면 칸 연결이 수로 바뀌어 단위를 못 읽음 · 845-2) */ const boundView = (d: FormulaDoc, raw: MachineValues): FormulaDoc => { const b = bindDoc(d, raw); b.변수?.forEach((v, i) => { const u = varUnit(d.변수?.[i]); if (u && !v.단위) v.단위 = u; }); return b; }; 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 workBox = 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; const lists = engineVals(orig, mine); base = defaultsOf(d, (plain(d, given).값 ?? {}) as Record, rowEntry(d, lists)); putCover(holder, no, coverFrom(parts, 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 { info.까닭 = got.까닭; // 식은 그대로 — 까닭만 지금 줄로 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, 키: crewKey(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, lists: () => engineVals(orig, mine), lock, }, { ...link, 키: info.키 }, ); if (zone && lock.length && mode === "품셈") zone.title = L("MS_Locked"); ctlBox.replaceChildren(...(zone ? [zone] : [])); }; /** 칸 단위 — 줄 `단위` 글(일 단위 손료 등 · 기계 표 기본과 다를 때) › 기계 표 칸 단위(845-2 한 벌) */ const unitOf = (k: string): string => { const u = text((cur.단위 as MachineValues | undefined)?.[k]); return u && u !== MACHINE_UNIT[k] ? unitText(u) : machineUnit(k); }; const numField = (k: string, read = false): HTMLElement => { const v = cur.값?.[k]; const unit = unitOf(k); const f = createInputField({ label: label(k), value: text(v), size: 28, ...(unit ? { unit } : {}) }); // 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), rowEntry(d, engineVals(orig, mine))); const a = plain(shaped, given); if (cache.takeMissing()) ask(); lastAnswer = a; right.replaceChildren( head, ...(info.까닭 ? [createText(info.까닭, "warning")] : []), buildOut(boundView(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(), }), ); right.append(sheetWorkOut(work())); if (cache.takeMissing()) ask(); foot.replaceChildren(docFoot(a)); }; // 846-2 시간당 작업량 — 지금 줄 `값.작업량`(고침 = 지금 줄 · 저장은 부르는 쪽) const work = (): SheetWorkOpts => ({ cur, orig, ro, links: cache.links, miss: () => undefined, changed: () => changed(), }); const drawWork = (): void => { const keep = workBox.firstElementChild?.classList.contains("is-collapsed") ?? false; workBox.replaceChildren(sheetWorkCard(work(), keep)); if (cache.takeMissing()) ask(); }; const drawWarn = (): void => { const vals: MachineValues = crewView({ ...engineVals(cur, mine), ...(cur.값 ?? {}) }); if (unitOf("손료계수") !== machineUnit("손료계수")) delete vals.손료계수; // 일 단위 손료(임업) = 시간당 범위 아님 warn.replaceChildren( ...rangeWarnings(vals).map(([k, r]) => createText(L("MS_Range").replace("{name}", label(k)).replace("{value}", r), "warning"), ), ); }; const drawLive = (): void => { drawCtl(); drawWork(); drawRight(); drawWarn(); }; const drawAll = (): void => { drawBase(); drawLive(); }; const setNotice = (t: string): void => { notice.replaceChildren(...(t ? [createText(t, "warning")] : [])); }; left.append(...similarBox(), notice, warn, ctlBox, workBox, 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, }), }; }