Files
Aislo/A00_Common/line_stack_machine_ctl.ts
T
eomsangdonandClaude Opus 5.5 f822a4c36a feat(840-2): 기계 보기 공용 — M01 기계 상세 · 내 기계를 왼쪽 제어 구역 + 오른쪽 시간당 경비 산출근거로 · 「복사해 내 기계로」 걷음 · 기계 줄 풀기 안 내놓음
- 기계 보기 부품(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>
2026-11-15 15:52:13 +09:00

347 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`) · 읽기(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 })]),
],
});
}