/* ============================================================================= * 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 { quoteSheet } from "../../A00_Common/spreadsheet/spreadsheet_address"; 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 type { SheetLogic, SlNode, 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; /** 비율 */ 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, 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 = 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[] }; } function rateLayout(ref: ItemRef, rate: Cell | undefined) { const base = ref.base ?? "노무비"; const into = ref.into ?? "재료비"; const cells: Record = { 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 = { 노무비: MONEY.labor, 재료비: MONEY.mat, 경비: MONEY.exp, }; /** 상자 시트를 항목 틀로 채움 — 예전 수량 칸(값 · 이어진 참조)은 살림 */ 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), }; } /** 비율 상자마다 「바탕」 = 다른 항목 상자의 그 비목 합 — 시트 사이 참조 · 선도 함께 */ export function refreshBases(sl: SheetLogic): SheetLogic { let out = sl; for (const node of sl.nodes) { const ref = itemRefOf(node); if (!ref || ref.what !== "비율") continue; const base = ref.base ?? "노무비"; const sources = out.nodes.filter((n) => { const r = itemRefOf(n); return n.kind === "item" && r && r.what !== "비율"; }); const refs = sources .map((n) => { const s = sheetOf(out, n); return s ? `${quoteSheet(s.이름)}!${costCell(itemRefOf(n)!, base)}` : ""; }) .filter(Boolean); const formula = refs.length ? refs.join("+") : "0"; out = editSheet(out, node, (cells) => { cells[RATE.base] = refs.length ? calc(formula) : num(0); }); const mine = (id: string): boolean => id.startsWith(`e-base-${node.id}-`); out = { ...out, edges: [ ...out.edges.filter((e) => !mine(e.id)), ...sources.map((n) => ({ id: `e-base-${node.id}-${n.id}`, from: { node: n.id, pill: base }, to: { node: node.id, pill: PILL_BASE }, })), ], }; } 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 = { person: "노무", material: "재료", machine: "장비", logic: "일위대가", }; /** 재료는 검색어 묶음만 오므로 단가를 부를 번호를 찾아 둠 */ async function materialKey(el_: Record): Promise { 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 { 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) }; element = { ...e, 키: await materialKey(e) }; } const inputs: Record = {}; 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): 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 => { 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, ): 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, ); }