Files
Aislo/A00_Common/line_stack_machine_sheet.ts
T
eomsangdonandClaude Opus 5.5 b93f7e8acc feat(842-3): 기계 보기 · 제어 구역 인력 · 잡품 줄 여럿 — 기계 줄 목록 ↔ 덮음 · 기본 정보 조종원 · 잡품 % 칸 걷음
- 제어 구역 기계 표 꼴(sheet) 한 줄 막음 걷음 — 인력 더하기 · 빼기 · 직종 고르기 · 잡품 재료 고르기 · 수량 · 율 줄마다
- coverFrom · applyCover = 줄 인력 · 잡품 목록 ↔ 덮음 조종원 · 잡품 통째 · bindDoc · bind = 목록 → 조종원 수 · 직종 · 잡재료율
- 기본값 바탕 = 기계 줄 목록(lists 고리 · 단가산출 안 = 연결 기록 · 기계 보기 = 처음 줄)
- M01 기계 목록 조종원 열 → 인력 줄 요약 · 기호 이름 = 인력N · 잡품N 줄마다
- 시험 test_842_3 새로(두 길 · 손셈 · 거울 · 저장 꼴) · test_840_2 ③ = 목록 꼴로 고침(옛 조종원 수 · 잡재료율 칸 기대 낡음)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 18:30:54 +09:00

560 lines
21 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_sheet.ts
* 기계 보기(PLAN 840-2 · 요구 853 · 862 「기계 보기 공용」) — 기계 한 대를 단가산출 수식 보기와 닮은 꼴로:
* 왼쪽 = 기계 제어 구역(836-1 `line_stack_machine_ctl` 그대로 — 값 칸 · 인력 줄 · 잡품 재료 줄 여럿 · 842-3) +
* 「기본 정보 · 손료 근거」 접이 카드(이름 · 규격 · 손료 방식 · 품셈 비율 · 식이 안 읽는 값 칸) + 비슷한 기계에서 시작(새 기계)
* 오른쪽 = 그 기계 시간당 경비 산출근거(`line_stack_out` · 노 · 재 · 경 · 계 합계 상자) — 값을 바꾸면 브라우저 계산기로 즉시
* 서버(`POST /api/m02/machine-sheet` · `M02_MyMachine_Sheet`) = 식 고름 + 처음 줄 값을 박은 식 문서 + 연결 기록 ·
* 고친 값은 덮음(`기계덮음` 꼴)으로 얹어 셈 · 캐시에 없는 연결 · 식이 바뀔 칸만 뒤에서 다시 물음(옛 요청 끊음)
* 줄 = 저장 꼴(M01 기계 줄 · 내 기계 줄) — 덮음 ↔ 줄은 `common_util_machine_sheet` · 저장은 부르는 쪽(M01 상세 · 내 기계)
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible";
import { createInputField, createSelectField, el } from "@ui/ui_template_elements";
import { createFindList } from "@ui/ui_template_find_list";
import { t as L } from "@ui/ui_template_locale";
import { attachPickSearch } from "@ui/ui_template_pick_search";
import { createSegment } from "@ui/ui_template_segment";
import { createText } from "@ui/ui_template_text";
import type { FormulaDoc, Link } from "../common_util/common_util_formula_doc";
import { calcDoc, type DocAnswer } from "../common_util/common_util_formula_doc_calc";
import { createDocCache, type DocRecord } from "../common_util/common_util_formula_doc_cache";
import {
COVER,
coverDoc,
coverOf,
defaultsOf,
partsOf,
putCover,
rowEntry,
type CoverParts,
type MachineCover,
} from "../common_util/common_util_formula_doc_cover";
import type { StackDoc } from "../common_util/common_util_line_stack";
import {
MACHINE_COEFS,
MACHINE_INPUTS,
machineCoefOf,
} from "../common_util/common_util_machine_coef";
import { crewView } from "../common_util/common_util_machine_crew";
import {
applyCover,
bindDoc,
coverFrom,
crewKey,
engineVals,
modeOf,
ownedCols,
rangeWarnings,
sameVal,
slotOf,
type MachineMode,
type MachinePick,
type MachineRow,
type MachineValues,
} from "../common_util/common_util_machine_sheet";
import { machineCellName } from "../common_util/common_util_machine_symbol_name";
import { docFoot } from "./formula_doc_foot";
import { buildMachineCtl } from "./line_stack_machine_ctl";
import { buildOut } from "./line_stack_out";
import "./line_stack.css";
import "./line_stack_machine_sheet.css";
export interface MachineSimilar {
key: string;
name: string;
spec: string;
/** 흐린 꼬리표(구분 · 내 기계 등) */
tag?: string;
row: MachineRow;
}
export interface MachineSheetOptions {
/** 처음 줄(저장본 · 새 기계 = 빈 줄) — 고친 칸 표지 · 되돌림 기준 */
orig: MachineRow;
/** 지금 줄(초안) · 없으면 처음 줄 */
cur?: MachineRow;
/** 내 기계(방식 바로 · 품셈 · 직접 · 그 사람 개인 층 기계 표) */
mine: boolean;
readOnly: boolean;
lists: { fuels: MachinePick[]; jobs: MachinePick[] };
findSimilar?: (q: string) => Promise<MachineSimilar[]>;
onSimilar?: (item: MachineSimilar) => void;
onChange?: () => void;
}
export interface MachineSheetState {
name: string;
spec: string;
/** 지금 줄(이름 · 규격 넣음) */
row: MachineRow;
mode: MachineMode;
/** 숫자 아닌 칸 — 있으면 저장 막음 */
errors: string[];
changed: boolean;
}
interface SheetInfo {
키: string | null;
이름?: string;
까닭?: string;
문서: FormulaDoc | null;
/** 처음 줄 셈 꼴 값(식에 박을 값) */
값?: MachineValues;
연결?: DocRecord;
직종: Record<string, string>;
}
/** 늘 보이는 기계 줄 칸(값이 없어도) — 그 밖 칸은 줄에 있으면 */
const BASE_KEYS = ["가격", "손료계수", "주연료", "주연료종류", "전력"];
const UNIT: Record<string, string> = {
가격: "천원", 손료계수: "10⁻⁷/hr", 내용시간: "시간", 연간표준가동시간: "시간", 주연료: "ℓ/hr", 전력: "kWh",
}; // prettier-ignore
const PICKS = ["주연료종류"];
/** 식이 바뀔 수 있는 칸(서버 식 고름) */
const PICKY = ["주연료", "전력", "손료계수", ...MACHINE_COEFS];
const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v ?? null)) as T;
const text = (v: unknown): string => (v === null || v === undefined ? "" : String(v));
const label = (k: string): string => machineCellName(k);
export function createMachineSheet(o: MachineSheetOptions) {
const mine = o.mine;
const ro = o.readOnly;
let orig: MachineRow = clone(o.orig);
let cur: MachineRow = clone(o.cur ?? o.orig);
let name = text(cur.이름);
let spec = text(cur.규격);
const start = JSON.stringify([name, spec, cur]);
const no = text(cur.원문번호) || text(cur.키) || "새 기계";
const given = { 기계: no };
const link: Link = { 갈래: "단가산출", 입력: { 기계: `'${no.replace(/'/g, "")}'` } };
const holder = { 꼴: "수식문서", 변수: [], 식: [], 금액: [] } as unknown as StackDoc;
const cache = createDocCache();
const opened = new Set<string>();
const errors = new Map<string, string>();
let info: SheetInfo | null = null;
let jobs: Record<string, string> = {};
let parts: CoverParts | null = null;
let base: Required<MachineCover> = { 값: {}, 조종원: [], 잡품: [] };
const left = el("div", { className: "lstk__left" });
const right = el("div", { className: "lstk__right" });
const foot = el("div", { className: "lstk__foot" });
const notice = el("div");
const warn = el("div");
const ctlBox = el("div");
const baseBox = el("div");
const root = el("div", {
className: "lstk",
attrs: { "data-edit": "1", "data-machine-sheet": no },
children: [el("div", { className: "lstk__areas", children: [left, right] }), foot],
});
attachCollapsible(baseBox);
const sub = (): FormulaDoc | null => info?.문서 ?? null;
const entry = (): MachineCover => coverOf(holder)[no] ?? {};
/** 값 박은 셈(덮음 얹은 뒤 · 제어 구역 기본값) */
const plain = (d: FormulaDoc, g: Record<string, unknown>): DocAnswer =>
raw(bindDoc(d, info?.값 ?? {}), g);
const raw = (d: FormulaDoc, g: Record<string, unknown>): DocAnswer =>
calcDoc(d, cache.links, g, [], new Map(), {});
/** 식이 바뀌거나 처음 줄 · 방식이 바뀐 뒤 — 덮음을 처음 줄 대 지금 줄로 다시 지음 */
const rebuild = (): void => {
delete (holder as { [COVER]?: unknown })[COVER];
const d = sub();
parts = d ? partsOf(d) : null;
if (!d || !parts) return;
const lists = engineVals(orig, mine);
base = defaultsOf(d, (plain(d, given).값 ?? {}) as Record<string, unknown>, rowEntry(d, lists));
putCover(holder, no, coverFrom(parts, orig, cur, mine), base);
};
// ── 서버(뒤에서) ──
let seq = 0;
let timer: number | undefined;
let ctl: AbortController | null = null;
const ask = (now = false): void => {
window.clearTimeout(timer);
timer = window.setTimeout(() => void run(), now ? 0 : 300);
};
const run = async (): Promise<void> => {
ctl?.abort();
ctl = new AbortController();
const mineSeq = ++seq;
const body = { 줄: rowOut(), 본: orig, 덮음: coverOf(holder)[no] ?? null, 개인: mine };
try {
const r = await fetch(`${API_BASE_URL}/m02/machine-sheet`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal: ctl.signal,
});
const got = (await r.json().catch(() => ({}))) as SheetInfo & { detail?: unknown };
if (mineSeq !== seq) return;
if (!r.ok) throw new Error(text(got.detail) || r.statusText);
cache.merge(got.연결);
jobs = got.직종 ?? jobs;
if (!info || got.키 !== info.키) {
info = got;
rebuild();
drawAll();
} else {
info.까닭 = got.까닭; // 식은 그대로 — 까닭만 지금 줄로
drawLive();
}
} catch (e) {
if (mineSeq !== seq || (e as Error).name === "AbortError") return;
info = { 키: null, 까닭: e instanceof Error ? e.message : "", 문서: null, 직종: jobs };
parts = null;
drawAll();
}
};
const rowOut = (): MachineRow => ({ ...cur, 이름: name.trim(), 규격: spec.trim() });
const changed = (refit = false): void => {
o.onChange?.();
drawLive();
ask(refit);
};
// ── 품셈 비율 → 계수(같으면 처음 값 · 제어 구역 칸은 덮음으로) ──
const recoef = (): void => {
if (modeOf(cur, mine) !== "품셈") return;
const v = (cur.값 ??= {});
const same = MACHINE_INPUTS.every((k) => sameVal(v[k], orig.값?.[k]));
const got = same ? null : machineCoefOf(v);
if (!same && !got) return;
const owned = parts ? ownedCols(parts) : [];
const e = clone(entry());
for (const k of MACHINE_COEFS) {
const want = same ? orig.값?.[k] : got![k];
if (owned.includes(k)) {
e.값 = { ...(e.값 ?? {}) };
if (want === undefined || same) delete e.값[k];
else e.값[k] = want as number;
} else if (want === undefined) delete v[k];
else if (!sameVal(v[k], want)) v[k] = want;
}
if (parts) {
putCover(holder, no, e, base);
cur = applyCover(parts, orig, cur, entry(), mine, jobs);
}
};
// ── 왼쪽 ──
const drawCtl = (): void => {
const d = sub();
if (!d || !info?.키) return void ctlBox.replaceChildren();
const mode = modeOf(cur, mine);
const lock = mode === "품셈" ? [...MACHINE_COEFS] : mine && mode === "바로" ? ["정비비계수", "관리비계수"] : []; // prettier-ignore
const zone = buildMachineCtl(
{
doc: holder,
answer: { 값: {} } as unknown as DocAnswer,
subDoc: () => d,
calc: plain,
change: (fn) => {
fn(holder);
const e = entry();
if (e.조종원) {
e.조종원 = e.조종원.map((r) => ({ ...r, 키: crewKey(r.키, jobs) }));
putCover(holder, no, e, base);
}
if (parts) cur = applyCover(parts, orig, cur, entry(), mine, jobs);
changed();
},
name: (k) => cache.names.get(k) ?? "",
readOnly: ro,
lists: () => engineVals(orig, mine),
lock,
},
{ ...link, 키: info.키 },
);
if (zone && lock.length && mode === "품셈") zone.title = L("MS_Locked");
ctlBox.replaceChildren(...(zone ? [zone] : []));
};
/** 칸 단위 — 줄 `단위` 글(일 단위 손료 등) › 기본 */
const unitOf = (k: string): string => {
const u = text((cur.단위 as MachineValues | undefined)?.[k]);
return !u || u === "10^-7/시간" ? (UNIT[k] ?? "") : u;
};
const numField = (k: string, read = false): HTMLElement => {
const v = cur.값?.[k];
const unit = unitOf(k);
const f = createInputField({ label: label(k), value: text(v), size: 28, ...(unit ? { unit } : {}) }); // prettier-ignore
f.input.inputMode = "decimal";
f.input.dataset.key = k;
f.input.readOnly = ro || read;
f.root.classList.toggle("is-changed", !sameVal(v, orig.값?.[k]));
f.input.addEventListener("change", () => {
const t = f.input.value.replace(/,/g, "").trim();
const box = (cur.값 ??= {});
const n = Number(t);
if (t && !Number.isFinite(n)) {
errors.set(k, `${label(k)} · ${L("MF_NumberOnly")}`);
f.setError(L("MF_NumberOnly"));
return o.onChange?.();
}
errors.delete(k);
f.setError();
if (!t) {
if (k in (orig.값 ?? {})) box[k] = null;
else delete box[k];
} else box[k] = n;
if ((MACHINE_INPUTS as readonly string[]).includes(k)) recoef();
f.root.classList.toggle("is-changed", !sameVal(box[k], orig.값?.[k]));
changed(PICKY.includes(k));
if ((MACHINE_INPUTS as readonly string[]).includes(k)) drawBase();
});
return f.root;
};
const pickField = (k: string): HTMLElement => {
const list = k === "주연료종류" ? o.lists.fuels : o.lists.jobs;
const now = text(cur.값?.[k]);
const options = [
{ value: "", text: L("MF_None") },
...list.map((p) => ({ value: p.value, text: p.text })),
];
if (now && !list.some((p) => p.value === now)) options.push({ value: now, text: `${now} (${L("MF_NoPrice")})` }); // prettier-ignore
const f = createSelectField({ label: label(k), options, value: now, size: 28 });
f.select.dataset.key = k;
f.select.disabled = ro;
attachPickSearch(f.select, { placeholder: L("MF_PickFind") });
f.root.classList.toggle("is-changed", !sameVal(now, orig.값?.[k]));
f.select.addEventListener("change", () => {
const box = (cur.값 ??= {});
if (f.select.value) box[k] = f.select.value;
else if (k in (orig.값 ?? {})) box[k] = orig.값![k] === "" ? "" : null;
else delete box[k];
f.root.classList.toggle("is-changed", !sameVal(box[k], orig.값?.[k]));
changed(true);
});
return f.root;
};
const setMode = (m: MachineMode): void => {
const v = (cur.값 ??= {});
const was = modeOf(cur, mine);
if (m === was) return;
if (m === "품셈") for (const k of MACHINE_INPUTS) if (orig.값?.[k] !== undefined) v[k] = clone(orig.값[k]); // prettier-ignore
if (m === "바로" && !mine) for (const k of MACHINE_INPUTS) delete v[k];
if (mine) {
if (m === "바로" && blankNum(v.손료계수)) {
const e = engineVals(cur, true);
v.손료계수 = ["상각비계수", "정비비계수", "관리비계수"].reduce(
(s, k) => s + (Number(e[k]) || 0),
0,
);
}
if (m === "직접" && !cur.시간당) cur.시간당 = Object.fromEntries((["노무비", "재료비", "경비"] as const).map((k) => [k, Math.round(lastAnswer?.[k] ?? 0)])); // prettier-ignore
cur.방식 = m;
}
recoef();
rebuild();
drawAll();
changed(true);
};
const blankNum = (x: unknown): boolean => typeof x !== "number" || !x;
const drawBase = (): void => {
const keep = baseBox.firstElementChild?.classList.contains("is-collapsed") ?? false;
const mode = modeOf(cur, mine);
const owned = new Set(
(parts && info?.키 ? ownedCols(parts) : [])
.map((c) => slotOf(c, mode, mine))
.flatMap((s) => (s && s[0] === "값" ? [s[1]] : [])),
);
const pumsem = mode === "품셈";
const keys =
mode === "직접"
? []
: [...new Set([...BASE_KEYS, ...Object.keys(cur.값 ?? {})])].filter(
(k) => !owned.has(k) && !(MACHINE_INPUTS as readonly string[]).includes(k) && !(pumsem && (MACHINE_COEFS as readonly string[]).includes(k)), // prettier-ignore
);
const textBox = (
lab: string,
get: string,
was: string,
set: (v: string) => void,
): HTMLElement => {
const f = createInputField({ label: lab, value: get, size: 28 });
f.input.readOnly = ro;
f.root.classList.toggle("is-changed", get !== was);
f.input.addEventListener("input", () => {
set(f.input.value);
f.root.classList.toggle("is-changed", f.input.value !== was);
o.onChange?.();
});
return f.root;
};
const seg = createSegment({
options: [
{ value: "바로", label: L("MF_CoefDirect") },
{ value: "품셈", label: L("MF_CoefPumsem") },
...(mine ? [{ value: "직접", label: L("MF_HourlyDirect") }] : []),
],
value: mode,
size: 24,
ariaLabel: L("MS_Mode"),
onChange: (v) => setMode(v as MachineMode),
});
if (ro) seg.root.querySelectorAll("button").forEach((b) => (b.disabled = true));
const row = (children: HTMLElement[]): HTMLElement => el("div", { className: "lstk-frow", children }); // prettier-ignore
const card = createPanelCard(L("MS_Base"), keep);
card.append(
row([
textBox(L("MF_Name"), name, text(orig.이름), (v) => (name = v)),
textBox(L("MF_Spec"), spec, text(orig.규격), (v) => (spec = v)),
]),
row([el("span", { className: "lstk-kind", text: L("MS_Mode") }), seg.root]),
...(pumsem
? [
row(MACHINE_INPUTS.map((k) => numField(k))),
row([
el("span", { className: "lstk-kind", text: L("MS_Coefs") }),
...MACHINE_COEFS.filter((k) => !owned.has(k)).map((k) => numField(k, true)),
]),
]
: []),
...(keys.length
? [row(keys.map((k) => (PICKS.includes(k) ? pickField(k) : numField(k))))]
: []),
);
baseBox.replaceChildren(card);
};
const similarBox = (): HTMLElement[] => {
if (!o.findSimilar || ro) return [];
const find = createFindList({ placeholder: L("MF_SimilarFind") });
const card = createPanelCard(L("MF_Similar"), !!(orig.이름 || orig.값 && Object.keys(orig.값).length)); // prettier-ignore
card.classList.add("lstk-msheet-similar");
card.append(find.root);
attachCollapsible(card);
let n = 0;
let wait: number | undefined;
const look = async (): Promise<void> => {
const me = ++n;
const got = await o.findSimilar!(find.search.value.trim()).catch(() => []);
if (me !== n) return;
find.hint.textContent = got.length ? "" : L("MF_SimilarNone");
find.setRows(
got.map((m) => ({
key: m.key,
name: `${m.name} ${m.spec}`.trim(),
tag: m.tag,
sub: m.key,
onPick: () => {
orig = clone(m.row);
cur = clone(m.row);
name = m.name;
spec = m.spec;
errors.clear();
info = null;
parts = null;
delete (holder as { [COVER]?: unknown })[COVER];
card.classList.add("is-collapsed");
setNotice(L("MF_SimilarCopied").replace("{value}", `${m.name} ${m.spec}`.trim()));
o.onSimilar?.(m);
drawAll();
changed(true);
},
})),
);
};
find.search.addEventListener("input", () => {
window.clearTimeout(wait);
wait = window.setTimeout(() => void look(), 250);
});
void look();
return [card];
};
// ── 오른쪽 · 경고 ──
let lastAnswer: DocAnswer | null = null;
const drawRight = (): void => {
const d = sub();
const head = el("div", {
className: "lstk__bar",
children: [
el("strong", { text: L("MS_Out") }),
...(info?.키 ? [el("span", { className: "ui-text ui-text--muted", text: `${info.키} ${info.이름 ?? ""}`.trim() })] : []), // prettier-ignore
],
});
if (!info) {
lastAnswer = null;
foot.replaceChildren();
return void right.replaceChildren(head, createText(L("MS_Loading"), "muted"));
}
if (!d) {
lastAnswer = null;
foot.replaceChildren();
const why = info.까닭 ? ` — ${info.까닭}` : "";
return void right.replaceChildren(head, createText(`${L("MS_NoFormula")}${why}`, "warning"));
}
const shaped = coverDoc(d, given, coverOf(holder), rowEntry(d, engineVals(orig, mine)));
const a = plain(shaped, given);
if (cache.takeMissing()) ask();
lastAnswer = a;
right.replaceChildren(
head,
...(info.까닭 ? [createText(info.까닭, "warning")] : []),
buildOut(bindDoc(shaped, info.값 ?? {}) as StackDoc, a, {
cache,
open: opened,
toggle: (at) => {
if (opened.has(at)) opened.delete(at);
else opened.add(at);
drawRight();
},
miss: () => ask(),
redraw: () => drawRight(),
}),
);
foot.replaceChildren(docFoot(a));
};
const drawWarn = (): void => {
const vals: MachineValues = crewView({ ...engineVals(cur, mine), ...(cur.값 ?? {}) });
if (unitOf("손료계수") !== UNIT.손료계수) delete vals.손료계수; // 일 단위 손료(임업) = 시간당 범위 아님
warn.replaceChildren(
...rangeWarnings(vals).map(([k, r]) =>
createText(L("MS_Range").replace("{name}", label(k)).replace("{value}", r), "warning"),
),
);
};
const drawLive = (): void => {
drawCtl();
drawRight();
drawWarn();
};
const drawAll = (): void => {
drawBase();
drawLive();
};
const setNotice = (t: string): void => {
notice.replaceChildren(...(t ? [createText(t, "warning")] : []));
};
left.append(...similarBox(), notice, warn, ctlBox, baseBox);
drawAll();
ask(true);
return {
root,
setNotice,
state: (): MachineSheetState => ({
name: name.trim(),
spec: spec.trim(),
row: rowOut(),
mode: modeOf(cur, mine),
errors: [...errors.values()],
changed: JSON.stringify([name, spec, cur]) !== start,
}),
};
}