/* ============================================================================= * B09_Estimation_UI_Haul_Modal.ts * 「운반 단가」 모달(PLAN 253-3 · 요구 162-1) — 운반공 줄 하나(거리이정표 행 하나)의 단가연결. * 위 = 단가산출 이름 · [변경](`openLogicPicker`) · [다른 종류](옛 단가연결 창) · * 왼 = 옵션 값 — 로직 입력(`fetchLogicInputs`) + 사이클 입력(L1 · L2 · V1~V4 · t1 · t3~t6 · t1 셈 값) · * 오른 = 계산 절차 — 사이클 식 줄(TS 짝 `B08_Quantity_Haul_Cycle`) + 로직 산출 줄 * (서버 `POST …/estimation/logic-preview` · 값 바꾸면 다시 · 최신 응답만). * 로직 입력 Cm · 사이클시간 · t2 · 운반거리 = 거리에서 채움(「거리에서」) · 손으로 고치면 덮음(`own`) · * 빈칸으로 지우면 다시 거리에서. * 연결 없는 줄도 같은 모달(단가산출 빈칸 → [고르기]) · [적용] = 부르는 쪽 `apply`(캐시 → [저장] 때 서버 정본). * 여는 곳 = B08 거리이정표 표 우클릭 · B09 설계내역서 운반공 줄 단가연결. * ========================================================================== */ import { API_BASE_URL, CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; import { createButton, el, showToast } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; import { CYCLE_DEFAULTS, CYCLE_FILL, cycleInputs, cycleTime, fillGiven, type Dist, } from "../B08_Quantity/B08_Quantity_Haul_Cycle"; import { loadTable, type TableRow } from "../M01_MasterData/M01_MasterData_UI_Logic_Note"; import type { ItemInput } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch"; import { fetchLogicInputs } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api"; import { openLogicPicker } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Pick"; import { createValueControl } from "../M02_MasterTemplete/M02_Labor_Values"; import type { Row } from "./B09_Estimation_Calc_Base"; type Apply = (ref: Row | null, from?: Row) => void; export interface HaulModalOpts { /** 지금 연결(없으면 null) */ ref: Row | null; /** 그 행 거리 — L1 포장 계 · L2 비포장(km) */ dist: Dist; /** 줄 이름(제목) */ name: string; apply: Apply; /** 옛 단가연결 창(일위대가 · 구조물 조합 등 다른 종류) */ other?: () => void; } /** 사이클 입력 칸 — [이름, 글, 단위] · 무리 = 화면 묶음 */ const CYCLE_FIELDS: Array<[string, string, string][]> = [ [ ["L1", "포장 운반거리 L1", "km"], ["L2", "비포장 운반거리 L2", "km"], ], [ ["V1", "포장 적재 V1", "km/hr"], ["V2", "포장 공차 V2", "km/hr"], ["V3", "비포장 적재 V3", "km/hr"], ["V4", "비포장 공차 V4", "km/hr"], ], [ ["t1", "적재 t1", "분"], ["T", "적재량 T", "ton"], ["rt", "단위중량 rt", "t/㎥"], ["f", "체적환산 f", ""], ["qs", "버킷용량 qs", "㎥"], ["k", "버킷계수 k", ""], ["Es", "작업효율 Es", ""], ["Cms", "적재기계 Cms", "초"], ], [ ["t3", "적하 t3", "분"], ["t4", "대기 t4", "분"], ["t5", "덮개 t5", "분"], ["t6", "세륜 t6", "분"], ], ]; /** 표 고르기(건설품셈 8-2-8 · master_data 읽기만) — [표 키, 채울 칸(값칸 → 입력), 조건 칸들] */ const PICKS: Array<[string, Record, string]> = [ ["CC000054", { 적재: "V1", 공차: "V2" }, "포장 도로상태"], ["CC000054", { 적재: "V3", 공차: "V4" }, "비포장 도로상태"], ["CC000055", { 시간: "t3" }, "적하(토질 · 작업조건)"], ["CC000056", { 시간: "t4" }, "대기(적재장소)"], ["CC000057", { 시간: "t5" }, "덮개"], ["CC000058", { 시간: "t6" }, "세륜"], ]; const OWNABLE = new Set(["L1", "L2", "t1"]); const FROM_DIST = "거리에서"; const fmt = (v: number | null | undefined): string => v == null || !Number.isFinite(v) ? "—" : String(v); /** 계산 절차 — 사이클 식 줄(거창 단가산출 꼴) */ export function cycleLines(input: Record): string[] { const v: Record = { ...CYCLE_DEFAULTS }; for (const k of Object.keys(v)) if (typeof input[k] === "number") v[k] = input[k] as number; const r = cycleTime(input); const t1 = typeof input.t1 === "number" ? `t1 = ${fmt(r.t1)} 분(손 입력)` : `t1 = Cms × n / (60 × Es) = ${v.Cms} × ${r.n} / (60 × ${v.Es}) = ${r.t1} 분`; return [ "사이클시간 — 건설품셈 8-2-8 덤프트럭", `q = T / rt × f = ${v.T} / ${v.rt} × ${v.f} = ${r.q} ㎥`, `n = q / (qs × k) = ${r.q} / (${v.qs} × ${v.k}) = ${r.n} 회`, t1, `t2 = (L1/V1 + L1/V2 + L2/V3 + L2/V4) × 60 = (${v.L1}/${v.V1} + ${v.L1}/${v.V2} + ${v.L2}/${v.V3} + ${v.L2}/${v.V4}) × 60 = ${r.t2} 분`, `Cm = t1 + t2 + t3 + t4 + t5 + t6 = ${r.t1} + ${r.t2} + ${v.t3} + ${v.t4} + ${v.t5} + ${v.t6} = ${r.cm} 분`, `운반거리 = L1 + L2 = ${r.L} km`, ]; } interface Preview { lines: string[]; unit: string; price: number | null; why: string | null; } async function preview(code: string, given: Row, rate: unknown, picks: unknown): Promise { const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); if (!projectId) throw new Error("프로젝트 없음"); const res = await fetch(`${API_BASE_URL}/projects/${projectId}/estimation/logic-preview`, { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ code, given, rate: rate ?? null, picks: picks ?? null }), }); if (!res.ok) throw new Error(`HTTP ${res.status}`); return (await res.json()) as Preview; } const label = (text: string, unit: string): string => (unit ? `${text} (${unit})` : text); /** 「운반 단가」 모달 */ export function openHaulModal(o: HaulModalOpts): void { const logicRef = o.ref?.tab === "logic" ? o.ref : null; const st = { code: String(logicRef?.code ?? ""), name: String(logicRef?.name ?? ""), given: { ...((logicRef?.given as Row) || {}) } as Row, cycle: { ...((logicRef?.cycle as Row) || {}) } as Row, own: new Set((logicRef?.own as string[]) || []), rate: logicRef?.rate ?? null, picks: logicRef?.picks ?? null, inputs: [] as ItemInput[], unit: "", }; let seq = 0; let timer = 0; openModal({ title: `운반 단가 — ${o.name}`, closeLabel: "닫기", wide: true, dialogClass: "b09-haul-modal", mount: (body, close, head) => { const title = el("strong"); const linkBox = el("div", { attrs: { style: "display:flex;gap:8px;align-items:center;margin-bottom:8px" }, }); const left = el("div", { attrs: { style: "overflow:auto;min-width:0" } }); const steps = el("pre", { attrs: { style: "margin:0;white-space:pre-wrap;font-size:12px;line-height:1.6" }, }); const right = el("div", { attrs: { style: "overflow:auto;min-width:0" }, children: [steps] }); const ref = (): Row => ({ own: [...st.own], cycle: st.cycle, given: st.given }); const names = (): string[] => st.inputs.map((i) => i.이름); const filled = (): Row => fillGiven(ref(), names(), o.dist) as Row; const redraw = (): void => { const input = cycleInputs(ref(), o.dist); const head2 = cycleLines(input); steps.textContent = [...head2, "", st.code ? "단가산출 — 셈 중…" : "단가산출 — 없음"].join( "\n", ); window.clearTimeout(timer); if (!st.code) return; const my = ++seq; timer = window.setTimeout(() => { preview(st.code, filled(), st.rate, st.picks).then( (got) => { if (my !== seq) return; // 최신 응답만 st.unit = got.unit; const price = got.price == null ? `빈칸 — ${got.why ?? ""}` : `${got.price} 원`; steps.textContent = [...head2, "", ...got.lines, "", `단가 = ${price}`].join("\n"); }, (error: unknown) => { if (my === seq) steps.textContent = [...head2, "", `못 셈 — ${String(error)}`].join("\n"); }, ); }, 250); }; const numberField = (name: string, text: string, unit: string): HTMLElement => { const input = el("input", { className: "ui-input m02-inputval__control", attrs: { type: "number", step: "any", "data-haul-cycle": name }, }) as HTMLInputElement; const tag = el("span", { className: "m02-labin__muted" }); const show = (): void => { const now = cycleInputs(ref(), o.dist); const auto = name === "t1" ? cycleTime(now).t1 : ((now[name] as number) ?? CYCLE_DEFAULTS[name]); const mine = OWNABLE.has(name) ? st.own.has(name) : name in st.cycle; if (document.activeElement !== input) input.value = mine ? String(st.cycle[name] ?? "") : ""; input.placeholder = String(auto ?? ""); tag.textContent = (name === "L1" || name === "L2") && !mine && o.dist ? FROM_DIST : ""; }; input.addEventListener("change", () => { const v = input.value.trim() === "" ? null : Number(input.value); if (v === null || !Number.isFinite(v)) { delete st.cycle[name]; st.own.delete(name); } else { st.cycle[name] = v; if (OWNABLE.has(name)) st.own.add(name); } update(); }); shows.push(show); return row(label(text, unit), [input, tag]); }; const row = (text: string, cells: HTMLElement[]): HTMLElement => el("div", { className: "m02-inputval__row", children: [ el("span", { className: "m02-inputval__name", text }), el("div", { className: "m02-inputval__cell", children: cells }), ], }); const shows: Array<() => void> = []; const tablePick = (key: string, map: Record, text: string): HTMLElement => { const select = el("select", { className: "ui-select ui-select--sm m02-inputval__control", attrs: { "data-haul-pick": Object.values(map).join(",") }, }) as HTMLSelectElement; select.append(el("option", { text: "표에서 고르기", attrs: { value: "" } })); void loadTable(key).then((t: TableRow | null) => { const conds = Object.keys(t?.조건 || {}); (t?.줄 || []).forEach((line, i) => { const what = conds.map((c) => String(line[c] ?? "")).join(" · "); const vals = Object.keys(map) .map((col) => `${map[col]} ${String(line[col] ?? "")}`) .join(" · "); select.append(el("option", { text: `${what} — ${vals}`, attrs: { value: String(i) } })); }); select.onchange = () => { const line = t?.줄?.[Number(select.value)]; if (!line) return; for (const [col, name] of Object.entries(map)) if (typeof line[col] === "number") st.cycle[name] = line[col] as number; select.value = ""; update(); }; }); return row(text, [select]); }; const logicRows = (): HTMLElement[] => st.inputs.map((input) => { const fill = input.이름 in CYCLE_FILL && !st.own.has(input.이름); const value = fill ? filled()[input.이름] : st.given[input.이름]; const field = createValueControl( input, (value as string | number | null) ?? null, (next, bad) => { if (bad) return; if (next === null || next === "") { delete st.given[input.이름]; st.own.delete(input.이름); } else { st.given[input.이름] = typeof next === "string" && Number.isFinite(Number(next)) ? Number(next) : next; if (input.이름 in CYCLE_FILL) st.own.add(input.이름); } update(); }, ); field.control.setAttribute("data-labor-input", input.이름); const tag = el("span", { className: "m02-labin__muted" }); shows.push(() => { const auto = input.이름 in CYCLE_FILL && !st.own.has(input.이름); tag.textContent = auto ? FROM_DIST : ""; if (auto && document.activeElement !== field.control) field.control.value = String(filled()[input.이름] ?? ""); }); return row(label(input.이름, input.단위 || ""), [field.control, tag, field.error]); }); /** 값 바뀜 — 칸은 그대로 두고 표시 · 계산 절차만 */ const update = (): void => { shows.forEach((show) => show()); redraw(); }; /** 로직 바뀜 — 왼쪽 칸을 새로 */ const refresh = (): void => { shows.splice(0); const sec = (text: string): HTMLElement => el("h4", { text, attrs: { style: "margin:12px 0 4px" } }); left.replaceChildren( sec("단가산출 옵션 값"), ...(st.code ? logicRows() : [el("p", { className: "m02-labin__muted", text: "단가산출 없음 — [고르기]" })]), sec("사이클시간 입력"), ...CYCLE_FIELDS.flatMap((group) => group.map(([n, t, u]) => numberField(n, t, u))), sec("표에서 고르기(건설품셈 8-2-8)"), ...PICKS.map(([key, map, text]) => tablePick(key, map, text)), ); title.textContent = st.code ? `단가산출 ${st.code} ${st.name}` : "단가산출 — 없음"; change.querySelector(".ui-btn__label")!.textContent = st.code ? "변경" : "고르기"; update(); }; const useLogic = (code: string, name: string): void => { fetchLogicInputs([code]).then( (got) => { st.code = code; st.name = name; st.inputs = got.로직[0]?.입력 ?? []; const keep = new Set(st.inputs.map((i) => i.이름)); for (const k of Object.keys(st.given)) if (!keep.has(k)) delete st.given[k]; refresh(); }, (error: unknown) => showToast(`로직 입력을 못 읽음 — ${String(error)}`, "error"), ); }; const change = createButton({ label: "변경", variant: "ghost", size: "sm", onClick: () => openLogicPicker((logic) => useLogic(logic.키, logic.이름)), }); linkBox.append(title, change); if (o.other) linkBox.append( createButton({ label: "다른 종류", variant: "ghost", size: "sm", onClick: () => { close(); o.other?.(); }, }), ); const ok = createButton({ label: "적용", onClick: () => { if (!st.code) return showToast("단가산출을 먼저 고르세요", "error"); const next: Row = { tab: "logic", code: st.code, name: st.name, given: filled(), rate: st.rate, picks: st.picks, cycle: st.cycle, own: [...st.own], }; o.apply(next, { name: st.name, unit: st.unit.replace(/^원\//, "") }); showToast("적용함 — [저장]하면 서버가 거리로 다시 셈", "success"); close(); }, }); head.append(ok); body.append( linkBox, el("div", { attrs: { style: "display:grid;grid-template-columns:minmax(320px,1fr) minmax(360px,1.4fr);gap:16px;min-height:60vh", }, children: [left, right], }), ); if (st.code) useLogic(st.code, st.name); else refresh(); }, }); }