- 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
648 lines
23 KiB
TypeScript
648 lines
23 KiB
TypeScript
/* =============================================================================
|
||
* 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,
|
||
);
|
||
}
|