diff --git a/M01_MasterData/SheetLogic/M01_SL_Item.ts b/M01_MasterData/SheetLogic/M01_SL_Item.ts index 9161771de..38337c9ac 100644 --- a/M01_MasterData/SheetLogic/M01_SL_Item.ts +++ b/M01_MasterData/SheetLogic/M01_SL_Item.ts @@ -10,7 +10,6 @@ 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"; @@ -18,7 +17,7 @@ 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 { connect, disconnect, type SheetLogic, type SlNode, type SlPill } from "./M01_SL_Model"; import "./M01_SL_Item.css"; const TEXT = { @@ -265,6 +264,16 @@ const FIELD_CELL: Record = { }; /** 상자 시트를 항목 틀로 채움 — 예전 수량 칸(값 · 이어진 참조)은 살림 */ +/** 상자 시트를 항목 틀로 채움 — 가져오기(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); @@ -299,39 +308,35 @@ function applyLayout(sl: SheetLogic, node: SlNode, ref: ItemRef, qty?: number | }; } -/** 비율 상자마다 「바탕」 = 다른 항목 상자의 그 비목 합 — 시트 사이 참조 · 선도 함께 */ +/** 비율 상자마다 「바탕」 = 다른 항목 상자의 그 비목 출력 — 선 잇기(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 !== "비율") continue; + if (!ref || ref.what !== "비율" || !node.pillsIn.some((p) => p.name === PILL_BASE)) continue; const base = ref.base ?? "노무비"; - const sources = out.nodes.filter((n) => { + const known = (n: SlNode): boolean => { 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 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; } diff --git a/M01_MasterData/SheetLogic/M01_SL_LogicTools.css b/M01_MasterData/SheetLogic/M01_SL_LogicTools.css new file mode 100644 index 000000000..b8392d2e8 --- /dev/null +++ b/M01_MasterData/SheetLogic/M01_SL_LogicTools.css @@ -0,0 +1,87 @@ +.m01-sllogic__title { + color: var(--color-text-body); + font-size: var(--text-body); +} + +.m01-sllogic__chosen { + margin: var(--spacing-4) 0; + color: var(--color-text-body); + font-weight: 600; +} + +.m01-sllogic__search { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8); + margin: var(--spacing-8) 0; +} + +.m01-sllogic__input { + box-sizing: border-box; + width: 200px; + max-width: 100%; + height: var(--control-height-sm); + padding: 3px 6px; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-canvas); + color: var(--color-text-body); + font: inherit; + font-size: var(--text-body-sm); +} + +.m01-sllogic__input[readonly] { + background: var(--color-surface); +} + +.m01-sllogic__list { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + max-height: 240px; + margin: 0 0 var(--spacing-8); + padding: 0; + overflow: auto; + list-style: none; +} + +.m01-sllogic__list .ui-btn { + max-width: 100%; + text-align: left; + white-space: normal; +} + +.m01-sllogic__fields { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: var(--spacing-8) var(--spacing-16); + margin: var(--spacing-8) 0; + color: var(--color-text-body); +} + +.m01-sllogic__fields > strong, +.m01-sllogic__fields > .m01-sllogic__muted { + flex-basis: 100%; +} + +.m01-sllogic__field { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + color: var(--color-text-secondary); + font-size: var(--text-body-sm); +} + +.m01-sllogic__muted { + margin: 0; + color: var(--color-text-muted); + font-size: var(--text-caption); +} + +.m01-sllogic__msg { + margin: var(--spacing-4) 0 0; + color: var(--color-danger); + font-size: var(--text-caption); +} diff --git a/M01_MasterData/SheetLogic/M01_SL_LogicTools.ts b/M01_MasterData/SheetLogic/M01_SL_LogicTools.ts new file mode 100644 index 000000000..0ddf8c2b2 --- /dev/null +++ b/M01_MasterData/SheetLogic/M01_SL_LogicTools.ts @@ -0,0 +1,297 @@ +/* ============================================================================= + * M01_SL_LogicTools.ts + * 시트 로직 — 「다른 로직」 상자 손잡이. 기존 로직을 찾아 고르면 상자 시트를 그 로직의 틀로 채움 + * 로직의 입력값마다 입력 알약(「넘길 값」 줄 · 예시값) · 결과는 바깥 값 출력 알약 + * 돈 로직 = 노무비 · 재료비 · 경비 · 계 / 돈 아닌 로직(작업량 등) = 결과 + * 틀 모양은 Import 의 다른 로직 상자와 한 벌(`outsideOf` 가 「넘길 값」 칸과 알약 이름으로 물음을 만듦) + * — 상자 `source` 는 안 씀: 한 상자에 비목별 물음이 여럿이라 알약 이름이 비목을 정함. 상자 `ref` = 로직 번호. + * 모양은 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 } from "../../A00_Common/spreadsheet/spreadsheet_types"; +import type { LogicInput, LogicRow } from "../M01_MasterData_UI_Logic_Api"; +import { listEasy, openEasy, type EasyListItem } from "../EasyCalc/M01_EasyCalc_Api"; +import { slPlain } from "./M01_SL_Api"; +import { disconnect, renameNode, type SheetLogic, type SlNode, type SlPill } from "./M01_SL_Model"; +import "./M01_SL_LogicTools.css"; + +const TEXT = { + Title: ["다른 로직", "Other logic"], + Search: ["로직 이름 찾기", "Find a logic by name"], + Find: ["찾기", "Find"], + None: ["아직 고르지 않았어요", "Nothing chosen yet"], + Chosen: ["고른 로직", "Chosen logic"], + NoHit: ["찾은 로직이 없어요", "No logic found"], + More: ["더 있어요 — 이름을 더 적어 보세요", "More exist — type more of the name"], + Loading: ["가져오는 중", "Loading"], + Inputs: ["넘길 값", "Passed values"], + NoInputs: ["넘길 값이 없는 로직이에요", "This logic takes no values"], + Linked: ["다른 상자에서 이어진 값", "Value from another box"], + Pick: ["고르세요", "Choose"], + Bad: ["숫자를 넣어 주세요", "Enter a number"], + Hint: [ + "결과는 바깥 값 — 열 때 · 저장 때 넣어요", + "Result is an outside value — filled on open and save", + ], + Result: ["결과", "Result"], + Labor: ["노무비", "Labor cost"], + Material: ["재료비", "Material cost"], + Expense: ["경비", "Expense"], + Sum: ["계", "Total"], + Name: ["이름", "Name"], + Value: ["값", "Value"], + Note: ["설명", "Note"], + Pass: ["넘길 값", "Passed value"], + Outside: ["바깥 값 — 열 때 · 저장 때 넣음", "Outside value — filled on open and save"], +} as const; + +type TextKey = keyof typeof TEXT; +const tt = (key: TextKey): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0]; + +const LIMIT = 30; + +/** 로직 결과가 돈인가 */ +const isMoney = (unit: string): boolean => unit.startsWith("원"); + +/** 상자 시트를 다른 로직 틀로 채움 · `trial` = 입력값 예시(열 때 받은 자동값) */ +export function applyLogic( + sl: SheetLogic, + nodeId: string, + row: LogicRow, + trial: Record = {}, +): SheetLogic { + const node = sl.nodes.find((n) => n.id === nodeId); + if (!node) return sl; + if (node.ref === row.키 && node.pillsIn.length) return sl; // 같은 로직을 또 고름 — 그대로 + // 다른 로직을 고르면 옛 선은 끊음(받는 칸 수식도 함께 정리) + let work = sl; + for (const e of sl.edges) + if (e.from.node === nodeId || e.to.node === nodeId) work = disconnect(work, e.id); + for (const name of [row.이름, `${row.이름} 2`, `${row.이름} 3`]) { + const got = renameNode(work, nodeId, name); + if (!got.why) { + work = got.sl; + break; + } + } + const cells: Record = { + A1: { 값: tt("Name") }, + B1: { 값: tt("Value") }, + C1: { 값: tt("Note") }, + }; + const pillsIn: SlPill[] = []; + const pillsOut: SlPill[] = []; + let r = 2; + for (const spec of row.입력 ?? []) { + const at = `B${r}`; + cells[`A${r}`] = { 값: spec.이름 }; + const v = trial[spec.이름]; + if (typeof v === "number" || typeof v === "string") cells[at] = { 값: v }; + cells[`C${r}`] = { 값: tt("Pass") }; + pillsIn.push({ name: spec.이름, cell: at, ...(spec.단위 ? { unit: spec.단위 } : {}) }); + r++; + } + const unit = row.결과단위 ?? ""; + const money = isMoney(unit); + const outs = money ? [tt("Labor"), tt("Material"), tt("Expense"), tt("Sum")] : [tt("Result")]; + for (const name of outs) { + const at = `B${r}`; + cells[`A${r}`] = { 값: name }; + cells[`C${r}`] = { 값: tt("Outside") }; + pillsIn.push({ name, cell: at, outside: true }); + pillsOut.push({ name, cell: at, unit: money ? "원" : unit }); + r++; + } + const 시트 = work.workbook.시트.map((s) => (s.id === node.sheetId ? { ...s, 칸: cells } : s)); + return { + ...work, + workbook: { ...work.workbook, 시트 }, + nodes: work.nodes.map((n) => { + if (n.id !== nodeId) return n; + const next: SlNode = { ...n, ref: row.키, pillsIn, pillsOut, stage: 1 }; + delete next.source; + return next; + }), + }; +} + +/** 그 로직의 입력 설명(고르기 · 범위) — 한 번 받은 것은 다시 안 받음 */ +const rows = new Map(); + +/** 다른 로직 상자 손잡이 — 찾기 → 고르기 → 넘길 값 칸 */ +export function renderLogicTools( + host: HTMLElement, + sl: SheetLogic, + nodeId: string, + on: { change(sl: SheetLogic): void }, +): void { + const node = sl.nodes.find((n) => n.id === nodeId); + const msg = el("p", { className: "m01-sllogic__msg" }); + if (!node) { + host.replaceChildren(); + return; + } + const sheet = sl.workbook.시트.find((s) => s.id === node.sheetId); + const results = el("ul", { className: "m01-sllogic__list" }); + const fieldsHost = el("div", { className: "m01-sllogic__fields" }); + const box = el("input", { + className: "m01-sllogic__input", + attrs: { type: "text", placeholder: tt("Search"), "aria-label": tt("Search") }, + }); + + const emit = (make: () => SheetLogic): void => { + msg.textContent = ""; + try { + on.change(make()); + } catch (e) { + msg.textContent = slPlain(e); + } + }; + + const choose = async (item: EasyListItem): Promise => { + msg.textContent = tt("Loading"); + try { + const opened = await openEasy(item.key); + rows.set(item.key, opened.row); + msg.textContent = ""; + on.change(applyLogic(sl, node.id, opened.row, opened.trial)); + } catch (e) { + msg.textContent = slPlain(e); + } + }; + + const search = async (): Promise => { + msg.textContent = tt("Loading"); + try { + const found = await listEasy(box.value.trim()); + msg.textContent = ""; + results.replaceChildren( + ...(found.length + ? found.slice(0, LIMIT).map((it) => { + const b = createButton({ + label: `${it.name} · ${it.resultUnit}${it.where ? ` · ${it.where}` : ""}`, + variant: "ghost", + size: "sm", + onClick: () => void choose(it), + }); + return el("li", { children: [b] }); + }) + : [el("li", { className: "m01-sllogic__muted", text: tt("NoHit") })]), + ...(found.length > LIMIT + ? [el("li", { className: "m01-sllogic__muted", text: tt("More") })] + : []), + ); + } catch (e) { + msg.textContent = slPlain(e); + } + }; + box.addEventListener("keydown", (ev) => { + if (ev.key === "Enter") void search(); + }); + + /** 넘길 값 칸 — 고르기가 있으면 고르기 · 아니면 숫자 · 다른 상자에서 이어진 칸은 읽기만 */ + const fieldFor = (pill: SlPill, spec: LogicInput | undefined): HTMLElement => { + const cur = sheet?.칸[pill.cell]; + const linked = cur?.식 !== undefined; + const put = (v: number | string): void => + emit(() => ({ + ...sl, + workbook: { + ...sl.workbook, + 시트: sl.workbook.시트.map((s) => + s.id === node.sheetId ? { ...s, 칸: { ...s.칸, [pill.cell]: { 값: v } } } : s, + ), + }, + })); + const label = el("span", { text: pill.unit ? `${pill.name} (${pill.unit})` : pill.name }); + let control: HTMLElement; + if (spec?.고르기?.length && !linked) { + control = createSelectField({ + options: [ + { value: "", text: tt("Pick") }, + ...spec.고르기.map((o) => ({ value: String(o), text: String(o) })), + ], + value: cur?.값 === undefined ? "" : String(cur.값), + compact: true, + ariaLabel: pill.name, + onChange: (v) => { + const hit = spec.고르기?.find((o) => String(o) === v); + if (hit !== undefined) put(hit); + }, + }).root; + } else { + const input = el("input", { + className: "m01-sllogic__input", + attrs: { type: "text", inputmode: "decimal", "aria-label": pill.name }, + }); + if (spec?.범위) input.placeholder = `${spec.범위[0]} ∼ ${spec.범위[1]}`; + input.value = linked || cur?.값 === undefined ? "" : String(cur.값); + input.readOnly = linked; + input.addEventListener("change", () => { + const raw = input.value.trim(); + const n = Number(raw); + if (raw === "") return; + if (Number.isNaN(n)) msg.textContent = tt("Bad"); + else put(n); + }); + control = input; + } + return el("label", { + className: "m01-sllogic__field", + children: [ + label, + control, + ...(linked ? [el("small", { className: "m01-sllogic__muted", text: tt("Linked") })] : []), + ], + }); + }; + + const drawFields = (row: LogicRow | undefined): void => { + const passed = node.pillsIn.filter((p) => !p.outside); + fieldsHost.replaceChildren( + el("strong", { text: tt("Inputs") }), + ...(passed.length + ? passed.map((p) => + fieldFor( + p, + row?.입력?.find((i) => i.이름 === p.name), + ), + ) + : [el("p", { className: "m01-sllogic__muted", text: tt("NoInputs") })]), + el("p", { className: "m01-sllogic__muted", text: tt("Hint") }), + ); + }; + + host.replaceChildren( + el("strong", { className: "m01-sllogic__title", text: `${tt("Title")} · ${node.name}` }), + el("p", { + className: "m01-sllogic__chosen", + text: node.ref ? `${tt("Chosen")}: ${node.name}` : tt("None"), + }), + el("div", { + className: "m01-sllogic__search", + children: [ + box, + createButton({ label: tt("Find"), size: "sm", onClick: () => void search() }), + ], + }), + results, + ...(node.ref ? [fieldsHost] : []), + msg, + ); + if (node.ref) { + const known = rows.get(node.ref); + drawFields(known); + if (!known) { + void openEasy(node.ref).then( + (o) => { + rows.set(o.row.키, o.row); + drawFields(o.row); + }, + () => undefined, + ); + } + } +} diff --git a/M01_MasterData/SheetLogic/M01_SL_Result.ts b/M01_MasterData/SheetLogic/M01_SL_Result.ts index 213fdd077..07c04fa7c 100644 --- a/M01_MasterData/SheetLogic/M01_SL_Result.ts +++ b/M01_MasterData/SheetLogic/M01_SL_Result.ts @@ -24,6 +24,7 @@ import "./M01_SL_Result.css"; const TEXT = { Per: ["{per} 당", "per {per}"], Total: ["합계", "Total"], + Value: ["결과값", "Result value"], Won: ["원", "KRW"], Labor: ["노무비", "Labor cost"], Material: ["재료비", "Material cost"], @@ -47,6 +48,7 @@ const tr = (key: TextKey, fill: Record = {}): string => TEXT[key][currentLanguageIndex].replace(/\{(\w+)\}/g, (m, k: string) => fill[k] ?? m); const COSTS = ["노무비", "재료비", "경비"] as const; +const num = (v: number): string => v.toLocaleString("ko-KR", { maximumFractionDigits: 6 }); const n0 = (v: unknown): number => (typeof v === "number" && Number.isFinite(v) ? v : 0); const sheetOf = (sl: SheetLogic, node: SlNode): Sheet | undefined => @@ -157,13 +159,34 @@ export function renderResult( values: SlEvalResult, on: { change(sl: SheetLogic): void }, ): void { + const money = !sl.resultUnit || sl.resultUnit.startsWith("원"); const { 계 } = values.result; - const per = sl.resultUnit.includes("/") ? sl.resultUnit.split("/").slice(1).join("/") : ""; + /** 돈이 아닌 결과(㎥/시간 …) — 결과 상자의 「계」 · 없으면 이어진 첫 숫자 */ + const plainValue = (): number | null => { + const box = sl.nodes.find((n) => n.kind === "result"); + const ins = box ? Object.values(values.pillsIn[box.id] ?? {}) : []; + const first = [계, ...COSTS.map((c) => values.result[c]), ...ins].find( + (v) => typeof v === "number" && Number.isFinite(v), + ); + return typeof first === "number" ? first : null; + }; + const perUnit = sl.resultUnit.split("/").slice(1).join("/"); + const perText = !perUnit + ? tr("Total") + : tr("Per", { per: /^\d/.test(perUnit) ? perUnit : `1${perUnit}` }); + const shown = money ? 계 : plainValue(); const big = el("p", { className: "m01-slresult__big", children: [ - el("span", { text: per ? tr("Per", { per: `1${per}` }) : tr("Total") }), - el("strong", { text: 계 === null ? "—" : `${formatMoney(계)} ${tr("Won")}` }), + el("span", { text: money ? perText : tr("Value") }), + el("strong", { + text: + shown === null + ? "—" + : money + ? `${formatMoney(shown)} ${tr("Won")}` + : `${num(shown)} ${sl.resultUnit}`.trim(), + }), ], }); const costs = el("dl", { @@ -191,7 +214,7 @@ export function renderResult( middle: {}, }; const sheetHost = el("div", { className: "m01-slresult__sheet" }); - renderSheet(sheetHost, head, ans); + if (money) renderSheet(sheetHost, head, ans); const msg = el("p", { className: "m01-slresult__msg" }); const put = @@ -225,7 +248,7 @@ export function renderResult( : []; host.replaceChildren( big, - costs, + ...(money ? [costs] : []), ...strip, ...(problems.length ? [