Files
Aislo/A00_Common/line_stack_machine_ctl.ts
T
eomsangdonandClaude Opus 5.5 badc24c0cc fix(845-2): 단가산출 단위 표시 규칙 한 벌 · 기계 산출근거 겹분수 중간 줄로 나눔
- 단위 규칙 한 벌(common_util_line_stack_unit) — 왼쪽 값 칸 · 식 줄 · 옵션 · 오른쪽 산출근거 · 기계 제어 구역 · 기계 보기가 같이 씀
  · 메타 단위(구분 따름 · 수(…) · 원문 단위 없음) 안 그림 · %(설명) = % · 10^-7 = 10⁻⁷ · 기계 칸 연결 = 기계 표 칸 단위 · 코드 값 쉼표 없음
- 비목 · 금액 줄 = 「단가 원/단위 × (수량 식) 단위 = 금액 원」 · 소계 · 합계 · 계 = 원 · 단가 · 수량 한 덩이(줄 바뀜에 단위 안 떨어짐)
- 기계경비식 겹분수 → 중간 줄(상여 · 휴지 · 손료계수 · common_util_line_stack_frac) · 한 줄 분수 한 층
- 기계 제어 구역 단위 = 칸 밖 바로 뒤 · 기계 보기 오른쪽 = 박기 전 기계 칸 단위
- 휴대폰 폭(560 px 밑) 산출근거 금액 칸 넷 걷음(가로 넘침 0) · 분수 줄 위아래 여유
- 전수 1,520 표시 탓 43,218 → 0 · 기계 겹분수 2 층 넘는 줄 1,040 → 0 · 금액 · 식 · 문서 자료 불변(그리기만)
- 시험 test_845_2_unit_show(차린 줄 글 전수 지킴) · shownUnit 걷음(규칙 한 벌로)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 22:44:36 +09:00

332 lines
13 KiB
TypeScript

/* =============================================================================
* line_stack_machine_ctl.ts
* 줄 쌓기 기계 제어 구역(PLAN 836-1 · 요구 846 · 849 · 850 · 853) — 왼쪽 기계 줄 아래 접는 구역
* ㉠ 값 칸 = 그 기계 식이 읽는 기계 칸(단위 · 기본 = 사본 값 · 고친 칸 = 「고침」 + [원래 값])
* ㉡ 조종원 = 인력 줄(노임 고르기 · 인/일 · 더하기 · 빼기 · 기본 = 기계 값 조종원 · 직종)
* ㉢ 잡품 = 재료 줄(재료 · 유가 고르기 · 수량 · 율 % · 기본 = 밑 재료 줄 · 품셈 잡재료율)
* 고침 = 문서 칸 `기계덮음`(캐시 · [저장] 때 서버가 정본으로 다시 셈) · 원본과 같으면 지움(`putCover`)
* 기본값 = 덮음 없이 셈한 그 기계 식 답 + 기계 줄 인력 · 잡품 목록(`defaultsOf` · `rowEntry` · 842-3) ·
* 읽기(M01 기초) = 칸 잠김 · 단추 없음
* 기계 보기(840-2 `line_stack_machine_sheet` · M01 기계 상세 · 내 기계) = 같은 구역(인력 · 잡품 여러 줄 · 842-3) ·
* `lock` = 잠긴 값 칸(품셈 방식 계수 등)
* ========================================================================== */
import { createBadge } from "@ui/ui_template_badge";
import { createButton, createIconButton } from "@ui/ui_template_button";
import { eqText } from "@ui/ui_template_equation";
import { el, showToast } from "@ui/ui_template_elements";
import { createInputField, createSelectField } from "@ui/ui_template_input";
import { varId, type FormulaDoc, type Link } from "../common_util/common_util_formula_doc";
import type { DocAnswer } from "../common_util/common_util_formula_doc_calc";
import {
coverOf,
defaultsOf,
machineKey,
partsOf,
putCover,
rowEntry,
type CrewRow,
type MachineCover,
type MiscRow,
} from "../common_util/common_util_formula_doc_cover";
import { subInputs, type StackDoc } from "../common_util/common_util_line_stack";
import type { Picked } from "../common_util/common_util_line_stack_pick";
import { machineUnit } from "../common_util/common_util_line_stack_unit";
import { machineCellName } from "../common_util/common_util_machine_symbol_name";
import { openFindPicker } from "../M02_MasterTemplete/M02_MasterTemplete_Find_Pick";
import { openStackPick } from "./line_stack_pick";
import { lt } from "./line_stack_text";
export interface CtlHooks {
doc: StackDoc;
answer: DocAnswer;
subDoc: (key: string) => FormulaDoc | null;
/** 덮음 없이 그 기계 식 셈(원본 = 기본값) */
calc: (doc: FormulaDoc, given: Record<string, unknown>) => DocAnswer;
change: (fn: (d: StackDoc) => void) => void;
name: (key: string) => string;
/** 읽기(M01 기초 · 칸 잠김) */
readOnly: boolean;
/** 그 기계 줄 값(인력 · 잡품 목록 — 기본값 바탕 · 842-3) */
lists?: (given: Record<string, unknown>) => Record<string, unknown> | null;
/** 잠긴 값 칸 */
lock?: string[];
}
const FUELS = ["경유", "휘발유", "실내등유"];
/** 그 줄 기계 열쇠 — 부를 입력을 지금 값으로 셈(옵션 · 만약 식 기호도) · 기계 줄 아님 = "" */
export const machineOfLine = (answer: DocAnswer, link: Link | undefined): string =>
link?.갈래 === "단가산출"
? machineKey(subInputs(link, (answer.값 ?? {}) as Record<string, unknown>))
: "";
/** 그 기계 줄의 기계 열쇠 · 식 · 원본 기본값 — 기계 식이 아니면 null */
export function ctlOf(h: CtlHooks, link: Link | undefined) {
if (link?.갈래 !== "단가산출" || !link.키) return null;
const given = subInputs(link, (h.answer.값 ?? {}) as Record<string, unknown>);
const machine = machineKey(given);
const sub = h.subDoc(link.키);
if (!machine || !sub) return null;
const orig = h.calc(sub, given);
const vals = (orig.값 ?? {}) as Record<string, unknown>;
const base = defaultsOf(sub, vals, rowEntry(sub, h.lists?.(given) ?? null));
return { machine, sub, vals, base, entry: coverOf(h.doc)[machine] };
}
const num = (t: string): number | null => {
const n = Number(t.trim().replace(/,/g, ""));
return t.trim() !== "" && Number.isFinite(n) ? n : null;
};
/** 수 칸 — 빈 글 = 원래대로(null) · 음수 · 글 = 알림 · 단위 = 칸 바로 뒤(공식 묶음 값 칸과 같은 자리 · 845-2 ㉥) */
function numBox(
label: string,
value: unknown,
unit: string,
ro: boolean,
set: (n: number | null) => void,
hint = "",
): HTMLElement {
const f = createInputField({
value: value === undefined || value === null ? "" : String(value),
placeholder: hint,
compact: true,
size: 24,
});
f.root.classList.add("lstk-f-s");
f.input.setAttribute("aria-label", label);
f.input.title = label;
f.input.disabled = ro;
f.input.addEventListener("change", () => {
const t = f.input.value;
const n = num(t);
if (t.trim() === "") return set(null);
if (n === null || n < 0) {
f.input.value = value === undefined || value === null ? "" : String(value);
return void showToast(lt("Ctl_Bad"), "warning");
}
set(n);
});
return unit
? el("span", { className: "lstk-qty", children: [f.root, el("span", { className: "lstk-unit", text: unit })] }) // prettier-ignore
: f.root;
}
const kind = (text: string): HTMLElement => el("span", { className: "lstk-kind", text });
/** 기계 제어 구역 — `link` = 그 기계 줄 단가 기호 연결 */
export function buildMachineCtl(h: CtlHooks, link: Link | undefined): HTMLElement | null {
const got = ctlOf(h, link);
if (!got) return null;
const { machine, sub, vals, base } = got;
const entry: MachineCover = got.entry ?? {};
const ro = h.readOnly;
const put = (next: MachineCover): void => h.change((d) => putCover(d, machine, next, base));
const parts = partsOf(sub);
const lines = sub.금액 ?? [];
// ㉠ 값 칸
const values = parts.fields.map(({ 칸: col }) => {
const was = base.값[col];
const mine = entry.값?.[col];
const now = mine ?? was;
const shut = ro || !!h.lock?.includes(col);
const setVal = (v: number | string | null): void => {
const 값 = { ...(entry.값 ?? {}) };
if (v === null || v === "") delete 값[col];
else 값[col] = v;
put({ ...entry, 값 });
};
const box =
col === "주연료종류"
? createSelectField({
options: [...new Set([...FUELS, String(now ?? "")])]
.filter(Boolean)
.map((x) => ({ value: x, text: x })),
value: String(now ?? ""),
compact: true,
size: 24,
disabled: shut,
ariaLabel: col,
onChange: (x) => setVal(x),
}).root
: numBox(col, now, machineUnit(col), shut, setVal); // 845-2 기계 표 칸 단위 한 벌
const row = el("div", {
className: "lstk-frow",
children: [
el("b", { className: "lstk-sym", text: machineCellName(col), attrs: { title: col } }),
"=",
box,
],
});
if (mine !== undefined) {
const tag = createBadge(lt("Ctl_Changed"), "info", { size: 18 });
tag.title = `${lt("Ctl_Was")} ${was === undefined ? "—" : eqText(was)}`;
row.append(tag);
if (!shut)
row.append(
createButton({
label: lt("Ctl_Back"),
variant: "ghost",
size: 24,
iconStart: "undo",
onClick: () => setVal(null),
}),
);
}
row.dataset.mctlField = col;
return row;
});
// ㉡ 조종원 — 인력 줄
const crew: CrewRow[] = entry.조종원 ?? base.조종원;
const setCrew = (rows: CrewRow[]): void => put({ ...entry, 조종원: rows });
const crewName = (r: CrewRow, i: number): string => {
if (r.이름) return r.이름;
const at = parts.crew[i] ?? parts.crew[0];
const sym = at === undefined ? "" : String(lines[at]?.단가 ?? "");
const v = (sub.변수 ?? []).find((x) => varId(x) === sym);
return (v?.이름 ?? "").replace(/노임$/, "") || h.name(r.키) || r.키;
};
const crewRows = crew.map((r, i) =>
lineRow(
lt("K_인력"),
crewName(r, i),
ro,
() => openStackPick("인력", (p) => setCrew(swapAt(crew, i, { ...r, 키: p.키, 이름: p.이름 })), undefined, crewName(r, i)), // prettier-ignore
[numBox(lt("Ctl_Crew_Qty"), r.인, lt("Ctl_Per_Day"), ro, (n) => setCrew(swapAt(crew, i, { ...r, 인: n ?? 0 })))], // prettier-ignore
"노무비",
() => setCrew(crew.filter((_, k) => k !== i)),
),
);
// ㉢ 잡품 — 재료 줄
const misc: MiscRow[] = entry.잡품 ?? base.잡품;
const setMisc = (rows: MiscRow[]): void => put({ ...entry, 잡품: rows });
const baseQty = (r: MiscRow): number | null => {
const b = lines.find((l) => String(l.이름 ?? "") === r.줄);
const q = b?.수량 ? (vals[String(b.수량)] ?? Number(b.수량)) : null;
return typeof q === "number" && Number.isFinite(q) ? q : null;
};
const miscName = (r: MiscRow): string => {
if (r.이름) return r.이름;
if (r.키) return h.name(r.키) || r.키;
const fuel = r.줄 === "주연료" ? vals.S_연료 : undefined;
return [r.줄, fuel].filter(Boolean).join(" · ");
};
const pickMat = (done: (p: Picked) => void, query: string): void =>
openFindPicker({
layer: "system",
owner: null,
title: `${lt("Find_Title")} — ${lt("Ctl_Misc")}`,
kinds: ["material", "oil"],
query: query || undefined,
element: (e) => done({ 키: e.코드, 이름: e.이름, 단위: e.단위, ...(e.규격 ? { 규격: e.규격 } : {}) }), // prettier-ignore
oil: (e) => done({ 키: e.코드, 이름: e.이름, 단위: e.단위 }),
logic: () => undefined,
});
const asMat = (r: MiscRow, p: Picked): MiscRow => ({
키: p.키,
이름: [p.이름, p.규격].filter(Boolean).join(" "),
...(p.단위 ? { 단위: p.단위 } : {}),
수량: r.수량 ?? baseQty(r) ?? 1,
율: r.율,
});
const miscRows = misc.map((r, i) =>
lineRow(
lt("K_재료"),
miscName(r),
ro,
() => pickMat((p) => setMisc(swapAt(misc, i, asMat(r, p))), r.키 ? miscName(r) : ""),
[
numBox(lt("Qty"), r.수량, r.단위 ?? "", ro, (n) => {
const { 수량: _, ...rest } = r;
setMisc(swapAt(misc, i, n === null ? rest : { ...r, 수량: n }));
}, r.줄 && r.수량 === undefined ? `${baseQty(r) ?? ""} ${lt("Ctl_Follow")}` : ""), // prettier-ignore
numBox(lt("Ctl_Rate"), r.율, "%", ro, (n) =>
setMisc(swapAt(misc, i, { ...r, 율: n ?? 0 })),
),
],
"재료비",
() => setMisc(misc.filter((_, k) => k !== i)),
),
);
const head = (label: string, covered: boolean, add: (() => void) | null, reset: () => void) =>
el("div", {
className: "lstk-fhead",
children: [
el("span", { className: "lstk-kind", text: label }),
...(covered ? [createBadge(lt("Ctl_Changed"), "info", { size: 18 })] : []),
...(ro
? []
: [
...(add ? [createIconButton({ icon: "plus", title: `${label} ${lt("Ctl_Add")}`, size: 24, onClick: add })] : []), // prettier-ignore
...(covered
? [createButton({ label: lt("Ctl_Back"), variant: "ghost", size: 24, iconStart: "undo", onClick: reset })] // prettier-ignore
: []),
]),
],
});
const box = el("div", {
className: "lstk-mctl lstk-wide",
attrs: { "data-mctl": machine },
children: [
...(values.length
? [el("div", { className: "lstk-fhead", children: [kind(lt("Ctl_Values"))] }), ...values]
: []),
head(
lt("Ctl_Crew"),
entry.조종원 !== undefined,
() => openStackPick("인력", (p) => setCrew([...crew, { 키: p.키, 이름: p.이름, 인: 1 }])),
() => put({ ...entry, 조종원: undefined }),
),
...(crewRows.length ? crewRows : [el("div", { className: "lstk-empty", text: lt("Ctl_None") })]), // prettier-ignore
head(
lt("Ctl_Misc"),
entry.잡품 !== undefined,
() => pickMat((p) => setMisc([...misc, asMat({ 율: 100 }, p)]), ""),
() => put({ ...entry, 잡품: undefined }),
),
...(miscRows.length ? miscRows : [el("div", { className: "lstk-empty", text: lt("Ctl_None") })]), // prettier-ignore
],
});
return box;
}
const swapAt = <T>(rows: T[], i: number, one: T): T[] => rows.map((r, k) => (k === i ? one : r));
/** 줄 꼴(단가산출 줄과 닮음) — [종류][이름 단추][칸들][비목][X] */
function lineRow(
label: string,
name: string,
ro: boolean,
pick: () => void,
boxes: HTMLElement[],
cost: string,
drop: () => void,
): HTMLElement {
const b = createButton({
label: name || "—",
variant: "ghost",
tone: "link",
size: 24,
iconStart: "search",
disabled: ro,
onClick: pick,
});
b.classList.add("lstk-res");
b.title = `${lt("Repick")} — ${name}`;
return el("div", {
className: "lstk-line k-ctl",
children: [
kind(label),
b,
...boxes,
el("span", { className: "lstk-hint", text: cost }),
...(ro ? [] : [createIconButton({ icon: "x", title: lt("Del"), size: 24, onClick: drop })]),
],
});
}