Files
Aislo/A00_Common/line_stack_machine_ctl.ts
T
eomsangdonandClaude Opus 5.5 c8cd98dec1 feat(846-2): 기계 줄 시간당 작업량 화면 — 산출근거 「시간당 작업량」 줄 먼저 · 비목 ÷ 시간당 작업량 · 제어 구역 일당 / 시간당 식 고르기 · 변수 칸 · 기계 보기 작업량 식
- 산출근거: 일당 「시간당 작업량 = 시공량 560 ㎥/일 ÷ (1 대 × 8 시간) = 70 ㎥/시간」 · 시간당 식 = q · K · f · E · cm 값 줄 + 한 층 분수 Q 식 · 비목 줄 「59,020 원/시간 ÷ 70 ㎥/시간 = 843.1 원」(금액 그대로)
- 제어 구역: 「시간당 작업량」 묶음 맨 위 — 방식 고르기(식 없는 기계 = 잠김 + 까닭) · 시간당 = 작업량 틀 변수 칸(고침 · 원래 값) · 덮음 = 기계덮음.<기계>.작업량
- 기계 보기: 왼쪽 작업량 카드(기계 정본 변수) · 오른쪽 풀이 · 서버가 작업량 틀 연결을 실음(M02_MyMachine_Sheet._work)
- 시험: test_846_2_work_view 새로 · test_845_2 GC000088 비목 줄 기대값을 새 꼴로 고침(846-2 표시 변경) · 헬퍼 가짜 DOM 이 진짜 DOM 처럼 요소를 옮김(같은 요소 두 줄 재사용 흠을 잡게)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-16 00:00:35 +09:00

359 lines
14 KiB
TypeScript

/* =============================================================================
* line_stack_machine_ctl.ts
* 줄 쌓기 기계 제어 구역(PLAN 836-1 · 요구 846 · 849 · 850 · 853) — 왼쪽 기계 줄 아래 접는 구역
* ㉠ 값 칸 = 그 기계 식이 읽는 기계 칸(단위 · 기본 = 사본 값 · 고친 칸 = 「고침」 + [원래 값])
* ㉡ 조종원 = 인력 줄(노임 고르기 · 인/일 · 더하기 · 빼기 · 기본 = 기계 값 조종원 · 직종)
* ㉢ 잡품 = 재료 줄(재료 · 유가 고르기 · 수량 · 율 % · 기본 = 밑 재료 줄 · 품셈 잡재료율)
* ㉣ 시간당 작업량(846-2 · 맨 위) = 일당 / 시간당 식 고르기 · 시간당 변수 칸(`line_stack_machine_work`)
* 고침 = 문서 칸 `기계덮음`(캐시 · [저장] 때 서버가 정본으로 다시 셈) · 원본과 같으면 지움(`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 { workAt, workOfMachine } from "../common_util/common_util_line_stack_work";
import { sites, type WorkCover, type WorkSpec } from "../common_util/common_util_formula_doc_work";
import { machineCellName } from "../common_util/common_util_machine_symbol_name";
import { openFindPicker } from "../M02_MasterTemplete/M02_MasterTemplete_Find_Pick";
import { buildWorkGroup } from "./line_stack_machine_work";
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[];
/** 846-2 그 기계 작업량 줄(기계 값 칸 `작업량`) */
work?: (machine: string) => WorkSpec[];
}
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 ㉥) */
export 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` = 그 기계 줄 단가 기호 연결 · `at` = 그 금액 줄 차례(작업량) */
export function buildMachineCtl(
h: CtlHooks,
link: Link | undefined,
at?: number,
): 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
: []),
]),
],
});
// ㉣ 시간당 작업량(846-2)
const info = (at === undefined ? null : workAt(h.answer, at)) ?? workOfMachine(h.answer, machine);
const workNow = (entry as MachineCover & { 작업량?: WorkCover }).작업량;
const work = info
? buildWorkGroup({
doc: h.doc,
info,
kind: info.꼴 === "시간당" ? (sites(h.doc, h.doc.금액?.[at ?? -1]?.수량)[0]?.꼴 ?? "일당") : info.꼴, // prettier-ignore
entry: workNow,
specs: h.work?.(machine) ?? [],
subDoc: h.subDoc,
vals: (h.answer.값 ?? {}) as Record<string, unknown>,
ro,
put: (w) => put({ ...entry, 작업량: w } as MachineCover),
})
: [];
const box = el("div", {
className: "lstk-mctl lstk-wide",
attrs: { "data-mctl": machine },
children: [
...work,
...(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 })]),
],
});
}