Files
Aislo/A00_Common/line_stack_machine_ctl.ts
T
eomsangdonandClaude Opus 5.5 b93f7e8acc feat(842-3): 기계 보기 · 제어 구역 인력 · 잡품 줄 여럿 — 기계 줄 목록 ↔ 덮음 · 기본 정보 조종원 · 잡품 % 칸 걷음
- 제어 구역 기계 표 꼴(sheet) 한 줄 막음 걷음 — 인력 더하기 · 빼기 · 직종 고르기 · 잡품 재료 고르기 · 수량 · 율 줄마다
- coverFrom · applyCover = 줄 인력 · 잡품 목록 ↔ 덮음 조종원 · 잡품 통째 · bindDoc · bind = 목록 → 조종원 수 · 직종 · 잡재료율
- 기본값 바탕 = 기계 줄 목록(lists 고리 · 단가산출 안 = 연결 기록 · 기계 보기 = 처음 줄)
- M01 기계 목록 조종원 열 → 인력 줄 요약 · 기호 이름 = 인력N · 잡품N 줄마다
- 시험 test_842_3 새로(두 길 · 손셈 · 거울 · 저장 꼴) · test_840_2 ③ = 목록 꼴로 고침(옛 조종원 수 · 잡재료율 칸 기대 낡음)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 18:30:54 +09:00

335 lines
13 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.
/* =============================================================================
* 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 { 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 UNIT: Record<string, string> = {
가격: "천원", 상각비계수: "10⁻⁷/hr", 정비비계수: "10⁻⁷/hr", 관리비계수: "10⁻⁷/hr", 손료계수: "10⁻⁷/hr",
주연료: "ℓ/hr", 전력: "kWh/hr", 노무비: "원/hr", 재료비: "원/hr", 경비: "원/hr",
}; // prettier-ignore
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) · 음수 · 글 = 알림 */
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,
...(unit ? { unit } : {}),
});
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 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, UNIT[col] ?? "", shut, setVal);
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 })]),
],
});
}