- 단위 규칙 한 벌(common_util_line_stack_unit) — 왼쪽 값 칸 · 식 줄 · 옵션 · 오른쪽 산출근거 · 기계 제어 구역 · 기계 보기가 같이 씀 · 메타 단위(구분 따름 · 수(…) · 원문 단위 없음) 안 그림 · %(설명) = % · 10^-7 = 10⁻⁷ · 기계 칸 연결 = 기계 표 칸 단위 · 코드 값 쉼표 없음 - 비목 · 금액 줄 = 「단가 원/단위 × (수량 식) 단위 = 금액 원」 · 소계 · 합계 · 계 = 원 · 단가 · 수량 한 덩이(줄 바뀜에 단위 안 떨어짐) - 기계경비식 겹분수 → 중간 줄(상여 · 휴지 · 손료계수 · common_util_line_stack_frac) · 한 줄 분수 한 층 - 기계 제어 구역 단위 = 칸 밖 바로 뒤 · 기계 보기 오른쪽 = 박기 전 기계 칸 단위 - 휴대폰 폭(560 px 밑) 산출근거 금액 칸 넷 걷음(가로 넘침 0) · 분수 줄 위아래 여유 - 전수 1,520 표시 탓 43,218 → 0 · 기계 겹분수 2 층 넘는 줄 1,040 → 0 · 금액 · 식 · 문서 자료 불변(그리기만) - 시험 test_845_2_unit_show(차린 줄 글 전수 지킴) · shownUnit 걷음(규칙 한 벌로) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
572 lines
21 KiB
TypeScript
572 lines
21 KiB
TypeScript
/* =============================================================================
|
|
* 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 {
|
|
MACHINE_UNIT,
|
|
machineUnit,
|
|
unitText,
|
|
varUnit,
|
|
} from "../common_util/common_util_line_stack_unit";
|
|
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 PICKS = ["주연료종류"];
|
|
/** 식이 바뀔 수 있는 칸(서버 식 고름) */
|
|
const PICKY = ["주연료", "전력", "손료계수", ...MACHINE_COEFS];
|
|
|
|
const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v ?? null)) as T;
|
|
/** 값 박은 식(그림용) — 박기 전 기계 칸 단위를 남김(박으면 칸 연결이 수로 바뀌어 단위를 못 읽음 · 845-2) */
|
|
const boundView = (d: FormulaDoc, raw: MachineValues): FormulaDoc => {
|
|
const b = bindDoc(d, raw);
|
|
b.변수?.forEach((v, i) => {
|
|
const u = varUnit(d.변수?.[i]);
|
|
if (u && !v.단위) v.단위 = u;
|
|
});
|
|
return b;
|
|
};
|
|
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] : []));
|
|
};
|
|
|
|
/** 칸 단위 — 줄 `단위` 글(일 단위 손료 등 · 기계 표 기본과 다를 때) › 기계 표 칸 단위(845-2 한 벌) */
|
|
const unitOf = (k: string): string => {
|
|
const u = text((cur.단위 as MachineValues | undefined)?.[k]);
|
|
return u && u !== MACHINE_UNIT[k] ? unitText(u) : machineUnit(k);
|
|
};
|
|
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(boundView(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("손료계수") !== machineUnit("손료계수")) 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,
|
|
}),
|
|
};
|
|
}
|