Files
Aislo/B09_Estimation/B09_Estimation_UI_Haul_Steps.ts
T
eomsangdonandClaude Opus 5.5 04659a028f feat(b09): 운반 단가 창 왼쪽 폭 줄임 · 단가산출근거 박스보기 머리 줄 = 입력값 · 중간값 칸 (278-1·2)
- 운반 단가 창 몸 두 칸 비율 — 왼쪽(옵션 값) 약 0.8 배
- 단가산출근거 [박스보기] 머리 줄 = 운반 창 ③ 칸과 같은 꼴(이름 · 값 오른쪽 굵게 · 식 작게) · 출처 = 칸 맨 위
- 줄 나눔 · 그리기 한 벌 — Haul_Steps headSteps 내보내 같이 씀 · 공용 묶음 카드 = 선택 몸 위 칸(top)
- 호표 줄 · 총계 · 표 보기 · 다른 탭 카드 그대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
2026-10-12 18:44:11 +09:00

232 lines
8.8 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`).
* 머리 줄 나눔 · 그리기(`headSteps`) = B09 단가산출근거 박스보기도 같이 씀(278-2 · `Tabs_Groups`).
* ========================================================================== */
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;
/** 머리 글 몇째 줄(`splitHead`) */
at?: number;
}
const PART_TEXT: Record<string, string> = { labor: "노무", material: "재료", expense: "경비" };
/** 값 — 천 단위 쉼표 · 빈 값 「—」 */
export const stepNum = (v: number | null | undefined, digits = 4): string =>
v == null || !Number.isFinite(v)
? "—"
: v.toLocaleString("ko-KR", { maximumFractionDigits: digits });
const rowEl = (r: StepRow, ats?: number[]): HTMLElement =>
el("div", {
className: "b09-haulstep__row",
...(ats && r.at !== undefined ? { attrs: { "data-at": String(ats[r.at]) } } : {}),
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[], ats?: number[]): HTMLElement[] =>
rows.length
? [
el("div", { className: "b09-haulstep__group", text: label }),
...rows.map((r) => rowEl(r, ats)),
]
: [];
/** 칸 하나 — 제목 줄(제목 · 곁글) · 몸 · 바닥(굵게) */
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 stepFoot = (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: `${stepNum(dist?.L1)} km` },
{ name: "비포장 L2", value: `${stepNum(dist?.L2)} km` },
];
return box(
"거리",
"거리이정표",
rows.map((r) => rowEl(r)),
stepFoot("운반거리", `${stepNum(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: `${stepNum(r.q)} ㎥`, formula: `T / rt × f = ${v.T} / ${v.rt} × ${v.f}` },
{
name: "n",
value: `${stepNum(r.n)} 회`,
formula: `q / (qs × k) = ${r.q} / (${v.qs} × ${v.k})`,
},
{
name: "t1",
value: `${stepNum(r.t1)} 분`,
formula: `Cms × n / (60 × Es) = ${v.Cms} × ${r.n} / (60 × ${v.Es})`,
},
{
name: "t2",
value: `${stepNum(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: `${stepNum(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((r) => rowEl(r)),
stepFoot("Cm", `${stepNum(r.cm)} 분`),
);
}
type HeadOf = Pick<StepBlock, "name" | "source" | "head">;
/** 머리 글 → 입력값(「이름 : 뜻 = 값」) · 중간값(「이름 = 식 = 값」 · 「└」 = 앞 줄 식에 붙임) */
export function splitHead(b: HeadOf): { inputs: StepRow[]; mids: StepRow[] } {
const inputs: StepRow[] = [];
const mids: StepRow[] = [];
let last: StepRow | null = null;
for (const [at, line] of b.head.entries()) {
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, at }));
} 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, at }));
} else mids.push((last = { name: line, value: "", at }));
}
return { inputs, mids };
}
/** 머리 글 → 「입력값」 · 「중간값」 묶음(③ 칸 몸 · 278-2 B09 단가산출근거 박스보기가 같이 씀) ·
* ats = 머리 줄마다 부르는 쪽 줄 번호(주면 줄에 `data-at` — 누름 · 고름) */
export function headSteps(b: HeadOf, ats?: number[]): HTMLElement[] {
const { inputs, mids } = splitHead(b);
return [...group("입력값", inputs, ats), ...group("중간값", mids, ats)];
}
/** ③ 단가산출 칸 — 블록 하나(맨 위 = 「단가산출」 · 하위 = 「하위 산근」) */
export function logicBox(b: StepBlock, label = "단가산출"): HTMLElement {
const unit = b.unit ? `원/${b.unit.replace(/^원\//, "")}` : "원";
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}`
: `${stepNum(ln.qty)} ${ln.unit} · ${stepNum(money, 1)} 원`,
formula: (ln.text || []).join("\n"),
bad: !!ln.why,
};
});
const foot =
b.price == null
? stepFoot("단가", `빈칸 — ${b.why ?? ""}`, true)
: [
...stepFoot("단가", `${stepNum(b.price, 1)} ${unit}`),
el("span", {
className: "b09-haulstep__formula",
text: Object.entries(b.parts)
.map(([p, v]) => `${PART_TEXT[p] ?? p} ${stepNum(v, 1)}`)
.join(" · "),
}),
];
return box(
`${label} ${b.logic} ${b.name}`,
[unit, b.source].filter(Boolean).join(" · "),
[...headSteps(b), ...group("호표", lines)],
foot,
);
}