Files
Aislo/B09_Estimation/B09_Estimation_UI_Haul_Steps.ts
T
eomsangdonandClaude Sonnet 5.5 361ce51659 refactor(B08·B09): 자체 변종 → 부품 상자 — 탭 줄 · 분할 고르기 · 배지 칩 · 도구줄 · 떠 있는 판 · 보조 글 (PLAN 553-3)
- 위 · 보조 탭 줄 = 부품 탭(fillTabs · 밑줄 · 알약) · B09 좌측 스위치 = 분할 고르기
- 연결 칩 · 검사 탭 이름 · 끌기 태그 = 배지 · 이정표 도구 · 머리 줄 · 저장 줄 = 도구줄 · 구간 고침 · 행 고름 창 = 떠 있는 판
- 안내 · 빈 줄 · 상태 글 = 보조 글 · 운반 칸 = 카드 · 인쇄 미리보기 좌측 줄 = B09 dock 한 벌 · 쪽 체크 = 라디오 · 체크 부품
- 시험 흉내 틀 · 문구 맞춤

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 22:33:35 +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 ui-card",
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,
);
}