- 기계 보기 부품(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>
557 lines
20 KiB
TypeScript
557 lines
20 KiB
TypeScript
/* =============================================================================
|
||
* line_stack_machine_sheet.ts
|
||
* 기계 보기(PLAN 840-2 · 요구 853 · 862 「기계 보기 공용」) — 기계 한 대를 단가산출 수식 보기와 닮은 꼴로:
|
||
* 왼쪽 = 기계 제어 구역(836-1 `line_stack_machine_ctl` 그대로 · 기계 표 꼴 `sheet` — 값 칸 · 조종원 인력 줄 · 잡품 줄) +
|
||
* 「기본 정보 · 손료 근거」 접이 카드(이름 · 규격 · 손료 방식 · 품셈 비율 · 식이 안 읽는 값 칸) + 비슷한 기계에서 시작(새 기계)
|
||
* 오른쪽 = 그 기계 시간당 경비 산출근거(`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,
|
||
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 {
|
||
applyCover,
|
||
bindDoc,
|
||
coverFrom,
|
||
engineVals,
|
||
jobCode,
|
||
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;
|
||
base = defaultsOf(d, (plain(d, given).값 ?? {}) as Record<string, unknown>);
|
||
putCover(holder, no, coverFrom(parts, d.금액 ?? [], 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 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, 키: `LB:${jobCode(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,
|
||
sheet: true,
|
||
lock,
|
||
},
|
||
{ ...link, 키: info.키 },
|
||
);
|
||
if (zone && lock.length && mode === "품셈") zone.title = L("MS_Locked");
|
||
ctlBox.replaceChildren(...(zone ? [zone] : []));
|
||
};
|
||
|
||
const numField = (k: string, read = false): HTMLElement => {
|
||
const v = cur.값?.[k];
|
||
const f = createInputField({ label: label(k), value: text(v), size: 28, ...(UNIT[k] ? { unit: UNIT[k] } : {}) }); // 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));
|
||
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 = { ...engineVals(cur, mine), ...(cur.값 ?? {}) };
|
||
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,
|
||
}),
|
||
};
|
||
}
|