- 기계 보기 부품(A00_Common/line_stack_machine_sheet) — 836-1 제어 구역 그대로(기계 표 꼴 훅 sheet · 잠금 lock) + 기본 정보 · 손료 근거 접이 카드 + 비슷한 기계에서 시작 · 값 바꾸면 브라우저 계산기로 즉시 - 서버 POST /api/m02/machine-sheet(M02_MyMachine_Sheet) — 식 고름 + 식 문서 + 처음 줄 값 + 연결 기록 · 쓰기 없음 · 파이썬 bind ↔ TS bindDoc 짝 - 덮음 ↔ 기계 줄(common_util_machine_sheet) — 고침 없음 = 줄 바이트 같음 · 내 기계 바로 · 직접 자리 맞춤 - 내 기계 = M01 에 없는 기계만 — 화면 만들기 · 고치기에서 M01 과 같은 이름 · 규격 409(불러오기 자동 생성은 그대로) · [새 기계] · 고침 열림 - 옛 기계 양식 부품(ui_template_machine_form) 지움 — 쓰는 곳 0(부품 모음 견본 줄도 걷음) - 낡은 시험 test_821_3 test_stack_line_copy — 836-2 자동 따라옴(내 기계 고치면 SK 금액 따라감 · 갱신 할 것 없음)에 맞춤 - 지킴 시험 test_840_2 — M01 기계 655 대 박은 식 셈 = 요소 셈 · 줄 그대로 · 굴착기 덮음 · 내 기계 꼴 · M01 같은 이름 막음 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
347 lines
13 KiB
TypeScript
347 lines
13 KiB
TypeScript
/* =============================================================================
|
||
* line_stack_machine_ctl.ts
|
||
* 줄 쌓기 기계 제어 구역(PLAN 836-1 · 요구 846 · 849 · 850 · 853) — 왼쪽 기계 줄 아래 접는 구역
|
||
* ㉠ 값 칸 = 그 기계 식이 읽는 기계 칸(단위 · 기본 = 사본 값 · 고친 칸 = 「고침」 + [원래 값])
|
||
* ㉡ 조종원 = 인력 줄(노임 고르기 · 인/일 · 더하기 · 빼기 · 기본 = 기계 값 조종원 · 직종)
|
||
* ㉢ 잡품 = 재료 줄(재료 · 유가 고르기 · 수량 · 율 % · 기본 = 밑 재료 줄 · 품셈 잡재료율)
|
||
* 고침 = 문서 칸 `기계덮음`(캐시 · [저장] 때 서버가 정본으로 다시 셈) · 원본과 같으면 지움(`putCover`)
|
||
* 기본값 = 덮음 없이 셈한 그 기계 식 답(`defaultsOf`) · 읽기(M01 기초) = 칸 잠김 · 단추 없음
|
||
* 기계 표 꼴(`sheet` · 840-2 기계 보기 `line_stack_machine_sheet`) = 조종원 한 줄까지 · 잡품 = 밑 줄 고정(율 하나) ·
|
||
* `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,
|
||
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;
|
||
/** 기계 표 꼴(840-2) — 기계 줄 칸에 담기는 만큼만(조종원 한 줄 · 잡품 율 하나 · 재료 고르기 없음) */
|
||
sheet?: boolean;
|
||
/** 잠긴 값 칸 */
|
||
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>;
|
||
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 table = !!h.sheet;
|
||
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 || table,
|
||
() => pickMat((p) => setMisc(swapAt(misc, i, asMat(r, p))), r.키 ? miscName(r) : ""),
|
||
[
|
||
...(table
|
||
? []
|
||
: [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(table ? misc.map((x) => ({ ...x, 율: n ?? 0 })) : swapAt(misc, i, { ...r, 율: n ?? 0 })), // prettier-ignore
|
||
),
|
||
],
|
||
"재료비",
|
||
() => 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,
|
||
table && crew.length
|
||
? null
|
||
: () =>
|
||
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,
|
||
!table
|
||
? () => pickMat((p) => setMisc([...misc, asMat({ 율: 100 }, p)]), "")
|
||
: parts.base.length && !misc.length
|
||
? () => setMisc(parts.base.map((k) => ({ 줄: String(lines[k].이름 ?? ""), 율: 0 })))
|
||
: null,
|
||
() => 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 })]),
|
||
],
|
||
});
|
||
}
|