- 운반 단가 창 몸 두 칸 비율 — 왼쪽(옵션 값) 약 0.8 배 - 단가산출근거 [박스보기] 머리 줄 = 운반 창 ③ 칸과 같은 꼴(이름 · 값 오른쪽 굵게 · 식 작게) · 출처 = 칸 맨 위 - 줄 나눔 · 그리기 한 벌 — Haul_Steps headSteps 내보내 같이 씀 · 공용 묶음 카드 = 선택 몸 위 칸(top) - 호표 줄 · 총계 · 표 보기 · 다른 탭 카드 그대로 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
232 lines
8.8 KiB
TypeScript
232 lines
8.8 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`).
|
||
* 머리 줄 나눔 · 그리기(`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,
|
||
);
|
||
}
|