Files
Aislo/B09_Estimation/B09_Estimation_UI_Haul_Modal.ts
T
eomsangdonandClaude Opus 5.5 2c8c715cc2 feat(B09): 263-2 사전조합 · 구조물 조합 운반 = 담은 로직 입력 + 거리 값 사본
- 모달 = 담은 로직 이름별 입력(library-inputs) · 거리 칸 거리에서 · 손 값(own) 이김
- 사본 폴더 = 종류@층@주인@키~값해시8 · 같은 값 = 같은 사본 · 다른 거리 = 따로 · 옛 이름 읽힘
- 셈 때 서버가 거리로 사본 다시 고름(resolve_hauls · fix_keys) · [저장] = 바뀐 연결 저장 값에도 · prune
- 연결 {tab dg, key, lib, given, own} 저장 꼴 · 원본 읽기 전 모양 검사

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU
2026-10-11 16:50:28 +09:00

404 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* 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`(캐시 → [저장] 때 서버 정본).
* 여는 곳 = 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 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, unknown>): string[] {
const v: Record<string, number> = { ...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<Preview> {
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<string, string> = {
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<string, Row>,
libOwn: new Set<string>((libRef && (o.ref?.own as string[])) || []),
code: String(logicRef?.code ?? ""),
name: String(logicRef?.name ?? ""),
given: { ...((logicRef?.given as Row) || {}) } as Row,
own: new Set<string>(((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) => {
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(320px,1fr) minmax(360px,1.4fr);gap:16px;min-height:60vh",
},
children: [left, right],
}),
);
if (st.code) useLogic(st.code, st.name);
else {
if (libRef) useLib(libRef);
refresh();
}
},
});
}