Files
Aislo/B09_Estimation/B09_Estimation_UI_Haul_Steps.ts
T
eomsangdonandClaude Opus 5.5 f43a36aa39 feat(b09): 운반 단가 창 오른쪽 계산 칸 나눔 · 운반거리 칸 자동계산 단추 (277-1~4)
- 오른쪽 글 상자 → 칸 차례(거리 · 사이클 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
2026-10-12 18:25:05 +09:00

211 lines
7.9 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_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,
);
}