- 왼쪽 기계 줄 ▸ = 접는 구역(값 칸 단위 · 고침 표지 · 원래 값 · 조종원 노임 고르기 · 잡품 재료 · 유가 고르기 · 율 %) - 고침 = 문서 칸 기계덮음(캐시 · 저장 때 서버 다시 셈) · 원본과 같으면 지움 · 같은 기계 줄 한 벌 - 오른쪽 산출근거 펼침 = 덮은 값으로 셈(읽기 그대로) · M01 기초 = 칸 잠김 - 기계 창 = 찾기 · 바꾸기만(양식 · 고쳐 쓰기 → 내 기계 초안 길 걷음) - 덮음 원본 대조에서 이름 · 단위(화면 글) 뺌 · 조종원 줄 규격 = 직종 이름 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
318 lines
12 KiB
TypeScript
318 lines
12 KiB
TypeScript
/* =============================================================================
|
||
* line_stack_machine_ctl.ts
|
||
* 줄 쌓기 기계 제어 구역(PLAN 836-1 · 요구 846 · 849 · 850 · 853) — 왼쪽 기계 줄 아래 접는 구역
|
||
* ㉠ 값 칸 = 그 기계 식이 읽는 기계 칸(단위 · 기본 = 사본 값 · 고친 칸 = 「고침」 + [원래 값])
|
||
* ㉡ 조종원 = 인력 줄(노임 고르기 · 인/일 · 더하기 · 빼기 · 기본 = 기계 값 조종원 · 직종)
|
||
* ㉢ 잡품 = 재료 줄(재료 · 유가 고르기 · 수량 · 율 % · 기본 = 밑 재료 줄 · 품셈 잡재료율)
|
||
* 고침 = 문서 칸 `기계덮음`(캐시 · [저장] 때 서버가 정본으로 다시 셈) · 원본과 같으면 지움(`putCover`)
|
||
* 기본값 = 덮음 없이 셈한 그 기계 식 답(`defaultsOf`) · 읽기(M01 기초) = 칸 잠김 · 단추 없음
|
||
* ========================================================================== */
|
||
|
||
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,
|
||
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 { 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;
|
||
}
|
||
|
||
/** 기계 칸 단위(기계 표 `단위` 와 같음) */
|
||
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>;
|
||
return { machine, sub, vals, base: defaultsOf(sub, vals), 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 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: ro,
|
||
ariaLabel: col,
|
||
onChange: (x) => setVal(x),
|
||
}).root
|
||
: numBox(col, now, UNIT[col] ?? "", ro, setVal);
|
||
const row = el("div", {
|
||
className: "lstk-frow",
|
||
children: [el("b", { className: "lstk-sym", text: 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 (!ro)
|
||
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 }))), // prettier-ignore
|
||
],
|
||
"재료비",
|
||
() => setMisc(misc.filter((_, k) => k !== i)),
|
||
),
|
||
);
|
||
|
||
const head = (label: string, covered: boolean, add: () => void, reset: () => void) =>
|
||
el("div", {
|
||
className: "lstk-fhead",
|
||
children: [
|
||
el("span", { className: "lstk-kind", text: label }),
|
||
...(covered ? [createBadge(lt("Ctl_Changed"), "info", { size: 18 })] : []),
|
||
...(ro
|
||
? []
|
||
: [
|
||
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 })]),
|
||
],
|
||
});
|
||
}
|