- 산출근거: 일당 「시간당 작업량 = 시공량 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>
359 lines
14 KiB
TypeScript
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 })]),
|
|
],
|
|
});
|
|
}
|