- 오른쪽 글 상자 → 칸 차례(거리 · 사이클 8-2-8 · 단가산출) · 칸마다 제목 · 몸 · 바닥 결과 - 사이클 칸 = Cm · 사이클시간 · t2 받는 로직만 · 조합 = 담은 로직 줄마다 미리보기 · 하위 산근 이어 붙임 - logic-preview 응답에 blocks(머리 · 호표 줄 · 계 · 출처) 더함 · 셈 · lines 그대로 - 값 칸 숫자 칸 = ui-input--sm(고르기 칸과 같은 높이) · 「거리에서」 → 「자동계산」 단추 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
211 lines
7.9 KiB
TypeScript
211 lines
7.9 KiB
TypeScript
/* =============================================================================
|
||
* B09_Estimation_UI_Haul_Steps.ts
|
||
* 「운반 단가」 창 오른쪽 계산 칸(PLAN 277) — 칸 차례 · 칸마다 제목 줄 · 몸 · 바닥 결과(굵게).
|
||
* ① 거리(바닥 운반거리) ② 사이클시간 건설품셈 8-2-8(TS 짝 `B08_Quantity_Haul_Cycle` · 바닥 Cm)
|
||
* ③ 단가산출 = 서버 `logic-preview` 의 `blocks` 하나(277-2) — 머리 글 → 입력값 · 중간값(이름 · 값 크게,
|
||
* 식 작게 흐리게) · 호표 줄(수량 · 금액) · 바닥 단가 + 노무 · 재료 · 경비(빈칸 = 까닭 빨강).
|
||
* 셈은 서버 그대로 — 여기는 그리기만 · 테마 변수(`B09_Estimation_UI_Haul_Steps.css`).
|
||
* ========================================================================== */
|
||
|
||
import { el } from "@ui/ui_template_elements";
|
||
import { CYCLE_DEFAULTS, cycleTime, type Dist } from "../B08_Quantity/B08_Quantity_Haul_Cycle";
|
||
import "./B09_Estimation_UI_Haul_Steps.css";
|
||
|
||
/** 호표 줄 하나(서버 `SHOWN_LINE`) */
|
||
export interface StepLine {
|
||
name: string;
|
||
spec: string;
|
||
qty: number | null;
|
||
unit: string;
|
||
kind: string;
|
||
labor: number | null;
|
||
material: number | null;
|
||
expense: number | null;
|
||
why: string | null;
|
||
text?: string[] | null;
|
||
}
|
||
|
||
/** 산근 블록 하나(서버 `_block_shown`) */
|
||
export interface StepBlock {
|
||
no: number;
|
||
logic: string;
|
||
name: string;
|
||
unit: string;
|
||
source: string;
|
||
head: string[];
|
||
foot: string;
|
||
price: number | null;
|
||
parts: Record<string, number | null>;
|
||
why: string | null;
|
||
lines: StepLine[];
|
||
}
|
||
|
||
interface StepRow {
|
||
name: string;
|
||
value: string;
|
||
formula?: string;
|
||
bad?: boolean;
|
||
}
|
||
|
||
const PART_TEXT: Record<string, string> = { labor: "노무", material: "재료", expense: "경비" };
|
||
|
||
/** 값 — 천 단위 쉼표 · 빈 값 「—」 */
|
||
export const num = (v: number | null | undefined, digits = 4): string =>
|
||
v == null || !Number.isFinite(v)
|
||
? "—"
|
||
: v.toLocaleString("ko-KR", { maximumFractionDigits: digits });
|
||
|
||
const rowEl = (r: StepRow): HTMLElement =>
|
||
el("div", {
|
||
className: "b09-haulstep__row",
|
||
children: [
|
||
el("span", { text: r.name }),
|
||
el("span", {
|
||
className: `b09-haulstep__value${r.bad ? " b09-haulstep__bad" : ""}`,
|
||
text: r.value,
|
||
}),
|
||
...(r.formula ? [el("span", { className: "b09-haulstep__formula", text: r.formula })] : []),
|
||
],
|
||
});
|
||
|
||
const group = (label: string, rows: StepRow[]): HTMLElement[] =>
|
||
rows.length
|
||
? [el("div", { className: "b09-haulstep__group", text: label }), ...rows.map(rowEl)]
|
||
: [];
|
||
|
||
/** 칸 하나 — 제목 줄(제목 · 곁글) · 몸 · 바닥(굵게) */
|
||
function box(title: string, sub: string, body: HTMLElement[], foot: HTMLElement[]): HTMLElement {
|
||
return el("section", {
|
||
className: "b09-haulstep",
|
||
children: [
|
||
el("div", {
|
||
className: "b09-haulstep__head",
|
||
children: [
|
||
el("span", { className: "b09-haulstep__title", text: title }),
|
||
...(sub ? [el("span", { className: "b09-haulstep__sub", text: sub })] : []),
|
||
],
|
||
}),
|
||
...(body.length ? [el("div", { className: "b09-haulstep__body", children: body })] : []),
|
||
...(foot.length ? [el("div", { className: "b09-haulstep__foot", children: foot })] : []),
|
||
],
|
||
});
|
||
}
|
||
|
||
const footOf = (name: string, value: string, bad = false): HTMLElement[] => [
|
||
el("span", { text: name }),
|
||
el("span", { className: `b09-haulstep__value${bad ? " b09-haulstep__bad" : ""}`, text: value }),
|
||
];
|
||
|
||
/** 알림 칸(제목 줄만) — 셈 중 · 못 셈 · 조합 안내 · 연결 없음 */
|
||
export function noteBox(text: string, bad = false): HTMLElement {
|
||
const got = box(text, "", [], []);
|
||
if (bad) got.querySelector(".b09-haulstep__title")?.classList.add("b09-haulstep__bad");
|
||
return got;
|
||
}
|
||
|
||
/** ① 거리 — 거리이정표 포장 · 비포장 → 운반거리 */
|
||
export function distBox(dist: Dist, input: Record<string, unknown>): HTMLElement {
|
||
const rows = [
|
||
{ name: "포장 L1", value: `${num(dist?.L1)} km` },
|
||
{ name: "비포장 L2", value: `${num(dist?.L2)} km` },
|
||
];
|
||
return box(
|
||
"거리",
|
||
"거리이정표",
|
||
rows.map(rowEl),
|
||
footOf("운반거리", `${num(cycleTime(input).L)} km`),
|
||
);
|
||
}
|
||
|
||
/** ② 사이클시간 — 건설품셈 8-2-8 덤프트럭(기본값 + 거리) */
|
||
export function cycleBox(input: Record<string, unknown>): HTMLElement {
|
||
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);
|
||
const rows: StepRow[] = [
|
||
{ name: "q", value: `${num(r.q)} ㎥`, formula: `T / rt × f = ${v.T} / ${v.rt} × ${v.f}` },
|
||
{ name: "n", value: `${num(r.n)} 회`, formula: `q / (qs × k) = ${r.q} / (${v.qs} × ${v.k})` },
|
||
{
|
||
name: "t1",
|
||
value: `${num(r.t1)} 분`,
|
||
formula: `Cms × n / (60 × Es) = ${v.Cms} × ${r.n} / (60 × ${v.Es})`,
|
||
},
|
||
{
|
||
name: "t2",
|
||
value: `${num(r.t2)} 분`,
|
||
formula: `(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`,
|
||
},
|
||
{
|
||
name: "Cm",
|
||
value: `${num(r.cm)} 분`,
|
||
formula: `t1 + t2 + t3 + t4 + t5 + t6 = ${r.t1} + ${r.t2} + ${v.t3} + ${v.t4} + ${v.t5} + ${v.t6}`,
|
||
},
|
||
];
|
||
return box(
|
||
"사이클시간",
|
||
"건설품셈 8-2-8 덤프트럭",
|
||
rows.map(rowEl),
|
||
footOf("Cm", `${num(r.cm)} 분`),
|
||
);
|
||
}
|
||
|
||
/** 머리 글 → 입력값(「이름 : 뜻 = 값」) · 중간값(「이름 = 식 = 값」 · 「└」 = 앞 줄 식에 붙임) */
|
||
export function splitHead(b: StepBlock): { inputs: StepRow[]; mids: StepRow[] } {
|
||
const inputs: StepRow[] = [];
|
||
const mids: StepRow[] = [];
|
||
let last: StepRow | null = null;
|
||
for (const line of b.head) {
|
||
if (line.startsWith(`${b.name} / `) || (b.source && line === `${b.source} 적용`)) continue;
|
||
if (line.startsWith("└") && last) {
|
||
last.formula = [last.formula, line].filter(Boolean).join("\n");
|
||
continue;
|
||
}
|
||
const colon = line.indexOf(" : ");
|
||
const eq = line.indexOf(" = ");
|
||
const end = line.lastIndexOf(" = ");
|
||
if (colon >= 0 && (eq < 0 || colon < eq)) {
|
||
const value = end > colon ? line.slice(end + 3) : line.slice(colon + 3);
|
||
const note = end > colon ? line.slice(colon + 3, end) : "";
|
||
inputs.push((last = { name: line.slice(0, colon), value, formula: note }));
|
||
} else if (eq >= 0) {
|
||
const formula = end > eq ? line.slice(eq + 3, end) : "";
|
||
mids.push((last = { name: line.slice(0, eq), value: line.slice(end + 3), formula }));
|
||
} else mids.push((last = { name: line, value: "" }));
|
||
}
|
||
return { inputs, mids };
|
||
}
|
||
|
||
/** ③ 단가산출 칸 — 블록 하나(맨 위 = 「단가산출」 · 하위 = 「하위 산근」) */
|
||
export function logicBox(b: StepBlock, label = "단가산출"): HTMLElement {
|
||
const unit = b.unit ? `원/${b.unit.replace(/^원\//, "")}` : "원";
|
||
const { inputs, mids } = splitHead(b);
|
||
const lines: StepRow[] = b.lines.map((ln) => {
|
||
const parts = [ln.labor, ln.material, ln.expense];
|
||
const money = parts.some((p) => p == null) ? null : parts.reduce((a, p) => a! + p!, 0);
|
||
return {
|
||
name: ln.spec ? `${ln.name} ${ln.spec}` : ln.name,
|
||
value: ln.why ? `빈칸 — ${ln.why}` : `${num(ln.qty)} ${ln.unit} · ${num(money, 1)} 원`,
|
||
formula: (ln.text || []).join("\n"),
|
||
bad: !!ln.why,
|
||
};
|
||
});
|
||
const foot =
|
||
b.price == null
|
||
? footOf("단가", `빈칸 — ${b.why ?? ""}`, true)
|
||
: [
|
||
...footOf("단가", `${num(b.price, 1)} ${unit}`),
|
||
el("span", {
|
||
className: "b09-haulstep__formula",
|
||
text: Object.entries(b.parts)
|
||
.map(([p, v]) => `${PART_TEXT[p] ?? p} ${num(v, 1)}`)
|
||
.join(" · "),
|
||
}),
|
||
];
|
||
return box(
|
||
`${label} ${b.logic} ${b.name}`,
|
||
[unit, b.source].filter(Boolean).join(" · "),
|
||
[...group("입력값", inputs), ...group("중간값", mids), ...group("호표", lines)],
|
||
foot,
|
||
);
|
||
}
|