Files
Aislo/M01_MasterData/SheetLogic/M01_SL_Item.ts
T
eomsangdonandClaude Sonnet 5.5 e5955b705d fix(m01-sl): 비율 바탕 이음은 아는 상자만 · 손선 보존, 결과 칸 값/단위, 다른 로직 손잡이
- refreshBases 를 Model connect/disconnect 로 바꿔 모르는 항목 상자와 손으로 이은 선을 안 건드림, applyItemLayout 내보냄
- 결과 칸: 돈 아닌 결과는 값과 단위, 「100㎡ 당」 표기
- 새 LogicTools: 다른 로직 찾기 · 고르기 · 넘길 값 · 결과 알약

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr
2026-10-01 13:55:04 +09:00

648 lines
23 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.
/* =============================================================================
* M01_SL_Item.ts
* 시트 로직 — 항목 상자 손잡이. 항목 상자를 고르면 시트 판 위에 붙음.
* 종류(노무 · 재료 · 장비 · 일위대가 · 비율) 고르기 → 상자 시트를 항목 틀로 채움 · 알약 정하기
* 단가 칸 = 바깥 값(노임 · 자재 · 장비 = 기계경비 로직 · 일위대가 = 기존 로직 결과) — Api `slOutside` 로 받아 넣음
* 수량 칸 = 입력 알약 · 금액 칸 = 수량 × 단가(ROUND 소수 첫째) · 출력 알약 노무비 / 재료비 / 경비
* 비율 = 「노무비의 [3]%」 꼴 — 바탕 = 다른 항목 상자의 그 비목 합(시트 사이 참조)
* 모양은 docs/raw/M01_쉬운단가/시트로직_정함.md 3 · 4. 이 폴더째 지워도 됨.
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, createSelectField, el } from "@ui/ui_template_elements";
import type { Cell, Sheet } from "../../A00_Common/spreadsheet/spreadsheet_types";
import type { LogicInput } from "../M01_MasterData_UI_Logic_Api";
import { searchElements } from "../M01_MasterData_UI_Logic_Api";
import { formatPrice } from "../M01_MasterData_UI_Logic_Money";
import { openEasyPick } from "../EasyCalc/M01_EasyCalc_Pick";
import type { PickResult } from "../EasyCalc/M01_EasyCalc_Model";
import { slOutside, slPlain, type SlField, type SlOutside, type SlOutsideAt } from "./M01_SL_Api";
import { connect, disconnect, type SheetLogic, type SlNode, type SlPill } from "./M01_SL_Model";
import "./M01_SL_Item.css";
const TEXT = {
Title: ["항목", "Item"],
Kind: ["종류", "Kind"],
Labor: ["노무", "Labor"],
Material: ["재료", "Material"],
Machine: ["장비", "Machine"],
Logic: ["일위대가", "Unit-price job"],
Rate: ["비율", "Rate"],
ToolLoss: ["공구손료", "Small tools"],
Pick: ["고르기", "Choose"],
Repick: ["다시 고르기", "Choose again"],
None: ["아직 고르지 않았어요", "Nothing chosen yet"],
Qty: ["수량", "Quantity"],
Price: ["단가", "Unit price"],
PriceLabor: ["노무 단가", "Labor price"],
PriceMat: ["재료 단가", "Material price"],
PriceExp: ["경비 단가", "Expense price"],
Reload: ["단가 다시 가져오기", "Reload prices"],
Loading: ["단가 가져오는 중", "Loading prices"],
Linked: ["다른 상자에서 이어진 값", "Value from another box"],
Split: [
"장비는 노무비 · 재료비 · 경비 셋으로 나뉘어요",
"Machines split into labor, material and expense",
],
Formula: ["금액 = 수량 × 단가 (소수 첫째 자리까지)", "Amount = quantity × price (one decimal)"],
RatePct: ["율(%)", "Rate (%)"],
Base: ["무엇의", "Of what"],
Into: ["넣을 자리", "Goes into"],
Fit: ["바탕 맞추기", "Refresh base"],
BaseHint: ["바탕 = 다른 항목 상자의 그 합계", "Base = total of the other item boxes"],
Labor3: ["노무비", "Labor cost"],
Mat3: ["재료비", "Material cost"],
Exp3: ["경비", "Expense"],
Bad: ["숫자를 넣어 주세요", "Enter a number"],
Cant: ["지금은 바꿀 수 없어요", "Cannot change this now"],
NoPrice: ["단가를 찾지 못했어요", "Price not found"],
Won: ["원", "KRW"],
} as const;
type TextKey = keyof typeof TEXT;
const ti = (key: TextKey): string => TEXT[key][currentLanguageIndex];
export type ItemKind = "노무" | "재료" | "장비" | "일위대가" | "비율";
export type CostName = "노무비" | "재료비" | "경비";
const COSTS: CostName[] = ["노무비", "재료비", "경비"];
/** 항목 상자가 `node.ref` 에 글(JSON)로 들고 있는 것 */
export interface ItemRef {
what: ItemKind;
/** 고른 것의 PickResult.element 그대로 */
element?: unknown;
/** 크기(규격) */
spec?: string;
/** 수량 단위 */
unit?: string;
/** 고른 것이 받는 값(장비 지역 · 일위대가 넘길 값)과 지금 값 */
need?: LogicInput[];
inputs?: Record<string, unknown>;
/** 비율 */
base?: CostName;
into?: CostName;
}
/** 칸 자리 — 항목 틀 둘(돈 · 비율) */
const MONEY = {
qty: "B1",
labor: "B2",
mat: "B3",
exp: "B4",
unit: "B5",
amount: "B6",
cost: { 노무비: "B7", 재료비: "B8", 경비: "B9" },
used: "B10",
};
const RATE = {
rate: "B1",
base: "B2",
baseKind: "B3",
amount: "B4",
into: "B5",
cost: { 노무비: "B6", 재료비: "B7", 경비: "B8" },
used: "B9",
unit: "B10",
};
export function itemRefOf(node: SlNode): ItemRef | null {
try {
const r = node.ref ? (JSON.parse(node.ref) as ItemRef) : null;
return r && r.what ? r : null;
} catch {
return null;
}
}
/** 항목 상자의 비목 칸 자리 — 합계 · 바탕이 같은 자리를 읽음 */
export const costCell = (ref: ItemRef, cost: CostName): string =>
ref.what === "비율" ? RATE.cost[cost] : MONEY.cost[cost];
/* ---- 시트 칸 고치기(받은 것은 안 바꿈) ---- */
const text = (v: string): Cell => ({ 값: v });
const num = (v: number): Cell => ({ 값: v });
const calc = (f: string): Cell => ({ 식: f });
function editSheet(
sl: SheetLogic,
node: SlNode,
edit: (cells: Record<string, Cell>, s: Sheet) => void,
): SheetLogic {
const 시트 = sl.workbook.시트.map((s) => {
if (s.id !== node.sheetId) return s;
const next = { ...s, 칸: { ...s.칸 } };
edit(next.칸, next);
return next;
});
return { ...sl, workbook: { ...sl.workbook, 시트 } };
}
const sheetOf = (sl: SheetLogic, node: SlNode): Sheet | undefined =>
sl.workbook.시트.find((s) => s.id === node.sheetId);
const nodeOf = (sl: SheetLogic, id: string): SlNode | undefined =>
sl.nodes.find((n) => n.id === id);
const withNode = (sl: SheetLogic, node: SlNode): SheetLogic => ({
...sl,
nodes: sl.nodes.map((n) => (n.id === node.id ? node : n)),
});
const listRule = (id: string, cell: string, values: string[]) => ({
id,
범위: [{ r0: Number(cell.slice(1)) - 1, c0: 1, r1: Number(cell.slice(1)) - 1, c1: 1 }],
규칙: { 종류: "목록" as const, 값들: values },
어기면: "막음" as const,
});
/* ---- 항목 틀 ---- */
/** 일위대가표가 읽는 출력 알약 이름 */
export const USED = "사용 수량";
export const PILL_PRICE = "단가";
export const PILL_AMOUNT = "금액";
const PILL_BASE = "바탕 금액";
function moneyLayout(ref: ItemRef, qty: Cell | undefined, outside: SlField[]) {
const label = (cell: string, name: string): [string, Cell] => ["A" + cell.slice(1), text(name)];
const cells: Record<string, Cell> = Object.fromEntries([
label(MONEY.qty, ti("Qty")),
label(MONEY.labor, ti("PriceLabor")),
label(MONEY.mat, ti("PriceMat")),
label(MONEY.exp, ti("PriceExp")),
label(MONEY.unit, PILL_PRICE),
label(MONEY.amount, PILL_AMOUNT),
label(MONEY.cost.노무비, ti("Labor3")),
label(MONEY.cost.재료비, ti("Mat3")),
label(MONEY.cost.경비, ti("Exp3")),
label(MONEY.used, USED),
]);
if (qty) cells[MONEY.qty] = qty;
// 바깥 값이 아닌 단가 칸은 0 으로 고정
for (const [cell, field] of [
[MONEY.labor, "노무비"],
[MONEY.mat, "재료비"],
[MONEY.exp, "경비"],
] as const) {
if (!outside.includes(field)) cells[cell] = num(0);
}
cells[MONEY.unit] = calc("B2+B3+B4");
cells[MONEY.amount] = calc("ROUND(B1*B5,1)");
cells[MONEY.cost.노무비] = calc("ROUND(B1*B2,1)");
cells[MONEY.cost.재료비] = calc("ROUND(B1*B3,1)");
cells[MONEY.cost.경비] = calc("ROUND(B1*B4,1)");
cells[MONEY.used] = calc("B1");
const unit = ref.unit ?? "";
const won = ti("Won");
const out = (name: string, cell: string, u = won): SlPill => ({ name, cell, unit: u });
const pillsIn: SlPill[] = [
{ name: ti("Qty"), cell: MONEY.qty, unit },
...(outside.includes("노무비")
? [{ name: ti("PriceLabor"), cell: MONEY.labor, unit: won, outside: true }]
: []),
...(outside.includes("재료비")
? [{ name: ti("PriceMat"), cell: MONEY.mat, unit: won, outside: true }]
: []),
...(outside.includes("경비")
? [{ name: ti("PriceExp"), cell: MONEY.exp, unit: won, outside: true }]
: []),
];
const pillsOut: SlPill[] = [
out(PILL_PRICE, MONEY.unit),
out(PILL_AMOUNT, MONEY.amount),
out(ti("Labor3"), MONEY.cost.노무비),
out(ti("Mat3"), MONEY.cost.재료비),
out(ti("Exp3"), MONEY.cost.경비),
out(USED, MONEY.used, unit),
];
return { cells, pillsIn, pillsOut, rules: [] as ReturnType<typeof listRule>[] };
}
function rateLayout(ref: ItemRef, rate: Cell | undefined) {
const base = ref.base ?? "노무비";
const into = ref.into ?? "재료비";
const cells: Record<string, Cell> = {
A1: text(ti("RatePct")),
B1: rate ?? num(3),
A2: text(PILL_BASE),
B2: num(0),
A3: text(ti("Base")),
B3: text(base),
A4: text(PILL_AMOUNT),
B4: calc("ROUND(B2*B1/100,1)"),
A5: text(ti("Into")),
B5: text(into),
A6: text(ti("Labor3")),
B6: calc('IF(B5="노무비",B4,0)'),
A7: text(ti("Mat3")),
B7: calc('IF(B5="재료비",B4,0)'),
A8: text(ti("Exp3")),
B8: calc('IF(B5="경비",B4,0)'),
A9: text(USED),
B9: calc("B1"),
A10: text(ti("Price")),
B10: calc("B2"),
};
const pillsIn: SlPill[] = [
{ name: ti("RatePct"), cell: RATE.rate, unit: "%" },
{ name: PILL_BASE, cell: RATE.base, unit: ti("Won") },
];
const pillsOut: SlPill[] = [
{ name: PILL_PRICE, cell: RATE.unit, unit: ti("Won") },
{ name: PILL_AMOUNT, cell: RATE.amount, unit: ti("Won") },
{ name: ti("Labor3"), cell: RATE.cost.노무비, unit: ti("Won") },
{ name: ti("Mat3"), cell: RATE.cost.재료비, unit: ti("Won") },
{ name: ti("Exp3"), cell: RATE.cost.경비, unit: ti("Won") },
{ name: USED, cell: RATE.used, unit: "%" },
];
const rules = [listRule("sl-base", RATE.baseKind, COSTS), listRule("sl-into", RATE.into, COSTS)];
return { cells, pillsIn, pillsOut, rules };
}
/** 어떤 바깥 값이 필요한가 — 노임 = 노무 · 자재 = 재료 · 장비 · 일위대가 = 셋 */
const outsideFields = (what: ItemKind): SlField[] =>
what === "노무" ? ["노무비"] : what === "재료" ? ["재료비"] : what === "비율" ? [] : COSTS;
const FIELD_CELL: Record<string, string> = {
노무비: MONEY.labor,
재료비: MONEY.mat,
경비: MONEY.exp,
};
/** 상자 시트를 항목 틀로 채움 — 예전 수량 칸(값 · 이어진 참조)은 살림 */
/** 상자 시트를 항목 틀로 채움 — 가져오기(Import)도 이 틀 · ref 로 맞춤 */
export function applyItemLayout(
sl: SheetLogic,
node: SlNode,
ref: ItemRef,
qty?: number | null,
): SheetLogic {
return applyLayout(sl, node, ref, qty);
}
function applyLayout(sl: SheetLogic, node: SlNode, ref: ItemRef, qty?: number | null): SheetLogic {
const sheet = sheetOf(sl, node);
const oldRef = itemRefOf(node);
const keep =
oldRef && oldRef.what !== "비율" && ref.what !== "비율" ? sheet?.칸[MONEY.qty] : undefined;
const keepRate =
oldRef?.what === "비율" && ref.what === "비율" ? sheet?.칸[RATE.rate] : undefined;
const given: Cell | undefined = qty !== undefined && qty !== null ? num(qty) : undefined;
const lay =
ref.what === "비율"
? rateLayout(ref, keepRate)
: moneyLayout(ref, given ?? keep, outsideFields(ref.what));
const next = editSheet(sl, node, (cells, s) => {
for (const k of Object.keys(cells)) delete cells[k];
Object.assign(cells, lay.cells);
if (lay.rules.length) s.유효성 = lay.rules;
else delete s.유효성;
});
const names = new Set([...lay.pillsIn.map((p) => p.name), ...lay.pillsOut.map((p) => p.name)]);
const kept = (e: { from: { node: string; pill: string }; to: { node: string; pill: string } }) =>
(e.from.node !== node.id || names.has(e.from.pill)) &&
(e.to.node !== node.id || names.has(e.to.pill));
return {
...withNode(next, {
...node,
ref: JSON.stringify(ref),
pillsIn: lay.pillsIn,
pillsOut: lay.pillsOut,
stage: 3,
}),
edges: next.edges.filter(kept),
};
}
/** 비율 상자마다 「바탕」 = 다른 항목 상자의 그 비목 출력 — 선 잇기(Model `connect`)로 함
* · 아는 항목 상자(ref 가 이 틀 것)만 바탕이 됨 — 모르는 상자(가져온 것 등)는 읽지도 쓰지도 않음
* · 비목을 바꾸면 옛 비목 선만 끊음 — 손으로 이은 다른 선 · 그 칸 수식은 그대로 */
export function refreshBases(sl: SheetLogic): SheetLogic {
let out = sl;
for (const node of sl.nodes) {
const ref = itemRefOf(node);
if (!ref || ref.what !== "비율" || !node.pillsIn.some((p) => p.name === PILL_BASE)) continue;
const base = ref.base ?? "노무비";
const known = (n: SlNode): boolean => {
const r = itemRefOf(n);
return n.id !== node.id && n.kind === "item" && !!r && r.what !== "비율";
};
for (const e of out.edges.filter((x) => x.to.node === node.id && x.to.pill === PILL_BASE)) {
const from = out.nodes.find((n) => n.id === e.from.node);
if (from && known(from) && e.from.pill !== base && (COSTS as string[]).includes(e.from.pill))
out = disconnect(out, e.id);
}
for (const src of out.nodes.filter(known)) {
const there = out.edges.some(
(e) =>
e.from.node === src.id &&
e.from.pill === base &&
e.to.node === node.id &&
e.to.pill === PILL_BASE,
);
if (!there)
out = connect(out, { node: src.id, pill: base }, { node: node.id, pill: PILL_BASE }).sl;
}
}
return out;
}
/** 이 항목 상자들이 필요로 하는 바깥 값 자리 — Api `slOutside` · 저장이 같은 목록을 씀 */
export function itemOutsides(sl: SheetLogic): SlOutsideAt[] {
return sl.nodes.flatMap((node) => {
const ref = itemRefOf(node);
const sheet = sheetOf(sl, node);
if (!ref || !sheet) return [];
const items = outsideItems(ref, node.id);
return items.map(({ field, item }) => ({ item, sheet: sheet.id, cell: FIELD_CELL[field] }));
});
}
function outsideItems(ref: ItemRef, id: string): { field: SlField; item: SlOutside }[] {
const key = typeof ref.element === "string" ? ref.element : "";
const name = (f: string): string => `${id}:${f}`;
if (ref.what === "노무")
return [{ field: "노무비", item: { id: name("노무비"), kind: "노임", key } }];
if (ref.what === "재료") {
const k = (ref.element as { 키?: string } | null)?.키 ?? "";
return [{ field: "재료비", item: { id: name("재료비"), kind: "자재", key: k } }];
}
const inputs = ref.inputs ?? {};
if (ref.what === "장비") {
return COSTS.map((f) => ({
field: f,
item: { id: name(f), kind: "장비" as const, element: key, inputs, field: f },
}));
}
if (ref.what === "일위대가") {
const logicKey = key.match(/로직\(\s*([A-Z]{2}\d{6})/)?.[1] ?? "";
return COSTS.map((f) => ({
field: f,
item: { id: name(f), kind: "로직" as const, key: logicKey, inputs, field: f },
}));
}
return [];
}
/** 단가를 서버에서 받아 단가 칸에 넣음 — 못 구한 것은 까닭을 돌려줌(쉬운 말) */
export async function loadPrices(
sl: SheetLogic,
nodeId: string,
): Promise<{ sl: SheetLogic; why: string | null }> {
const node = nodeOf(sl, nodeId);
const ref = node && itemRefOf(node);
if (!node || !ref) return { sl, why: null };
const items = outsideItems(ref, node.id);
if (!items.length) return { sl, why: null };
const got = await slOutside(items.map((i) => i.item));
let why: string | null = null;
const next = editSheet(sl, node, (cells) => {
for (const { field, item } of items) {
const v = got.find((g) => g.id === item.id);
if (v?.ok && typeof v.value === "number") cells[FIELD_CELL[field]] = num(v.value);
else why = v?.reason ?? ti("NoPrice");
}
});
return { sl: next, why };
}
const PICK_KIND = { 노무: "person", 재료: "material", 장비: "machine", 일위대가: "logic" } as const;
const KIND_OF_PICK: Record<PickResult["kind"], ItemKind> = {
person: "노무",
material: "재료",
machine: "장비",
logic: "일위대가",
};
/** 재료는 검색어 묶음만 오므로 단가를 부를 번호를 찾아 둠 */
async function materialKey(el_: Record<string, string>): Promise<string> {
const found = await searchElements("재료", el_["검색어"] ?? "");
const hit = found.items.find(
(it) => `${it.이름 ?? ""} ${it.규격 ?? ""}`.trim() === (el_["검색어"] ?? "").trim(),
);
return (hit ?? found.items[0])?.ref ?? "";
}
/** 고른 것으로 항목 상자를 채움(단가는 따로 loadPrices) */
export async function applyPick(
sl: SheetLogic,
nodeId: string,
pick: PickResult,
): Promise<SheetLogic> {
const node = nodeOf(sl, nodeId);
if (!node) return sl;
const what = KIND_OF_PICK[pick.kind];
let element = pick.element;
if (what === "재료") {
const e = { ...(pick.element as Record<string, string>) };
element = { ...e, 키: await materialKey(e) };
}
const inputs: Record<string, unknown> = {};
for (const n of pick.needInputs ?? []) inputs[n.이름] = n.고르기?.[0] ?? n.범위?.[0];
const ref: ItemRef = {
what,
element,
spec: pick.spec,
unit: pick.unit,
need: pick.needInputs,
inputs,
};
const named = withNode(sl, { ...node, name: `${pick.name}${pick.spec ? ` ${pick.spec}` : ""}` });
return refreshBases(applyLayout(named, nodeOf(named, nodeId)!, ref, pick.qty));
}
/* ---- 손잡이 ---- */
const cellValue = (sl: SheetLogic, node: SlNode, cell: string): Cell | undefined =>
sheetOf(sl, node)?.칸[cell];
export function renderItemTools(
host: HTMLElement,
sl: SheetLogic,
nodeId: string,
on: { change(sl: SheetLogic): void },
): void {
const node = nodeOf(sl, nodeId);
const msg = el("p", { className: "m01-slitem__msg" });
if (!node) {
host.replaceChildren();
return;
}
const ref = itemRefOf(node);
const emit = (make: () => SheetLogic): void => {
msg.textContent = "";
try {
on.change(make());
} catch (e) {
msg.textContent = slPlain(e) || ti("Cant");
}
};
const setRef = (patch: Partial<ItemRef>): SheetLogic => {
const next: ItemRef = { ...ref!, ...patch };
return refreshBases(applyLayout(sl, node, next));
};
const chosen = (): HTMLElement =>
el("p", {
className: "m01-slitem__chosen",
text: ref && ref.what !== "비율" ? node.name : ti("None"),
});
const pickAndLoad = async (kind: ItemKind): Promise<void> => {
if (kind === "비율") {
emit(() => {
const named = /^항목 \d+$/.test(node.name)
? withNode(sl, { ...node, name: ti("ToolLoss") })
: sl;
return refreshBases(
applyLayout(named, nodeOf(named, node.id)!, {
what: "비율",
base: "노무비",
into: "재료비",
}),
);
});
return;
}
const got = await openEasyPick(PICK_KIND[kind]);
if (!got) return;
msg.textContent = ti("Loading");
try {
const filled = await applyPick(sl, node.id, got);
on.change(filled);
const priced = await loadPrices(filled, node.id);
msg.textContent = priced.why ?? "";
on.change(priced.sl);
} catch (e) {
msg.textContent = slPlain(e) || ti("Cant");
}
};
const kindField = createSelectField({
label: ti("Kind"),
options: (["노무", "재료", "장비", "일위대가", "비율"] as ItemKind[]).map((k) => ({
value: k,
text: ti(
k === "노무"
? "Labor"
: k === "재료"
? "Material"
: k === "장비"
? "Machine"
: k === "비율"
? "Rate"
: "Logic",
),
})),
value: ref?.what ?? "노무",
compact: true,
onChange: (v) => void pickAndLoad(v as ItemKind),
});
const numberBox = (cell: string, label: string, unit: string): HTMLElement => {
const c = cellValue(sl, node, cell);
const linked = c?.식 !== undefined;
const box = el("input", {
className: "m01-slitem__input",
attrs: { type: "text", inputmode: "decimal", "aria-label": label },
});
box.value = linked ? "" : c?.값 === undefined ? "" : String(c.값);
box.readOnly = linked;
box.addEventListener("change", () => {
const raw = box.value.trim();
const n = Number(raw);
if (raw === "" || Number.isNaN(n)) {
msg.textContent = ti("Bad");
return;
}
emit(() => editSheet(sl, node, (cells) => (cells[cell] = num(n))));
});
return el("label", {
className: "m01-slitem__field",
children: [
el("span", { text: `${label}${unit ? ` (${unit})` : ""}` }),
box,
...(linked ? [el("small", { className: "m01-slitem__muted", text: ti("Linked") })] : []),
],
});
};
const costSelect = (
label: string,
value: CostName,
patch: (c: CostName) => Partial<ItemRef>,
): HTMLElement => {
const f = createSelectField({
label,
options: COSTS.map((c) => ({ value: c, text: c })),
value,
compact: true,
onChange: (v) => emit(() => setRef(patch(v as CostName))),
});
return f.root;
};
const body: HTMLElement[] = [kindField.root];
if (ref && ref.what !== "비율") {
const price = (cell: string, label: string): HTMLElement =>
el("div", {
className: "m01-slitem__price",
children: [
el("span", { text: label }),
el("strong", { text: formatPrice(cellValue(sl, node, cell)?.값 ?? 0) }),
],
});
body.push(
chosen(),
createButton({
label: ref.element === undefined ? ti("Pick") : ti("Repick"),
size: "sm",
onClick: () => void pickAndLoad(ref.what),
}),
numberBox(MONEY.qty, ti("Qty"), ref.unit ?? ""),
el("div", {
className: "m01-slitem__prices",
children: [
price(MONEY.labor, ti("PriceLabor")),
price(MONEY.mat, ti("PriceMat")),
price(MONEY.exp, ti("PriceExp")),
],
}),
);
if (ref.element !== undefined) {
body.push(
createButton({
label: ti("Reload"),
size: "sm",
variant: "ghost",
onClick: () =>
void loadPrices(sl, node.id).then(
(r) => {
msg.textContent = r.why ?? "";
on.change(r.sl);
},
(e) => (msg.textContent = slPlain(e) || ti("NoPrice")),
),
}),
);
}
body.push(
el("p", {
className: "m01-slitem__muted",
text: ref.what === "장비" || ref.what === "일위대가" ? ti("Split") : ti("Formula"),
}),
);
} else if (ref) {
body.push(
numberBox(RATE.rate, ti("RatePct"), "%"),
costSelect(ti("Base"), ref.base ?? "노무비", (c) => ({ base: c })),
costSelect(ti("Into"), ref.into ?? "재료비", (c) => ({ into: c })),
createButton({
label: ti("Fit"),
size: "sm",
variant: "ghost",
onClick: () => emit(() => refreshBases(sl)),
}),
el("p", { className: "m01-slitem__muted", text: ti("BaseHint") }),
);
} else {
body.push(chosen());
}
host.replaceChildren(
el("strong", { className: "m01-slitem__title", text: `${ti("Title")} · ${node.name}` }),
el("div", { className: "m01-slitem__body", children: body }),
msg,
);
}