/* ============================================================================= * B09_Estimation_UI_Haul_Modal.ts * 「운반 단가」 모달(PLAN 253-3 · 263-1 · 요구 162-1 · 164-1) — 운반공 줄 하나(거리이정표 행 하나)의 단가연결. * 위 = 고른 항목 이름 · [변경](단가연결 한 목록 `openPriceLink` — 단가산출 · 사전조합 · 구조물 조합 · 호표) · * 왼 = 고른 항목이 요구하는 입력만(단가산출 = 로직 입력 `fetchLogicInputs` · 호표 = 입력 없음 · * 사전조합 · 구조물 조합 = 담은 로직 이름별 입력 `fetchLibInputs` · 263-2) · * 오른 = 계산 절차 — 거리 줄 · 사이클 식 줄(TS 짝 `B08_Quantity_Haul_Cycle` · 8-2-8 기본값 · 거리 칸 있을 때만) + * 로직 산출 줄(서버 `POST …/estimation/logic-preview` · 값 바꾸면 다시 · 최신 응답만). * 거리 칸(운반거리 · Cm · 사이클시간 · t2) = 거리에서 채움(「거리에서」) · 손으로 고치면 덮음(`own`) · * 빈칸으로 지우면 다시 거리에서. 옛 사이클 손 값(`cycle`) = 칸 없음 · [적용]하면 지움(기본값). * 조합 [적용] = 거리 · 손 값 사본(서버 · 같은 값 = 같은 사본) · 연결 {tab dg, key, lib, given, own}. * [적용] = 부르는 쪽 `apply`(캐시 → [저장] 때 서버 정본) · 크기 = 큰 창보다 작게(276). * 여는 곳 = B08 거리이정표 표 우클릭 · B09 설계내역서 운반공 줄 단가연결 — 연결 있는 줄만(276 · * 연결 없음 = 단가연결 목록 → 옵션 값 창 옛 길). * ========================================================================== */ 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 type { ItemInput } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch"; import { fetchLogicInputs } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api"; import { createValueControl } from "../M02_MasterTemplete/M02_Labor_Values"; import type { Row } from "./B09_Estimation_Calc_Base"; import { fetchLibInputs, importLib, openPriceLink, parseLib, type LibLine, type LibPick, type Picked, type PriceLinkOpts, } from "./B09_Estimation_UI_Tabs_Bill_Link"; export interface HaulModalOpts extends PriceLinkOpts { /** 지금 연결(없으면 null) */ ref: Row | null; /** 그 행 거리 — L1 포장 계 · L2 비포장(km) */ dist: Dist; /** 줄 이름(제목) */ name: string; } /** 옛 사이클 손 칸(253-3) — 새 모달은 받지 않음 */ const OLD_OWN = 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); 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 = Cms × n / (60 × Es) = ${v.Cms} × ${r.n} / (60 × ${v.Es}) = ${r.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); const KIND_TEXT: Record = { logic: "단가산출", precombo: "일위대가 사전조합", combo: "구조물 조합", dg: "일위대가 호표", sg: "단가산출 호표", }; /** 「운반 단가」 모달 */ export function openHaulModal(o: HaulModalOpts): void { const logicRef = o.ref?.tab === "logic" ? o.ref : null; const old = (logicRef?.cycle as Row) || {}; const libRef = o.ref?.tab === "dg" && o.ref.lib ? parseLib(String(o.ref.lib)) : null; const st = { /** 고른 것 — 단가산출 아닌 것(조합 · 호표) */ other: (libRef ? { ...libRef, name: String(o.ref?.name ?? ""), unit: "" } : o.ref && !logicRef ? { kind: o.ref.tab, ref: o.ref, from: { name: o.ref.name ?? o.ref.key } } : null) as Picked | null, /** 조합 담은 로직 줄(263-2) · 손 값 {줄 id: {입력: 값}} · 덮은 칸 `줄 id|입력` */ lines: [] as LibLine[], libGiven: structuredClone((libRef && (o.ref?.given as Row)) || {}) as Record, libOwn: new Set((libRef && (o.ref?.own as string[])) || []), code: String(logicRef?.code ?? ""), name: String(logicRef?.name ?? ""), given: { ...((logicRef?.given as Row) || {}) } as Row, own: new Set(((logicRef?.own as string[]) || []).filter((n) => !OLD_OWN.has(n))), rate: logicRef?.rate ?? null, picks: logicRef?.picks ?? null, inputs: [] as ItemInput[], unit: "", }; const oldCycle = Object.keys(old).length > 0; let seq = 0; let timer = 0; openModal({ title: `운반 단가 — ${o.name}`, closeLabel: "닫기", wide: true, dialogClass: "b09-haul-modal", mount: (body, close, head) => { // 크기(276) — 공용 큰 창(96vw × 94vh)보다 작게 · 가운데 · 몸 안 두 칸이 따로 스크롤 body.parentElement?.setAttribute( "style", "--modal-max:min(680px,86vh);width:min(1100px,94vw);height:var(--modal-max)", ); 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] }); // 사이클 = 8-2-8 기본값 + 거리(옛 손 값 안 씀 · [적용]하면 지움) const ref = (): Row => ({ own: [...st.own], cycle: {}, given: st.given }); const names = (): string[] => st.inputs.map((i) => i.이름); const filled = (): Row => fillGiven(ref(), names(), o.dist) as Row; const isLib = (p: Picked | null): p is LibPick & { name: string; unit: string } => p?.kind === "combo" || p?.kind === "precombo"; /** 거리 칸 값(거리 없으면 undefined · 서버 짝 `Bill_Library.haul_values`) */ const fromDist = (name: string): unknown => o.dist ? fillGiven({ given: {}, own: [], cycle: {} }, [name], o.dist)[name] : undefined; const redraw = (): void => { const fills = (st.code !== "" && names().some((n) => n in CYCLE_FILL)) || (isLib(st.other) && !!o.dist && st.lines.some((l) => l.입력.some((i) => i.이름 in CYCLE_FILL))); const input = cycleInputs(ref(), o.dist); const head2 = fills ? [ `거리 — 포장 ${fmt(o.dist?.L1)} km + 비포장 ${fmt(o.dist?.L2)} km`, ...cycleLines(input), "", ] : []; window.clearTimeout(timer); const my = ++seq; if (isLib(st.other)) { steps.textContent = [ ...head2, `${KIND_TEXT[st.other.kind]} — [적용]하면 거리 · 손 값 사본(같은 값 = 같은 사본)`, "단가 = [저장] 뒤 서버 셈", ].join("\n"); return; } if (!st.code) { const what = st.other ? `${KIND_TEXT[st.other.kind] ?? ""} — 입력 없음 · 그대로 연결` : "없음"; steps.textContent = `연결 — ${what}`; return; } steps.textContent = [...head2, "단가산출 — 셈 중…"].join("\n"); 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 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> = []; /** 입력 칸 하나 — auto = 거리에서 채울 값(아니면 undefined) · set(null = 비움) */ const valueRow = ( input: ItemInput, auto: () => unknown, hand: () => unknown, set: (value: unknown) => void, ): HTMLElement => { const now = auto(); const field = createValueControl( input, ((now === undefined ? hand() : now) as string | number | null) ?? null, (next, bad) => { if (bad) return; const empty = next === null || next === ""; const num = typeof next === "string" && Number.isFinite(Number(next)); set(empty ? null : num ? Number(next) : next); update(); }, ); field.control.setAttribute("data-labor-input", input.이름); const tag = el("span", { className: "m02-labin__muted" }); shows.push(() => { const value = auto(); tag.textContent = value === undefined ? "" : FROM_DIST; if (value !== undefined && document.activeElement !== field.control) field.control.value = String(value ?? ""); }); return row(label(input.이름, input.단위 || ""), [field.control, tag, field.error]); }; const logicRows = (): HTMLElement[] => st.inputs.map((input) => { const name = input.이름; const fill = name in CYCLE_FILL; return valueRow( input, () => (fill && !st.own.has(name) ? filled()[name] : undefined), () => st.given[name], (value) => { if (value === null) delete st.given[name]; else st.given[name] = value; if (fill && value !== null) st.own.add(name); else st.own.delete(name); }, ); }); /** 조합 = 담은 로직 이름별 입력(263-2) */ const libRows = (): HTMLElement[] => st.lines.flatMap((line) => [ el("h5", { text: `${line.이름} (${line.로직})`, attrs: { style: "margin:8px 0 2px" } }), ...line.입력.map((input) => { const name = input.이름; const id = `${line.id}|${name}`; const fill = name in CYCLE_FILL; return valueRow( input, () => (fill && !st.libOwn.has(id) ? fromDist(name) : undefined), () => st.libGiven[line.id]?.[name] ?? line.값[name], (value) => { const mine = (st.libGiven[line.id] ??= {}); if (value === null) delete mine[name]; else mine[name] = value; if (fill && value !== null) st.libOwn.add(id); else st.libOwn.delete(id); }, ); }), ]); /** 값 바뀜 — 칸은 그대로 두고 표시 · 계산 절차만 */ const update = (): void => { shows.forEach((show) => show()); redraw(); }; const muted = (text: string): HTMLElement => el("p", { className: "m02-labin__muted", text }); /** 고른 것 바뀜 — 왼쪽 칸을 새로 */ const refresh = (): void => { shows.splice(0); const lines = st.code ? st.inputs.length ? logicRows() : [muted("입력 없음")] : isLib(st.other) ? st.lines.length ? libRows() : [muted("담은 로직 입력 없음")] : [muted(st.other ? "입력 없음 — 그대로 연결" : "연결 없음 — [고르기]")]; left.replaceChildren( el("h4", { text: "옵션 값", attrs: { style: "margin:12px 0 4px" } }), ...lines, ...(oldCycle && st.code ? [muted("옛 사이클 손 값 — 셈은 8-2-8 기본값 · [적용]하면 지움")] : []), ); const p = st.other; title.textContent = st.code ? `단가산출 ${st.code} ${st.name}` : p ? `${KIND_TEXT[p.kind] ?? p.kind} ${String("from" in p ? (p.from.name ?? "") : "name" in p ? p.name : "")}` : "연결 — 없음"; change.querySelector(".ui-btn__label")!.textContent = st.code || p ? "변경" : "고르기"; update(); }; const useLogic = (code: string, name: string): void => { fetchLogicInputs([code]).then( (got) => { st.other = null; 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 useLib = (p: LibPick): void => { fetchLibInputs(p).then( (lines) => { st.lines = lines; refresh(); }, (error: unknown) => showToast(`조합 입력을 못 읽음 — ${String(error)}`, "error"), ); }; const usePicked = (p: Picked): void => { if (p.kind === "logic") return useLogic(p.code, p.name); st.other = p; st.code = ""; st.inputs = []; st.lines = []; st.libGiven = {}; st.libOwn.clear(); if (isLib(p)) useLib(p); refresh(); }; const change = createButton({ label: "변경", variant: "ghost", size: "sm", onClick: () => void openPriceLink({ ...o, onPick: usePicked }), }); linkBox.append(title, change); const ok = createButton({ label: "적용", onClick: () => { const p = st.other; if (!st.code && !p) return showToast("연결할 것을 먼저 고르세요", "error"); if (isLib(p)) { const haul = { given: st.libGiven, own: [...st.libOwn], dist: o.dist }; const { kind, layer, owner, key, lib } = p; void importLib(o, { kind, layer, owner, key, lib, haul }); } else if (p && "ref" in p) o.apply(p.ref, p.from); else { const next: Row = { tab: "logic", code: st.code, name: st.name, given: filled(), rate: st.rate, picks: st.picks, 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(300px,1fr) minmax(340px,1.4fr);gap:16px;flex:1;min-height:0", }, children: [left, right], }), ); if (st.code) useLogic(st.code, st.name); else { if (libRef) useLib(libRef); refresh(); } }, }); }