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
This commit is contained in:
2026-10-12 18:44:11 +09:00
co-authored by Claude Opus 5.5
parent 76959c9d88
commit 04659a028f
7 changed files with 217 additions and 29 deletions
@@ -429,10 +429,11 @@ export function openHaulModal(o: HaulModalOpts): void {
head.append(ok);
body.append(
linkBox,
// 278-1 왼쪽(옵션 값) 폭 = 예전 약 0.8 배(칸 · 단추는 줄 안에서 접힘)
el("div", {
attrs: {
style:
"display:grid;grid-template-columns:minmax(300px,1fr) minmax(340px,1.4fr);gap:16px;flex:1;min-height:0",
"display:grid;grid-template-columns:minmax(260px,0.7fr) minmax(340px,1.4fr);gap:16px;flex:1;min-height:0",
},
children: [left, right],
}),
@@ -79,3 +79,11 @@
.b09-haulstep__bad {
color: var(--color-danger);
}
/* 278-2 B09 단가산출근거 박스보기 머리 줄 — 같은 줄 꼴 · 값 굵게 */
.b09-sgsteps {
padding: 2px var(--spacing-4) var(--spacing-4);
border-bottom: 1px solid var(--color-border);
}
.b09-sgsteps .b09-haulstep__value {
font-weight: 700;
}
+48 -27
View File
@@ -5,6 +5,7 @@
* ③ 단가산출 = 서버 `logic-preview` 의 `blocks` 하나(277-2) — 머리 글 → 입력값 · 중간값(이름 · 값 크게,
* 식 작게 흐리게) · 호표 줄(수량 · 금액) · 바닥 단가 + 노무 · 재료 · 경비(빈칸 = 까닭 빨강).
* 셈은 서버 그대로 — 여기는 그리기만 · 테마 변수(`B09_Estimation_UI_Haul_Steps.css`).
* 머리 줄 나눔 · 그리기(`headSteps`) = B09 단가산출근거 박스보기도 같이 씀(278-2 · `Tabs_Groups`).
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
@@ -45,19 +46,22 @@ interface StepRow {
value: string;
formula?: string;
bad?: boolean;
/** 머리 글 몇째 줄(`splitHead`) */
at?: number;
}
const PART_TEXT: Record<string, string> = { labor: "노무", material: "재료", expense: "경비" };
/** 값 — 천 단위 쉼표 · 빈 값 「—」 */
export const num = (v: number | null | undefined, digits = 4): string =>
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): HTMLElement =>
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", {
@@ -68,9 +72,12 @@ const rowEl = (r: StepRow): HTMLElement =>
],
});
const group = (label: string, rows: StepRow[]): HTMLElement[] =>
const group = (label: string, rows: StepRow[], ats?: number[]): HTMLElement[] =>
rows.length
? [el("div", { className: "b09-haulstep__group", text: label }), ...rows.map(rowEl)]
? [
el("div", { className: "b09-haulstep__group", text: label }),
...rows.map((r) => rowEl(r, ats)),
]
: [];
/** 칸 하나 — 제목 줄(제목 · 곁글) · 몸 · 바닥(굵게) */
@@ -91,7 +98,7 @@ function box(title: string, sub: string, body: HTMLElement[], foot: HTMLElement[
});
}
const footOf = (name: string, value: string, bad = false): HTMLElement[] => [
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 }),
];
@@ -106,14 +113,14 @@ export function noteBox(text: string, bad = false): HTMLElement {
/** ① 거리 — 거리이정표 포장 · 비포장 → 운반거리 */
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` },
{ name: "포장 L1", value: `${stepNum(dist?.L1)} km` },
{ name: "비포장 L2", value: `${stepNum(dist?.L2)} km` },
];
return box(
"거리",
"거리이정표",
rows.map(rowEl),
footOf("운반거리", `${num(cycleTime(input).L)} km`),
rows.map((r) => rowEl(r)),
stepFoot("운반거리", `${stepNum(cycleTime(input).L)} km`),
);
}
@@ -123,38 +130,44 @@ export function cycleBox(input: Record<string, unknown>): HTMLElement {
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: "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: `${num(r.t1)} 분`,
value: `${stepNum(r.t1)} 분`,
formula: `Cms × n / (60 × Es) = ${v.Cms} × ${r.n} / (60 × ${v.Es})`,
},
{
name: "t2",
value: `${num(r.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: `${num(r.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(rowEl),
footOf("Cm", `${num(r.cm)} 분`),
rows.map((r) => rowEl(r)),
stepFoot("Cm", `${stepNum(r.cm)} 분`),
);
}
type HeadOf = Pick<StepBlock, "name" | "source" | "head">;
/** 머리 글 → 입력값(「이름 : 뜻 = 값」) · 중간값(「이름 = 식 = 값」 · 「└」 = 앞 줄 식에 붙임) */
export function splitHead(b: StepBlock): { inputs: StepRow[]; mids: StepRow[] } {
export function splitHead(b: HeadOf): { inputs: StepRow[]; mids: StepRow[] } {
const inputs: StepRow[] = [];
const mids: StepRow[] = [];
let last: StepRow | null = null;
for (const line of b.head) {
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");
@@ -166,45 +179,53 @@ export function splitHead(b: StepBlock): { inputs: StepRow[]; mids: StepRow[] }
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 }));
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 }));
} else mids.push((last = { name: line, value: "" }));
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 { 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)} 원`,
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
? footOf("단가", `빈칸 — ${b.why ?? ""}`, true)
? stepFoot("단가", `빈칸 — ${b.why ?? ""}`, true)
: [
...footOf("단가", `${num(b.price, 1)} ${unit}`),
...stepFoot("단가", `${stepNum(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)}`)
.map(([p, v]) => `${PART_TEXT[p] ?? p} ${stepNum(v, 1)}`)
.join(" · "),
}),
];
return box(
`${label} ${b.logic} ${b.name}`,
[unit, b.source].filter(Boolean).join(" · "),
[...group("입력값", inputs), ...group("중간값", mids), ...group("호표", lines)],
[...headSteps(b), ...group("호표", lines)],
foot,
);
}
@@ -11,6 +11,8 @@
* 보기 · 가로 열 수 기억 = `b_page_state`(`b09-group-view` · `b09-group-cols` · 탭마다) · 못 읽으면 카드 · 2
* 보기 단추 · 가로 열 수(202-1) = 페이지 보조 탭 줄 오른쪽(`setTools`) · 안내 글 = 표 · 카드 판 맨 아래
* 안내 글 길면 한 줄 줄임표 + 공용 호버로 전문(200-1)
* 278-2 단가산출근거 박스 = 머리 줄(`|머리n`)을 운반 단가 창 ③ 칸 꼴로(`headSteps` 한 벌 · 몸 위 칸) ·
* 출처 = 그 칸 맨 위 · 호표 줄 · 총계 = 그대로 · 묶음 자료(`tabGroups`) · 표는 안 바뀜
* ========================================================================== */
import type { DataRow, DataTable } from "@ui/ui_template_data_table";
@@ -26,6 +28,7 @@ import {
type GroupParts,
} from "@ui/ui_template_group_cards";
import { readState, writeState } from "../A00_Common/b_page_state";
import { headSteps } from "./B09_Estimation_UI_Haul_Steps";
import { rowIndex, type Link, type TabId, type TabTable } from "./B09_Estimation_UI_Tabs";
export type GroupView = "cards" | "table";
@@ -152,6 +155,33 @@ export function tabGroups(tab: TabId, spec: TabTable): GroupCard[] {
return cards;
}
const HEAD_LINE = /\|머리\d+$/;
/** 278-2 단가산출근거 박스 — 머리 줄을 몸 줄에서 빼 입력값 · 중간값 칸(`top`)으로 · 줄 번호 그대로 */
export function sgHeadCards(cards: GroupCard[], rows: DataRow[]): GroupCard[] {
return cards.map((c) => {
const heads = c.lines.filter((l) => HEAD_LINE.test(str(rows[l.at]?.key)));
if (!heads.length) return c;
const source = str(rows[c.at]?.source);
const top = document.createElement("div");
top.className = "b09-sgsteps";
if (source) {
const cite = document.createElement("div");
cite.className = "b09-haulstep__group";
cite.textContent = source;
top.append(cite);
}
const head = heads.map((l) => str(rows[l.at].text));
top.append(
...headSteps(
{ name: c.name, source, head },
heads.map((l) => l.at),
),
);
return { ...c, top, lines: c.lines.filter((l) => !heads.includes(l)) };
});
}
/** 고른 호표의 표 줄 구간 [첫 줄, 끝 줄](키 `|` 앞머리가 같은 줄) · 없으면 null */
export function pickRange(rows: DataRow[], focus: string | undefined): [number, number] | null {
if (focus === undefined) return null;
@@ -245,7 +275,10 @@ export function renderGroupPanel(o: {
if (view === "cards") {
const first = o.spec.columns[0]?.key ?? "";
const cards = createGroupCards({
groups: tabGroups(o.tab, o.spec),
groups:
o.tab === "sg"
? sgHeadCards(tabGroups(o.tab, o.spec), o.spec.rows)
: tabGroups(o.tab, o.spec),
columns: readGroupCols(o.tab),
onColumns: (n) => rememberGroup("b09-group-cols", o.tab, n),
onPick: (i) => o.goto(o.spec.link?.(i, first) ?? null),
@@ -94,6 +94,7 @@ export const buildSg: TabBuild = ({ sheets }): TabTable | string => {
note_raw: b.spec_raw,
name: b.name,
unit: b.unit,
source: b.source ?? "", // 278-2 박스보기 머리 줄 나눔(출처 줄)
},
null,
true,