From 7c1266bcb44989cb9d226725b632e222126be6f1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 13:01:29 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat(M01):=20=EC=8B=9C=ED=8A=B8=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=9C=84=20=EA=B0=80=EB=A1=9C=20=EB=8B=A4=EC=9D=B4?= =?UTF-8?q?=EC=96=B4=EA=B7=B8=EB=9E=A8=20=EC=B4=88=EC=95=88=20=E2=80=94=20?= =?UTF-8?q?=EC=83=81=EC=9E=90=20=C2=B7=20=EC=95=8C=EC=95=BD=20=C2=B7=20?= =?UTF-8?q?=EC=84=A0=20=C2=B7=20=EB=81=8C=EC=96=B4=20=EC=9E=87=EA=B8=B0(PL?= =?UTF-8?q?AN=2035-5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z --- M01_MasterData/SheetLogic/M01_SL_Diagram.css | 251 +++++++++ M01_MasterData/SheetLogic/M01_SL_Diagram.ts | 504 +++++++++++++++++++ 2 files changed, 755 insertions(+) create mode 100644 M01_MasterData/SheetLogic/M01_SL_Diagram.css create mode 100644 M01_MasterData/SheetLogic/M01_SL_Diagram.ts diff --git a/M01_MasterData/SheetLogic/M01_SL_Diagram.css b/M01_MasterData/SheetLogic/M01_SL_Diagram.css new file mode 100644 index 000000000..22c75a9ab --- /dev/null +++ b/M01_MasterData/SheetLogic/M01_SL_Diagram.css @@ -0,0 +1,251 @@ +/* 시트 로직 — 위 가로 다이어그램 */ +.m01-sldg { + --sldg-in: var(--color-accent); + --sldg-out: #c2610c; + display: flex; + flex-direction: column; + gap: var(--spacing-8); + min-width: 0; + font-size: var(--text-body-sm); + outline: none; +} + +:root[data-theme="dark"] .m01-sldg { + --sldg-out: #e5a866; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) .m01-sldg { + --sldg-out: #e5a866; + } +} + +.m01-sldg__bar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8); + min-height: 2rem; +} + +.m01-sldg__note { + color: var(--color-danger); +} + +.m01-sldg__muted { + margin: 0; + color: var(--color-text-muted); +} + +.m01-sldg__menu { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-4); + padding: var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-surface); +} + +.m01-sldg__menu[hidden] { + display: none; +} + +/* 띠 — 화면 위 3분의 1 안쪽 · 길면 가로로 밀기 */ +.m01-sldg__band { + max-height: 33vh; + overflow: auto; + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: color-mix(in srgb, var(--color-accent) 5%, var(--color-canvas)); +} + +.m01-sldg__inner { + position: relative; + display: grid; + grid-template-columns: repeat(5, minmax(11rem, 1fr)); + gap: var(--spacing-24); + min-width: max-content; + padding: var(--spacing-12) var(--spacing-16); +} + +.m01-sldg__lines { + position: absolute; + inset: 0 auto auto 0; + overflow: visible; + pointer-events: none; + z-index: 0; +} + +.m01-sldg__col { + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + gap: var(--spacing-12); + min-width: 11rem; +} + +.m01-sldg__col-head { + color: var(--color-text-muted); + font-size: var(--text-caption); + letter-spacing: 0.04em; +} + +/* 상자 */ +.m01-sldg__box { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + padding: var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-surface); + cursor: pointer; +} + +.m01-sldg__box:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; +} + +.m01-sldg__box.is-selected { + border: 2px solid var(--color-accent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 18%, transparent); +} + +.m01-sldg__box-head { + display: flex; + flex-direction: column; +} + +.m01-sldg__kind { + color: var(--color-text-muted); + font-size: var(--text-caption); +} + +.m01-sldg__name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.m01-sldg__pills { + display: flex; + justify-content: space-between; + gap: var(--spacing-8); +} + +.m01-sldg__ins, +.m01-sldg__outs { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + min-width: 0; +} + +.m01-sldg__outs { + align-items: flex-end; +} + +/* 알약 — 입력 파랑 · 출력 주황 · 빈 자리 회색 */ +.m01-sldg__pill { + display: inline-flex; + align-items: baseline; + gap: var(--spacing-4); + max-width: 10rem; + padding: 0 var(--spacing-8); + border: 1px solid var(--sldg-in); + border-radius: 999px; + background: color-mix(in srgb, var(--sldg-in) 12%, var(--color-surface)); + color: var(--sldg-in); + font: inherit; + font-size: var(--text-caption); + cursor: pointer; + white-space: nowrap; +} + +.m01-sldg__pill.is-out { + border-color: var(--sldg-out); + background: color-mix(in srgb, var(--sldg-out) 14%, var(--color-surface)); + color: var(--sldg-out); + cursor: grab; + touch-action: none; +} + +.m01-sldg__pill.is-empty { + border-style: dashed; + border-color: var(--color-text-muted); + background: transparent; + color: var(--color-text-muted); +} + +.m01-sldg__pill.is-chosen { + box-shadow: 0 0 0 2px currentColor; +} + +.m01-sldg.is-linking .m01-sldg__pill.is-target { + box-shadow: 0 0 0 2px var(--sldg-in); + animation: m01-sldg-blink 1s ease-in-out infinite alternate; +} + +@keyframes m01-sldg-blink { + to { + background: color-mix(in srgb, var(--sldg-in) 30%, var(--color-surface)); + } +} + +.m01-sldg__pill-name { + overflow: hidden; + text-overflow: ellipsis; +} + +.m01-sldg__pill-value { + color: var(--color-text-secondary); + font-variant-numeric: tabular-nums; +} + +.m01-sldg__pill-value:empty { + display: none; +} + +/* 선 */ +.m01-sldg__edge { + pointer-events: auto; + cursor: pointer; +} + +.m01-sldg__hit { + fill: none; + stroke: transparent; + stroke-width: 12; +} + +.m01-sldg__wire { + fill: none; + stroke: var(--color-text-muted); + stroke-width: 1.5; +} + +.m01-sldg__edge.is-strong .m01-sldg__wire { + stroke: var(--color-accent); + stroke-width: 3; +} + +.m01-sldg__edge.is-dim .m01-sldg__wire { + opacity: 0.3; +} + +.m01-sldg__drag { + fill: none; + stroke: var(--sldg-out); + stroke-width: 2; + stroke-dasharray: 5 4; +} + +@media (prefers-reduced-motion: reduce) { + .m01-sldg.is-linking .m01-sldg__pill.is-target { + animation: none; + } +} diff --git a/M01_MasterData/SheetLogic/M01_SL_Diagram.ts b/M01_MasterData/SheetLogic/M01_SL_Diagram.ts new file mode 100644 index 000000000..c32d15315 --- /dev/null +++ b/M01_MasterData/SheetLogic/M01_SL_Diagram.ts @@ -0,0 +1,504 @@ +/* ============================================================================= + * M01_SL_Diagram.ts + * 시트 로직 — 위 가로 다이어그램(정함 8 · 제안서 「화면 배치」 · 「상자의 입출력도 알약으로」) + * 단계 다섯 칸(입력값 · 표 · 계산 · 항목 · 결과) · 단계마다 상자를 세로로 쌓음(자리는 저절로) · + * 상자 왼쪽 입력 알약(파랑 · 빈 자리는 회색 「? 이름」) · 오른쪽 출력 알약(주황) · 선 = SVG. + * 출력 알약을 끌어 입력 알약에 놓으면 on.connect · 선을 골라 Delete / 「끊기」 → on.disconnect · + * 「+」 → 종류 → on.add · 상자를 골라 Delete → 물음 → on.remove. 키보드: Tab · Enter · 「잇기」. + * 그리기만 — 시트 로직을 바꾸지 않음(바꾸는 것은 부른 쪽이 Model 로). + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { createButton, el, showConfirmDialog } from "@ui/ui_template_elements"; +import type { SheetLogic, SlEdge, SlEnd, SlKind, SlNode } from "./M01_SL_Model"; +import "./M01_SL_Diagram.css"; + +const TEXT = { + Stage0: ["입력값", "Inputs"], + Stage1: ["표", "Tables"], + Stage2: ["계산", "Calculations"], + Stage3: ["항목", "Items"], + Stage4: ["결과", "Result"], + K_input: ["입력값", "Input"], + K_const: ["고정값", "Fixed value"], + K_table: ["표", "Table"], + K_calc: ["계산", "Calculation"], + K_item: ["항목", "Item"], + K_logic: ["다른 로직", "Other logic"], + K_result: ["결과", "Result"], + Add: ["+ 상자", "+ Box"], + AddTitle: ["더할 상자 종류", "Kind of box to add"], + Cancel: ["취소", "Cancel"], + Connect: ["잇기", "Connect"], + Disconnect: ["끊기", "Disconnect"], + Remove: ["상자 지우기", "Remove box"], + AskRemove: [ + "「{v}」 상자를 지울까요? 이어진 선도 함께 끊어짐", + "Remove box “{v}”? Its lines go too", + ], + PickTarget: ["「{v}」 을 받을 입력 알약을 누르세요", "Tap the input pill that receives “{v}”"], + Empty: ["상자가 없음 — 「+ 상자」 로 더함", "No boxes — add one with “+ Box”"], + NoOut: ["내보내는 알약이 없음", "No output pills"], + NoIn: ["받는 알약이 없음", "No input pills"], + SameBox: ["같은 상자 안에서는 이을 수 없음", "Can't connect inside the same box"], + NotIn: ["입력 알약에 놓아야 이어짐", "Drop it on an input pill"], + NoMatch: [ + "이름이 같은 입력 알약이 없음 — 받을 알약에 바로 놓으세요", + "No input pill with the same name — drop on the pill itself", + ], + Taken: [ + "이미 이어진 입력 알약 — 먼저 그 선을 끊으세요", + "That input already has a line — disconnect it first", + ], + From: ["어디서", "From"], + To: ["어디로", "To"], + Value: ["지금 값", "Now"], + LineOf: ["선 · {a} → {b}", "Line · {a} → {b}"], +} as const; + +const td = (key: keyof typeof TEXT, fill: Record = {}): string => + TEXT[key][currentLanguageIndex as 0 | 1].replace(/\{(\w)\}/g, (_m, k: string) => fill[k] ?? ""); + +/** 종류 → 기본 단계(고정값 · 다른 로직은 앞 칸) */ +export const STAGE_OF: Record = { + input: 0, + const: 0, + logic: 0, + table: 1, + calc: 2, + item: 3, + result: 4, +}; + +const KINDS: SlKind[] = ["input", "const", "table", "calc", "item", "logic", "result"]; +const kindLabel = (k: SlKind): string => td(`K_${k}` as keyof typeof TEXT); + +export interface DiagramOn { + select(id: string | null): void; + /** 잇기 — 막히면 까닭(쉬운 말)을 돌려주면 띠에 한 줄로 보임 */ + connect(from: SlEnd, to: SlEnd): string | null | void; + disconnect(edgeId: string): void; + add(kind: SlKind, stage: number): void; + remove(nodeId: string): void; +} + +export interface DiagramOpts { + /** 상자 id → 알약 이름 → 지금 값 */ + values?: Record>; + /** 고른 상자 id · 선 id */ + selected?: string | null; +} + +/* ── 자리 정하기(DOM 없음) ─────────────────────────────────────────── */ + +/** 단계 칸마다 상자 차례 — node.stage(없으면 종류 기본) · 0~4 로 자름 · 같은 칸은 sl 차례 */ +export function layout(nodes: SlNode[]): SlNode[][] { + const cols: SlNode[][] = [[], [], [], [], []]; + for (const n of nodes) { + const s = Number.isInteger(n.stage) ? n.stage : STAGE_OF[n.kind]; + cols[Math.min(4, Math.max(0, s ?? 0))].push(n); + } + return cols; +} + +/** 떨군 자리 → 받을 끝 · 상자에 떨구면 이름 같은 입력 알약 · 못 정하면 까닭 */ +export function resolveDrop( + sl: SheetLogic, + from: SlEnd, + target: { node: string; pill: string | null; dir: "in" | "out" | null } | null, +): { to: SlEnd } | { why: string } { + if (!target) return { why: td("NotIn") }; + if (target.node === from.node) return { why: td("SameBox") }; + if (target.dir === "out") return { why: td("NotIn") }; + const node = sl.nodes.find((n) => n.id === target.node); + if (!node?.pillsIn.length) return { why: td("NoIn") }; + const name = target.pill ?? node.pillsIn.find((p) => p.name === from.pill)?.name; + if (!name) return { why: td("NoMatch") }; + if (sl.edges.some((e) => e.to.node === node.id && e.to.pill === name)) + return { why: td("Taken") }; + return { to: { node: node.id, pill: name } }; +} + +/* ── 그리기 ─────────────────────────────────────────────────────── */ + +interface HostState { + selected: string | null; + /** 잇기 중 — 출력 알약 */ + linking: SlEnd | null; + /** 고른 알약(정보 줄) */ + pill: { node: string; pill: string; dir: "in" | "out" } | null; + note: string; + scroll: number; +} + +const states = new WeakMap(); + +const showValue = (v: unknown): string => { + if (v === null || v === undefined || v === "") return ""; + if (typeof v === "number") + return Number.isInteger(v) ? v.toLocaleString() : String(+v.toFixed(4)); + return String(v).slice(0, 12); +}; + +const edgeTouches = (e: SlEdge, node: string): boolean => + e.from.node === node || e.to.node === node; + +export function renderDiagram( + host: HTMLElement, + sl: SheetLogic, + on: DiagramOn, + opts: DiagramOpts = {}, +): void { + const old = states.get(host); + const st: HostState = old ?? { selected: null, linking: null, pill: null, note: "", scroll: 0 }; + if (opts.selected !== undefined) st.selected = opts.selected; + states.set(host, st); + const values = opts.values ?? {}; + const nodeName = (id: string): string => sl.nodes.find((n) => n.id === id)?.name ?? ""; + const redraw = (): void => renderDiagram(host, sl, on, { ...opts, selected: st.selected }); + const select = (id: string | null): void => { + st.selected = id; + st.pill = null; + on.select(id); + redraw(); + }; + const tryConnect = (from: SlEnd, target: Parameters[2]): void => { + const got = resolveDrop(sl, from, target); + st.linking = null; + if ("why" in got) st.note = got.why; + else st.note = on.connect(from, got.to) || ""; + redraw(); + }; + + /* 상자 */ + const pillEl = (n: SlNode, name: string, dir: "in" | "out", unit?: string): HTMLElement => { + const wired = dir === "out" || sl.edges.some((e) => e.to.node === n.id && e.to.pill === name); + const linking = st.linking; + const chosen = st.pill?.node === n.id && st.pill.pill === name && st.pill.dir === dir; + const b = el("button", { + className: [ + "m01-sldg__pill", + `is-${dir}`, + wired ? "" : "is-empty", + chosen ? "is-chosen" : "", + linking && dir === "in" && n.id !== linking.node ? "is-target" : "", + ].join(" "), + attrs: { + type: "button", + "data-node": n.id, + "data-pill": name, + "data-dir": dir, + title: unit ? `${name} (${unit})` : name, + }, + children: [ + el("span", { className: "m01-sldg__pill-name", text: wired ? name : `? ${name}` }), + el("span", { className: "m01-sldg__pill-value", text: showValue(values[n.id]?.[name]) }), + ], + }); + b.addEventListener("click", (ev) => { + ev.stopPropagation(); + if (st.linking && dir === "in") + return tryConnect(st.linking, { node: n.id, pill: name, dir }); + st.pill = chosen ? null : { node: n.id, pill: name, dir }; + st.linking = null; + st.note = ""; + redraw(); + }); + if (dir === "out") + b.addEventListener("pointerdown", (ev) => startDrag(ev, { node: n.id, pill: name })); + return b; + }; + + const boxEl = (n: SlNode): HTMLElement => { + const box = el("div", { + className: ["m01-sldg__box", `is-${n.kind}`, st.selected === n.id ? "is-selected" : ""].join( + " ", + ), + attrs: { + "data-node": n.id, + tabindex: "0", + role: "button", + "aria-label": `${kindLabel(n.kind)} · ${n.name}`, + }, + children: [ + el("div", { + className: "m01-sldg__box-head", + children: [ + el("span", { className: "m01-sldg__kind", text: kindLabel(n.kind) }), + el("strong", { className: "m01-sldg__name", text: n.name }), + ], + }), + el("div", { + className: "m01-sldg__pills", + children: [ + el("div", { + className: "m01-sldg__ins", + children: n.pillsIn.map((p) => pillEl(n, p.name, "in", p.unit)), + }), + el("div", { + className: "m01-sldg__outs", + children: n.pillsOut.map((p) => pillEl(n, p.name, "out", p.unit)), + }), + ], + }), + ], + }); + box.addEventListener("click", () => { + if (st.linking) return tryConnect(st.linking, { node: n.id, pill: null, dir: null }); + select(st.selected === n.id ? null : n.id); + }); + box.addEventListener("keydown", (ev) => { + if (ev.target !== box) return; + if (ev.key === "Enter" || ev.key === " ") { + ev.preventDefault(); + box.click(); + } + }); + return box; + }; + + const cols = layout(sl.nodes); + const inner = el("div", { + className: "m01-sldg__inner", + children: cols.map((list, i) => + el("div", { + className: "m01-sldg__col", + children: [ + el("div", { + className: "m01-sldg__col-head", + text: td(`Stage${i}` as keyof typeof TEXT), + }), + ...list.map(boxEl), + ], + }), + ), + }); + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.classList.add("m01-sldg__lines"); + inner.prepend(svg); + inner.addEventListener("click", (ev) => { + if (ev.target === inner || (ev.target as HTMLElement).classList.contains("m01-sldg__col")) { + st.linking = null; + select(null); + } + }); + + /* 위 줄 — 더하기 · 고른 것 · 할 일 */ + const addMenu = el("div", { className: "m01-sldg__menu", attrs: { hidden: "", role: "menu" } }); + addMenu.append( + el("span", { className: "m01-sldg__muted", text: td("AddTitle") }), + ...KINDS.map((k) => + createButton({ + label: kindLabel(k), + size: "sm", + variant: "ghost", + onClick: () => { + addMenu.hidden = true; + on.add(k, STAGE_OF[k]); + }, + }), + ), + ); + const addBtn = createButton({ + label: td("Add"), + size: "sm", + onClick: () => (addMenu.hidden = !addMenu.hidden), + }); + const selEdge = sl.edges.find((e) => e.id === st.selected) ?? null; + const selNode = sl.nodes.find((n) => n.id === st.selected) ?? null; + const tools: HTMLElement[] = []; + if (st.linking) { + tools.push( + el("span", { text: td("PickTarget", { v: st.linking.pill }) }), + createButton({ + label: td("Cancel"), + size: "sm", + variant: "ghost", + onClick: () => ((st.linking = null), redraw()), + }), + ); + } else if (st.pill) { + const p = st.pill; + const linked = sl.edges.filter((e) => + p.dir === "in" + ? e.to.node === p.node && e.to.pill === p.pill + : e.from.node === p.node && e.from.pill === p.pill, + ); + const where = linked + .map((e) => + p.dir === "in" + ? `${nodeName(e.from.node)} · ${e.from.pill}` + : `${nodeName(e.to.node)} · ${e.to.pill}`, + ) + .join(", "); + tools.push( + el("span", { + text: [ + `${p.pill}`, + `${td("Value")} ${showValue(values[p.node]?.[p.pill]) || "—"}`, + where ? `${td(p.dir === "in" ? "From" : "To")} ${where}` : "", + ] + .filter(Boolean) + .join(" · "), + }), + ); + if (p.dir === "out") + tools.push( + createButton({ + label: td("Connect"), + size: "sm", + onClick: () => { + st.linking = { node: p.node, pill: p.pill }; + st.pill = null; + redraw(); + }, + }), + ); + } else if (selEdge) { + tools.push( + el("span", { + text: td("LineOf", { + a: `${nodeName(selEdge.from.node)} · ${selEdge.from.pill}`, + b: `${nodeName(selEdge.to.node)} · ${selEdge.to.pill}`, + }), + }), + createButton({ + label: td("Disconnect"), + size: "sm", + variant: "danger", + onClick: () => on.disconnect(selEdge.id), + }), + ); + } else if (selNode) { + tools.push( + createButton({ + label: td("Remove"), + size: "sm", + variant: "danger", + onClick: () => void askRemove(selNode), + }), + ); + } + const askRemove = async (n: SlNode): Promise => { + if (await showConfirmDialog(td("AskRemove", { v: n.name }), td("Remove"))) on.remove(n.id); + }; + + const band = el("div", { className: "m01-sldg__band", children: [inner] }); + const root = el("div", { + className: `m01-sldg${st.linking ? " is-linking" : ""}`, + attrs: { tabindex: "-1" }, + children: [ + el("div", { + className: "m01-sldg__bar", + children: [addBtn, ...tools, el("span", { className: "m01-sldg__note", text: st.note })], + }), + addMenu, + sl.nodes.length ? band : el("p", { className: "m01-sldg__muted", text: td("Empty") }), + ], + }); + root.addEventListener("keydown", (ev) => { + if (ev.key === "Escape" && (st.linking || st.pill)) { + st.linking = null; + st.pill = null; + redraw(); + } + if (ev.key !== "Delete" || (ev.target as HTMLElement).closest("input, textarea")) return; + if (selEdge) on.disconnect(selEdge.id); + else if (selNode) void askRemove(selNode); + }); + band.addEventListener("scroll", () => (st.scroll = band.scrollLeft)); + host.replaceChildren(root); + band.scrollLeft = st.scroll; + + /* 선 — 붙인 뒤 알약 자리를 재서 그림 */ + const drawLines = (): void => { + const box = inner.getBoundingClientRect(); + svg.setAttribute("width", String(inner.scrollWidth)); + svg.setAttribute("height", String(inner.scrollHeight)); + svg.replaceChildren(); + const at = (end: SlEnd, dir: "in" | "out"): { x: number; y: number } | null => { + const pill = inner.querySelector( + `.m01-sldg__pill[data-node="${CSS.escape(end.node)}"][data-pill="${CSS.escape(end.pill)}"][data-dir="${dir}"]`, + ); + if (!pill) return null; + const r = pill.getBoundingClientRect(); + return { + x: (dir === "out" ? r.right : r.left) - box.left, + y: r.top + r.height / 2 - box.top, + }; + }; + for (const e of sl.edges) { + const a = at(e.from, "out"); + const b = at(e.to, "in"); + if (!a || !b) continue; + const dx = Math.max(30, Math.abs(b.x - a.x) / 2); + const d = `M${a.x},${a.y} C${a.x + dx},${a.y} ${b.x - dx},${b.y} ${b.x},${b.y}`; + const strong = st.selected === e.id || (selNode !== null && edgeTouches(e, selNode.id)); + const dim = st.selected !== null && !strong; + const g = document.createElementNS("http://www.w3.org/2000/svg", "g"); + g.setAttribute("class", `m01-sldg__edge${strong ? " is-strong" : ""}${dim ? " is-dim" : ""}`); + g.setAttribute("data-edge", e.id); + g.setAttribute("data-x1", a.x.toFixed(1)); + g.setAttribute("data-y1", a.y.toFixed(1)); + g.setAttribute("data-x2", b.x.toFixed(1)); + g.setAttribute("data-y2", b.y.toFixed(1)); + for (const cls of ["m01-sldg__hit", "m01-sldg__wire"]) { + const p = document.createElementNS("http://www.w3.org/2000/svg", "path"); + p.setAttribute("d", d); + p.setAttribute("class", cls); + g.append(p); + } + g.addEventListener("click", (ev) => { + ev.stopPropagation(); + select(st.selected === e.id ? null : e.id); + }); + svg.append(g); + } + }; + requestAnimationFrame(drawLines); + + /* 끌어 잇기 */ + function startDrag(ev: PointerEvent, from: SlEnd): void { + if (ev.button !== 0) return; + const pill = ev.currentTarget as HTMLElement; + const box = inner.getBoundingClientRect(); + const r = pill.getBoundingClientRect(); + const x0 = r.right - box.left; + const y0 = r.top + r.height / 2 - box.top; + let line: SVGPathElement | null = null; + const move = (m: PointerEvent): void => { + const x = m.clientX - box.left; + const y = m.clientY - box.top; + if (!line && Math.hypot(x - x0, y - y0) < 6) return; + if (!line) { + line = document.createElementNS("http://www.w3.org/2000/svg", "path"); + line.setAttribute("class", "m01-sldg__drag"); + svg.append(line); + } + line.setAttribute("d", `M${x0},${y0} L${x},${y}`); + }; + const up = (u: PointerEvent): void => { + window.removeEventListener("pointermove", move); + window.removeEventListener("pointerup", up); + if (!line) return; // 끌지 않음 = 누름(click 이 처리) + line.remove(); + const hit = document.elementFromPoint(u.clientX, u.clientY) as HTMLElement | null; + const pillHit = hit?.closest(".m01-sldg__pill"); + const boxHit = hit?.closest(".m01-sldg__box"); + const target = pillHit + ? { + node: pillHit.dataset.node ?? "", + pill: pillHit.dataset.pill ?? null, + dir: (pillHit.dataset.dir as "in" | "out") ?? null, + } + : boxHit + ? { node: boxHit.dataset.node ?? "", pill: null, dir: null } + : null; + // 끈 뒤 따라오는 click 을 한 번 삼킴 + window.addEventListener("click", (c) => c.stopPropagation(), { capture: true, once: true }); + tryConnect(from, target); + }; + window.addEventListener("pointermove", move); + window.addEventListener("pointerup", up); + } +} From 920783a9b03dee576cc514aad21b4e11ba74abe6 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 13:05:41 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix(M01):=20=EC=8B=9C=ED=8A=B8=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EB=8B=A4=EC=9D=B4=EC=96=B4=EA=B7=B8=EB=9E=A8=20?= =?UTF-8?q?=E2=80=94=20=EC=84=A0=EC=9D=84=20=EB=B0=94=EB=A1=9C=20=EA=B7=B8?= =?UTF-8?q?=EB=A6=BC(=ED=81=AC=EA=B8=B0=20=EB=B0=94=EB=80=8C=EB=A9=B4=20?= =?UTF-8?q?=EB=8B=A4=EC=8B=9C)=20=C2=B7=20=EB=81=88=20=EB=92=A4=20?= =?UTF-8?q?=ED=81=B4=EB=A6=AD=20=EC=82=BC=ED=82=B4=EC=9D=B4=20=EB=82=A8?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EA=B2=8C=20=C2=B7=20=EA=B3=A0=EB=A5=B4?= =?UTF-8?q?=EB=A9=B4=20=EA=B9=8C=EB=8B=AD=20=EC=A4=84=20=EC=A7=80=EC=9B=80?= =?UTF-8?q?(PLAN=2035-5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SheetLogic 전부 검사 초록 - ORCA 견본(상자 7 · 선 8): 단계마다 x 16 · 299 · 582 · 866 · 1149 · 선 끝 알약에서 0.05px 안 · 띠 321 / 화면 1076 · 끌어 잇기 → on.connect · 상자 몸통에 놓으면 같은 이름 자리 · 막히면 한 줄 까닭 · 선 고름 → Delete → on.disconnect · 상자 고르면 이어진 선 넷만 진하게 · 잇기 단추 길 · + 상자 → on.add · Delete → 물음 → on.remove · 밝음 · 어두움 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z --- M01_MasterData/SheetLogic/M01_SL_Diagram.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/M01_MasterData/SheetLogic/M01_SL_Diagram.ts b/M01_MasterData/SheetLogic/M01_SL_Diagram.ts index c32d15315..f9bbf1801 100644 --- a/M01_MasterData/SheetLogic/M01_SL_Diagram.ts +++ b/M01_MasterData/SheetLogic/M01_SL_Diagram.ts @@ -129,6 +129,8 @@ interface HostState { pill: { node: string; pill: string; dir: "in" | "out" } | null; note: string; scroll: number; + /** 선 다시 그리기 — 띠 크기 바뀜 */ + watch?: ResizeObserver; } const states = new WeakMap(); @@ -159,6 +161,7 @@ export function renderDiagram( const select = (id: string | null): void => { st.selected = id; st.pill = null; + st.note = ""; on.select(id); redraw(); }; @@ -455,7 +458,11 @@ export function renderDiagram( svg.append(g); } }; - requestAnimationFrame(drawLines); + // 붙어 있으면 바로 · 나중에 붙거나 크기가 바뀌면 다시(띠 밀기 · 창 크기) + st.watch?.disconnect(); + st.watch = typeof ResizeObserver === "undefined" ? undefined : new ResizeObserver(drawLines); + st.watch?.observe(inner); + if (inner.isConnected) drawLines(); /* 끌어 잇기 */ function startDrag(ev: PointerEvent, from: SlEnd): void { @@ -494,8 +501,10 @@ export function renderDiagram( : boxHit ? { node: boxHit.dataset.node ?? "", pill: null, dir: null } : null; - // 끈 뒤 따라오는 click 을 한 번 삼킴 - window.addEventListener("click", (c) => c.stopPropagation(), { capture: true, once: true }); + // 끈 뒤 바로 따라오는 click 만 삼킴 — 안 오면 곧 풀어 다음 누름은 그대로 + const swallow = (c: MouseEvent): void => c.stopPropagation(); + window.addEventListener("click", swallow, { capture: true, once: true }); + setTimeout(() => window.removeEventListener("click", swallow, { capture: true }), 0); tryConnect(from, target); }; window.addEventListener("pointermove", move); From 50ecb5abed1e53db757142ab342c664d39b9a467 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 13:05:55 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat(M01-=EC=8B=9C=ED=8A=B8=EB=A1=9C?= =?UTF-8?q?=EC=A7=81):=20=EC=95=84=EB=9E=98=20=EC=8B=9C=ED=8A=B8=20?= =?UTF-8?q?=ED=8C=90=20Panel=20=EC=B6=94=EA=B0=80=20-=20=EC=8B=9C=ED=8A=B8?= =?UTF-8?q?=20=ED=8E=B8=EC=A7=91=EA=B8=B0=20=C2=B7=20=EC=9E=85=EB=A0=A5/?= =?UTF-8?q?=EC=B6=9C=EB=A0=A5=20=EC=95=8C=EC=95=BD=20=C2=B7=20=EB=B0=9B?= =?UTF-8?q?=EB=8A=94=20=EC=B9=B8=20=EC=9E=A0=EA=B8=88=20=C2=B7=20=ED=8B=80?= =?UTF-8?q?=20=EB=8B=A8=EC=B6=94=20=C2=B7=20=ED=95=9C=20=EC=A4=84=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c --- M01_MasterData/SheetLogic/M01_SL_Panel.css | 172 +++++++ M01_MasterData/SheetLogic/M01_SL_Panel.ts | 544 +++++++++++++++++++++ 2 files changed, 716 insertions(+) create mode 100644 M01_MasterData/SheetLogic/M01_SL_Panel.css create mode 100644 M01_MasterData/SheetLogic/M01_SL_Panel.ts diff --git a/M01_MasterData/SheetLogic/M01_SL_Panel.css b/M01_MasterData/SheetLogic/M01_SL_Panel.css new file mode 100644 index 000000000..d67df74d1 --- /dev/null +++ b/M01_MasterData/SheetLogic/M01_SL_Panel.css @@ -0,0 +1,172 @@ +.m01-slpanel { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + font-size: var(--text-body); + color: var(--color-text-body); +} + +.m01-slpanel-head, +.m01-slpanel-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8); +} + +.m01-slpanel-bar { + display: flex; + flex-direction: column; + gap: var(--spacing-8); +} + +.m01-slpanel-cell { + padding: 2px var(--spacing-12); + border: 1px solid var(--color-border); + border-radius: var(--radius-pills); + background: var(--color-surface-raised); +} + +.m01-slpanel-note { + color: var(--color-text-muted); + font-size: var(--text-body-sm); +} + +.m01-slpanel .ui-btn, +.m01-slpanel .ui-input { + min-height: 44px; + font-size: var(--text-body); +} + +.m01-slpanel-name { + flex: none; + width: 12em; +} + +.m01-slpanel-msg { + margin: 0; + padding: var(--spacing-8) var(--spacing-12); + border-left: 4px solid var(--color-warning); + background: var(--color-surface-raised); + font-weight: var(--font-weight-medium); +} + +.m01-slpanel-sheet { + position: relative; + min-height: 260px; + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + overflow: hidden; +} + +.m01-slpanel-pills { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-8); +} + +.m01-slpanel-pill, +.m01-slpanel-dir { + min-height: 44px; + padding: 0 var(--spacing-12); + border: 2px solid; + border-radius: var(--radius-pills); + background: var(--color-surface); + color: inherit; + font: inherit; + font-weight: var(--font-weight-medium); + cursor: pointer; +} + +.m01-slpanel-dir { + display: inline-flex; + align-items: center; + min-height: 0; + cursor: default; +} + +.m01-slpanel-pill:focus-visible { + outline: 2px solid var(--color-focus-ring); +} + +.m01-slpanel-pill.is-on { + background: var(--color-surface-raised); + font-weight: var(--font-weight-bold); +} + +.is-in { + border-color: #3b82f6; +} + +.is-out { + border-color: #f59e0b; +} + +.m01-slpanel-info { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + padding: var(--spacing-12); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-surface-raised); +} + +.m01-slpanel-infoline { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-8); +} + +.m01-slpanel-infoline > .m01-slpanel-note { + min-width: 6em; +} + +/* 칸 위 알약 표시 — 색 + 글자(입력 · 출력) 둘 다 */ +.m01-slpanel .aislo-cell[data-sl-pill="in"] { + box-shadow: inset 0 0 0 2px #3b82f6; +} + +.m01-slpanel .aislo-cell[data-sl-pill="out"] { + box-shadow: inset 0 0 0 2px #f59e0b; +} + +.m01-slpanel .aislo-cell[data-sl-pill]::after { + content: attr(data-sl-name); + position: absolute; + top: 0; + right: 0; + padding: 0 4px; + border-radius: 0 0 0 6px; + background: var(--color-surface); + color: var(--color-text); + font-size: 10px; + line-height: 14px; + pointer-events: none; +} + +.m01-slpanel .aislo-cell[data-sl-pill="in"]::after { + border: 1px solid #3b82f6; +} + +.m01-slpanel .aislo-cell[data-sl-pill="out"]::after { + border: 1px solid #f59e0b; +} + +.m01-slpanel .aislo-cell[data-sl-lock]::before { + content: "🔒 " attr(data-sl-lock); + position: absolute; + bottom: 0; + left: 0; + padding: 0 4px; + background: var(--color-surface); + color: var(--color-text); + font-size: 10px; + line-height: 14px; + pointer-events: none; +} + +.m01-slpanel .aislo-cell[data-sl-err] { + outline: 2px dashed var(--color-danger); + outline-offset: -2px; +} diff --git a/M01_MasterData/SheetLogic/M01_SL_Panel.ts b/M01_MasterData/SheetLogic/M01_SL_Panel.ts new file mode 100644 index 000000000..f2a4c112f --- /dev/null +++ b/M01_MasterData/SheetLogic/M01_SL_Panel.ts @@ -0,0 +1,544 @@ +/* ============================================================================= + * M01_SL_Panel.ts + * 시트 로직 아래 시트 판 — 고른 상자의 시트를 우리 스프레드시트 편집기로 · 칸 → 입력/출력 알약 · 칸 위 알약 표시 · + * 받는 알약 칸 잠금 · 틀 단추(진짜 엑셀 수식) · 틀린 수식/안 이어진 입력 알약 한 줄 + * 모양은 `docs/raw/M01_쉬운단가/시트로직_정함.md` · M01_SL_Model 타입 그대로 · 편집기는 M02 미리보기가 쓰는 그것(늦게 불러옴) + * 글 [한, 영] 짝은 아래 TEXT + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; +import type { + CalcValue, + CalcValues, + SpreadsheetHandle, + Workbook, +} from "../../A00_Common/spreadsheet/spreadsheet"; +import { disconnect, type SheetLogic, type SlEdge, type SlNode, type SlPill } from "./M01_SL_Model"; +import "./M01_SL_Panel.css"; + +const TEXT = { + Cell: ["고른 칸", "Selected cell"], + PillIn: ["입력 알약으로", "Make input pill"], + PillOut: ["출력 알약으로", "Make output pill"], + AskName: ["알약 이름", "Pill name"], + Ok: ["정함", "Set"], + Cancel: ["취소", "Cancel"], + Frames: ["틀", "Templates"], + FrameMore: ["할증", "Markup"], + FrameDiv: ["나누기", "Divide"], + FrameIf: ["만약", "If"], + FrameMin: ["최소", "Min"], + FrameMax: ["최대", "Max"], + FrameUp: ["올림", "Round up"], + FrameLook: ["표에서 찾기", "Look up in table"], + FrameHint: ["칸 주소만 바꾸면 됨", "Just change the cell addresses"], + In: ["입력", "In"], + Out: ["출력", "Out"], + Lock: ["선", "Wire"], + Pills: ["알약", "Pills"], + NoPills: [ + "아직 알약이 없음 — 칸을 골라 알약으로 정함", + "No pills yet — pick a cell and make it a pill", + ], + Value: ["값", "Value"], + Unit: ["단위", "Unit"], + From: ["어디서 오나", "Comes from"], + To: ["어디로 가나", "Goes to"], + NoFrom: ["아직 이어지지 않음", "Not connected yet"], + NoTo: ["아직 어디로도 안 감", "Goes nowhere yet"], + Close: ["닫기", "Close"], + ReadOnly: ["표준은 읽기만", "Read only"], + Loading: ["시트를 불러오는 중", "Loading sheet"], + BreakAsk: [ + "이 칸은 선으로 들어오는 값입니다. 선을 끊고 직접 고칠까요?", + "This cell receives a wire. Cut the wire and edit it yourself?", + ], + BreakOk: ["선을 끊고 고침", "Cut and edit"], + Cannot: ["이 고침은 지금 할 수 없음", "This change cannot be made now"], + Problem: ["⚠ ", "⚠ "], + Unwired: [" 입력 알약이 아직 이어지지 않음", " input pill is not connected yet"], + Err_DIV0: ["0으로 나눔", "divides by zero"], + Err_NAME: ["알 수 없는 이름", "unknown name"], + Err_REF: ["없는 칸을 가리킴", "points to a missing cell"], + Err_VALUE: ["값 종류가 안 맞음", "wrong kind of value"], + Err_CYCLE: ["서로 돌려 가리킴(순환)", "circular reference"], + Err_NA: ["찾는 값이 없음", "value not found"], + Err_OTHER: ["수식이 틀림", "formula is wrong"], +} as const; + +const tp = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex]; + +/** 틀 단추 — 진짜 엑셀 수식을 고른 칸에 넣음 */ +const FRAMES: [keyof typeof TEXT, string][] = [ + ["FrameMore", "=B1*(1+B2/100)"], + ["FrameDiv", "=B1/B2"], + ["FrameIf", '=IF(B3="급",10,0)'], + ["FrameMin", "=MIN(B1,B2)"], + ["FrameMax", "=MAX(B1,B2)"], + ["FrameUp", "=ROUNDUP(B4,3)"], + ["FrameLook", "=XLOOKUP(B1,E1:E10,F1:F10)"], +]; + +const ERR_KEY: Record = { + "#DIV/0!": "Err_DIV0", + "#NAME?": "Err_NAME", + "#REF!": "Err_REF", + "#VALUE!": "Err_VALUE", + "#CYCLE!": "Err_CYCLE", + "#N/A": "Err_NA", +}; + +type Handlers = { + change(sl: SheetLogic): void; + pill(nodeId: string, cell: string, dir: "in" | "out", name: string): void; +}; +type SheetModule = typeof import("../../A00_Common/spreadsheet/spreadsheet"); + +interface State { + nodeId: string; + sl: SheetLogic; + on: Handlers; + readOnly: boolean; + handle: SpreadsheetHandle | null; + /** 수식 칸 계산값 — 바뀐 뒤에만 새로 받음(받으면 격자가 다시 그려져, 표시 쪽에서 부르면 맴돎) */ + snap: CalcValues; + /** 편집기가 마지막으로 낸 문서(활성 빼고) — 같으면 다시 안 끼움 */ + json: string; + cell: string; + info: { dir: "in" | "out"; name: string } | null; + asking: "in" | "out" | null; + busy: boolean; + parts: { + cell: HTMLElement; + bar: HTMLElement; + msg: HTMLElement; + sheet: HTMLElement; + pills: HTMLElement; + info: HTMLElement; + }; + observer: MutationObserver | null; + redraw(): void; +} + +const states = new WeakMap(); +let sheetModule: Promise | null = null; +/** 편집기는 M02 처럼 늦게 불러옴 — 정적 import 금지 */ +const loadSheet = (): Promise => + (sheetModule ??= import("../../A00_Common/spreadsheet/spreadsheet")); + +const norm = (book: Workbook): string => JSON.stringify({ ...book, 활성: null, 계산값: null }); +const nodeOf = (st: State): SlNode | undefined => st.sl.nodes.find((n) => n.id === st.nodeId); +const shown = (st: State): Workbook => ({ + ...st.sl.workbook, + 활성: nodeOf(st)?.sheetId ?? st.sl.workbook.활성, +}); + +/* ------------------------------ 값 · 말 ------------------------------ */ + +function valueText(v: CalcValue | number | string | boolean | undefined | null): string { + if (v === undefined || v === null) return ""; + if (typeof v !== "object") return String(v); + if ("수" in v) return v.수; + if ("글" in v) return v.글; + if ("참" in v) return v.참 ? "TRUE" : "FALSE"; + return v.오류; +} + +function valueAt(st: State, addr: string): string { + const node = nodeOf(st); + const sheet = st.sl.workbook.시트.find((s) => s.id === node?.sheetId); + const cell = sheet?.칸[addr]; + if (!cell) return ""; + if (cell.식 === undefined) return valueText(cell.값); + return valueText(st.snap[sheet!.id]?.[addr]); +} + +const edgesIn = (st: State, pill: string): SlEdge[] => + st.sl.edges.filter((e) => e.to.node === st.nodeId && e.to.pill === pill); +const edgesOut = (st: State, pill: string): SlEdge[] => + st.sl.edges.filter((e) => e.from.node === st.nodeId && e.from.pill === pill); + +/** 선으로 값을 받는 칸 — 직접 고치기 전에 물음 */ +function lockedCells(st: State): Map { + const out = new Map(); + for (const p of nodeOf(st)?.pillsIn ?? []) { + const e = edgesIn(st, p.name)[0]; + if (e) out.set(p.cell, e); + } + return out; +} + +/** 틀린 수식 · 이어지지 않은 입력 알약 — 칸 → 까닭 */ +function problems(st: State): Map { + const out = new Map(); + const node = nodeOf(st); + if (!node || !st.handle) return out; + const sheet = st.sl.workbook.시트.find((s) => s.id === node.sheetId); + const snap = st.snap[node.sheetId] ?? {}; + for (const [addr, v] of Object.entries(snap)) { + if (typeof v === "object" && "오류" in v) { + out.set(addr, `${addr} ${tp(ERR_KEY[v.오류] ?? "Err_OTHER")}`); + } + } + for (const p of node.pillsIn) { + const filled = sheet?.칸[p.cell]?.식 !== undefined; + if (!edgesIn(st, p.name).length && !filled && node.kind !== "input" && node.kind !== "const") { + out.set(p.cell, `${p.name}(${p.cell})${tp("Unwired")}`); + } + } + return out; +} + +/** 계산값 새로 받고 표시 다시 — 편집기가 바뀐 뒤 · 처음 뜬 뒤에만 */ +function refreshSnap(st: State): void { + if (!st.handle) return; + st.snap = st.handle.recalc(); + st.redraw(); +} + +/* ------------------------------ 칸 위 표시 ------------------------------ */ + +function applyMarks(st: State): void { + const node = nodeOf(st); + const grid = st.parts.sheet; + if (!node) return; + const pill = new Map(); + for (const p of node.pillsIn) pill.set(p.cell, { dir: "in", p }); + for (const p of node.pillsOut) pill.set(p.cell, { dir: "out", p }); + const lock = lockedCells(st); + const bad = problems(st); + for (const c of grid.querySelectorAll(".aislo-cell")) { + const addr = c.dataset.addr ?? ""; + const hit = pill.get(addr); + for (const a of ["slPill", "slName", "slLock", "slErr"]) delete c.dataset[a]; + if (hit) { + c.dataset.slPill = hit.dir; + c.dataset.slName = `${tp(hit.dir === "in" ? "In" : "Out")} ${hit.p.name}`; + } + if (lock.has(addr)) c.dataset.slLock = tp("Lock"); + if (bad.has(addr)) c.dataset.slErr = "1"; + } + const first = [...bad.values()][0]; + st.parts.msg.textContent = first + ? `${tp("Problem")}${first}${bad.size > 1 ? ` (+${bad.size - 1})` : ""}` + : ""; + st.parts.msg.hidden = !first; +} + +/* ------------------------------ 위 줄 · 알약 목록 ------------------------------ */ + +function nameDefault(st: State): string { + const row = st.cell.replace(/^[A-Z]+/, ""); + const sheet = st.sl.workbook.시트.find((s) => s.id === nodeOf(st)?.sheetId); + const a = sheet?.칸[`A${row}`]; + return a ? valueText(a.값 ?? a.식) : ""; +} + +function drawBar(st: State): void { + const { bar, cell } = st.parts; + cell.textContent = `${tp("Cell")} ${st.cell}`; + const frag: (HTMLElement | string)[] = []; + if (st.readOnly) { + bar.replaceChildren(el("span", { className: "m01-slpanel-note", text: tp("ReadOnly") })); + return; + } + const ask = (dir: "in" | "out"): void => { + st.asking = dir; + drawBar(st); + }; + const pillRow = el("div", { + className: "m01-slpanel-row", + children: [ + createButton({ label: tp("PillIn"), variant: "ghost", size: "sm", onClick: () => ask("in") }), + createButton({ + label: tp("PillOut"), + variant: "ghost", + size: "sm", + onClick: () => ask("out"), + }), + ], + }); + frag.push(pillRow); + if (st.asking) { + const dir = st.asking; + const input = el("input", { + className: "ui-input m01-slpanel-name", + attrs: { type: "text", "aria-label": tp("AskName") }, + }); + input.value = nameDefault(st); + const done = (): void => { + const name = input.value.trim(); + if (!name) return; + st.asking = null; + st.on.pill(st.nodeId, st.cell, dir, name); + drawBar(st); + }; + input.addEventListener("keydown", (e) => e.key === "Enter" && done()); + frag.push( + el("div", { + className: "m01-slpanel-row", + children: [ + el("span", { + className: `m01-slpanel-dir is-${dir}`, + text: tp(dir === "in" ? "In" : "Out"), + }), + input, + createButton({ label: tp("Ok"), variant: "filled", size: "sm", onClick: done }), + createButton({ + label: tp("Cancel"), + variant: "ghost", + size: "sm", + onClick: () => ((st.asking = null), drawBar(st)), + }), + ], + }), + ); + queueMicrotask(() => input.focus()); + } + frag.push( + el("div", { + className: "m01-slpanel-row", + children: [ + el("span", { className: "m01-slpanel-note", text: tp("Frames") }), + ...FRAMES.map(([key, formula]) => + createButton({ + label: tp(key), + variant: "ghost", + size: "sm", + onClick: () => putFormula(st, formula), + }), + ), + el("span", { className: "m01-slpanel-note", text: tp("FrameHint") }), + ], + }), + ); + bar.replaceChildren(...frag.filter((x): x is HTMLElement => typeof x !== "string")); +} + +function drawPills(st: State): void { + const node = nodeOf(st); + const chips: HTMLElement[] = []; + const add = (dir: "in" | "out", p: SlPill): void => { + const b = el("button", { + className: `m01-slpanel-pill is-${dir}${st.info?.name === p.name && st.info.dir === dir ? " is-on" : ""}`, + text: `${tp(dir === "in" ? "In" : "Out")} · ${p.name} (${p.cell})`, + attrs: { type: "button" }, + }); + b.addEventListener("click", () => ((st.info = { dir, name: p.name }), drawPills(st))); + chips.push(b); + }; + for (const p of node?.pillsIn ?? []) add("in", p); + for (const p of node?.pillsOut ?? []) add("out", p); + st.parts.pills.replaceChildren( + ...(chips.length + ? chips + : [el("span", { className: "m01-slpanel-note", text: tp("NoPills") })]), + ); + drawInfo(st); +} + +function drawInfo(st: State): void { + const box = st.parts.info; + const node = nodeOf(st); + const p = (st.info?.dir === "in" ? node?.pillsIn : node?.pillsOut)?.find( + (x) => x.name === st.info?.name, + ); + if (!st.info || !p || !node) { + box.hidden = true; + box.replaceChildren(); + return; + } + const dir = st.info.dir; + const nameOf = (id: string): string => st.sl.nodes.find((n) => n.id === id)?.name ?? ""; + const links = (dir === "in" ? edgesIn(st, p.name) : edgesOut(st, p.name)).map((e) => { + const end = dir === "in" ? e.from : e.to; + return `${nameOf(end.node)} · ${end.pill}`; + }); + const rows: [string, string][] = [ + [tp("Value"), valueAt(st, p.cell)], + [tp("Unit"), p.unit ?? ""], + [ + tp(dir === "in" ? "From" : "To"), + links.length ? links.join(", ") : tp(dir === "in" ? "NoFrom" : "NoTo"), + ], + ]; + box.hidden = false; + box.replaceChildren( + el("strong", { text: `${tp(dir === "in" ? "In" : "Out")} · ${p.name} (${p.cell})` }), + ...rows.map(([k, v]) => + el("div", { + className: "m01-slpanel-infoline", + children: [el("span", { className: "m01-slpanel-note", text: k }), v], + }), + ), + createButton({ + label: tp("Close"), + variant: "ghost", + size: "sm", + onClick: () => ((st.info = null), drawPills(st)), + }), + ); +} + +/* ------------------------------ 편집기 ------------------------------ */ + +/** 틀 수식을 고른 칸에 넣음 — 수식 입력줄에 쳐서 확정하는 길(되돌리기 · 검사 그대로) */ +function putFormula(st: State, formula: string): void { + const root = st.parts.sheet; + const bar = root.querySelector(".ss-formula"); + const addr = root.querySelector(".ss-addr"); + if (!bar || !addr || st.readOnly) return; + const at = st.cell; + bar.focus(); + bar.value = formula; + bar.dispatchEvent(new Event("input", { bubbles: true })); + bar.dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", ctrlKey: true, bubbles: true, cancelable: true }), + ); + addr.value = at; + addr.dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }), + ); +} + +/** 편집기가 문서를 냄 — 선 받는 칸을 직접 고쳤으면 먼저 물음 */ +async function onBook(st: State, book: Workbook): Promise { + if (st.busy) return; + const prev = st.sl; + const node = nodeOf(st); + const next: SheetLogic = { ...prev, workbook: { ...book, 활성: prev.workbook.활성 } }; + const before = prev.workbook.시트.find((s) => s.id === node?.sheetId); + const after = book.시트.find((s) => s.id === node?.sheetId); + const hit = [...lockedCells(st)].find( + ([addr]) => JSON.stringify(before?.칸[addr]) !== JSON.stringify(after?.칸[addr]), + ); + if (!hit) { + st.json = norm(book); + st.sl = next; + st.on.change(next); + return void setTimeout(() => refreshSnap(st), 0); + } + st.busy = true; + const ok = await showConfirmDialog(tp("BreakAsk"), tp("BreakOk")); + st.busy = false; + if (!ok) { + st.json = norm(shown(st)); + return st.handle?.setDoc(structuredClone(shown(st))); + } + try { + const cut = disconnect(next, hit[1].id); + const sheet = cut.workbook.시트.find((s) => s.id === node?.sheetId); + const mine = after?.칸[hit[0]]; + if (sheet) { + if (mine) sheet.칸[hit[0]] = mine; + else delete sheet.칸[hit[0]]; + } + st.json = norm(cut.workbook); + st.sl = cut; + st.on.change(cut); + setTimeout(() => refreshSnap(st), 0); + } catch { + showToast(tp("Cannot"), "error"); + st.handle?.setDoc(structuredClone(shown(st))); + } +} + +async function mountSheet(st: State): Promise { + const { createSpreadsheet } = await loadSheet(); + if (!st.parts.sheet.isConnected || st.handle) return; + st.parts.sheet.replaceChildren(); + const book = structuredClone(shown(st)); // 편집기가 제자리에서 고치므로 사본을 줌 + st.json = norm(book); + st.handle = createSpreadsheet(st.parts.sheet, book, { + preview: true, + tabs: false, + readOnly: st.readOnly, + onChange: (b) => void onBook(st, b), + onSelect: (s) => { + st.cell = s.칸; + st.parts.cell.textContent = `${tp("Cell")} ${st.cell}`; + if (st.asking) drawBar(st); + }, + }); + st.observer = new MutationObserver(() => requestAnimationFrame(() => applyMarks(st))); + st.observer.observe(st.parts.sheet, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ["data-addr"], + }); + refreshSnap(st); +} + +/** 아래 시트 판 그리기 — 같은 상자면 편집기를 그대로 두고 알약 · 표시만 다시 */ +export function renderPanel( + host: HTMLElement, + sl: SheetLogic, + nodeId: string, + on: Handlers, + opts: { readOnly?: boolean } = {}, +): void { + const old = states.get(host); + const readOnly = !!opts.readOnly; + if (old && old.nodeId === nodeId && old.readOnly === readOnly) { + old.sl = sl; + old.on = on; + if (old.handle && norm(shown(old)) !== old.json) { + old.json = norm(shown(old)); + old.handle.setDoc(structuredClone(shown(old))); + setTimeout(() => refreshSnap(old), 0); + return; + } + return old.redraw(); + } + if (old) { + old.observer?.disconnect(); + old.handle?.destroy(); + } + const parts: State["parts"] = { + cell: el("span", { className: "m01-slpanel-cell", text: `${tp("Cell")} A1` }), + bar: el("div", { className: "m01-slpanel-bar" }), + msg: el("p", { className: "m01-slpanel-msg", attrs: { role: "status" } }), + sheet: el("div", { className: "m01-slpanel-sheet", text: tp("Loading") }), + pills: el("div", { className: "m01-slpanel-pills" }), + info: el("div", { className: "m01-slpanel-info" }), + }; + const st: State = { + nodeId, + sl, + on, + readOnly, + handle: null, + snap: {}, + json: "", + cell: "A1", + info: null, + asking: null, + busy: false, + parts, + observer: null, + redraw: () => { + drawBar(st); + drawPills(st); + applyMarks(st); + }, + }; + states.set(host, st); + const node = nodeOf(st); + host.classList.add("m01-slpanel"); + host.replaceChildren( + el("div", { + className: "m01-slpanel-head", + children: [el("strong", { text: node?.name ?? "" }), parts.cell], + }), + parts.bar, + parts.msg, + parts.sheet, + parts.pills, + parts.info, + ); + parts.msg.hidden = true; + st.redraw(); + void mountSheet(st); +} From 7f98edac2c54bdece28daf5d3388e0011d8615f4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 13:07:36 +0900 Subject: [PATCH 4/4] =?UTF-8?q?feat(m01-sl):=20=EC=8B=9C=ED=8A=B8=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=ED=95=AD=EB=AA=A9=20=EC=83=81=EC=9E=90=20?= =?UTF-8?q?=EC=86=90=EC=9E=A1=EC=9D=B4(Item)=20=C2=B7=20=EA=B2=B0=EA=B3=BC?= =?UTF-8?q?=20=EC=B9=B8(Result)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 항목 종류(노무 · 재료 · 장비 · 일위대가 · 비율) 틀 · 단가 바깥 값 자리 · 바탕 이음, 큰 합계 · 일위대가표 · 예시값 띠 · 빈 입력 · 오류. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr --- M01_MasterData/SheetLogic/M01_SL_Item.css | 76 +++ M01_MasterData/SheetLogic/M01_SL_Item.ts | 642 ++++++++++++++++++++ M01_MasterData/SheetLogic/M01_SL_Result.css | 98 +++ M01_MasterData/SheetLogic/M01_SL_Result.ts | 243 ++++++++ 4 files changed, 1059 insertions(+) create mode 100644 M01_MasterData/SheetLogic/M01_SL_Item.css create mode 100644 M01_MasterData/SheetLogic/M01_SL_Item.ts create mode 100644 M01_MasterData/SheetLogic/M01_SL_Result.css create mode 100644 M01_MasterData/SheetLogic/M01_SL_Result.ts diff --git a/M01_MasterData/SheetLogic/M01_SL_Item.css b/M01_MasterData/SheetLogic/M01_SL_Item.css new file mode 100644 index 000000000..45f8a6fa5 --- /dev/null +++ b/M01_MasterData/SheetLogic/M01_SL_Item.css @@ -0,0 +1,76 @@ +.m01-slitem__title { + color: var(--color-text-body); + font-size: var(--text-body); +} + +.m01-slitem__body { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: var(--spacing-8) var(--spacing-16); + margin: var(--spacing-8) 0; +} + +.m01-slitem__field { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + color: var(--color-text-secondary); + font-size: var(--text-body-sm); +} + +.m01-slitem__input { + box-sizing: border-box; + width: 120px; + 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); + text-align: right; +} + +.m01-slitem__input[readonly] { + background: var(--color-surface); +} + +.m01-slitem__chosen { + margin: 0; + color: var(--color-text-body); + font-weight: 600; +} + +.m01-slitem__prices { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-8) var(--spacing-16); +} + +.m01-slitem__price { + display: flex; + flex-direction: column; + color: var(--color-text-secondary); + font-size: var(--text-caption); + font-variant-numeric: tabular-nums; +} + +.m01-slitem__price strong { + color: var(--color-text-body); + font-size: var(--text-body-sm); +} + +.m01-slitem__muted { + flex-basis: 100%; + margin: 0; + color: var(--color-text-muted); + font-size: var(--text-caption); +} + +.m01-slitem__msg { + margin: var(--spacing-4) 0 0; + color: var(--color-danger); + font-size: var(--text-caption); +} diff --git a/M01_MasterData/SheetLogic/M01_SL_Item.ts b/M01_MasterData/SheetLogic/M01_SL_Item.ts new file mode 100644 index 000000000..9161771de --- /dev/null +++ b/M01_MasterData/SheetLogic/M01_SL_Item.ts @@ -0,0 +1,642 @@ +/* ============================================================================= + * 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, + ); +} diff --git a/M01_MasterData/SheetLogic/M01_SL_Result.css b/M01_MasterData/SheetLogic/M01_SL_Result.css new file mode 100644 index 000000000..d173658af --- /dev/null +++ b/M01_MasterData/SheetLogic/M01_SL_Result.css @@ -0,0 +1,98 @@ +.m01-slresult__big { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: var(--spacing-8); + margin: 0 0 var(--spacing-8); + color: var(--color-text-secondary); +} + +.m01-slresult__big strong { + color: var(--color-text-body); + font-size: 28px; + font-variant-numeric: tabular-nums; +} + +.m01-slresult__costs { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-4) var(--spacing-16); + margin: 0 0 var(--spacing-12); + font-size: var(--text-body-sm); +} + +.m01-slresult__costs dt { + display: inline; + color: var(--color-text-secondary); +} + +.m01-slresult__costs dd { + display: inline; + margin: 0 0 0 var(--spacing-4); + color: var(--color-text-body); + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.m01-slresult__strip { + margin: 0 0 var(--spacing-12); + color: var(--color-text-body); +} + +.m01-slresult__fields { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-8) var(--spacing-16); + margin: var(--spacing-8) 0; +} + +.m01-slresult__field { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + color: var(--color-text-secondary); + font-size: var(--text-body-sm); +} + +.m01-slresult__input { + box-sizing: border-box; + width: 140px; + 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-slresult__input[readonly] { + background: var(--color-surface); +} + +.m01-slresult__muted { + margin: 0; + color: var(--color-text-muted); + font-size: var(--text-caption); +} + +.m01-slresult__msg { + margin: var(--spacing-4) 0 0; + color: var(--color-danger); + font-size: var(--text-caption); +} + +.m01-slresult__problems { + margin: 0 0 var(--spacing-12); + padding: var(--spacing-8) var(--spacing-12); + border-left: 4px solid var(--color-danger); + background: var(--color-surface); + color: var(--color-text-body); + font-size: var(--text-body-sm); +} + +.m01-slresult__problems ul { + margin: var(--spacing-4) 0 0; + padding-left: var(--spacing-16); +} diff --git a/M01_MasterData/SheetLogic/M01_SL_Result.ts b/M01_MasterData/SheetLogic/M01_SL_Result.ts new file mode 100644 index 000000000..213fdd077 --- /dev/null +++ b/M01_MasterData/SheetLogic/M01_SL_Result.ts @@ -0,0 +1,243 @@ +/* ============================================================================= + * M01_SL_Result.ts + * 시트 로직 — 오른쪽 결과 칸: 큰 합계 · 노무비 · 재료비 · 경비 · 일위대가표(EasyCalc Sheet 에 값을 맞춰 넣음) + * · 예시값 띠(입력값 상자의 예시값을 여기서도 바꿈) · 오류 · 빈 입력 한 줄 + * 값은 화면 계산(Eval `evalLogic`) 결과를 받음 — 이 파일은 셈하지 않고 읽어서 보여 줌. + * 모양은 docs/raw/M01_쉬운단가/시트로직_정함.md 8. 이 폴더째 지워도 됨. + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { createSelectField, el } from "@ui/ui_template_elements"; +import type { + Cell, + DataValidationRule, + Sheet, +} from "../../A00_Common/spreadsheet/spreadsheet_types"; +import type { CalcAnswer, CalcLine } from "../M01_MasterData_UI_Logic_Api"; +import { formatMoney } from "../M01_MasterData_UI_Logic_Money"; +import { renderSheet } from "../EasyCalc/M01_EasyCalc_Sheet"; +import type { SlEvalResult } from "./M01_SL_Eval"; +import { PILL_AMOUNT, PILL_PRICE, USED, itemRefOf } from "./M01_SL_Item"; +import type { SheetLogic, SlNode, SlPill } from "./M01_SL_Model"; +import "./M01_SL_Result.css"; + +const TEXT = { + Per: ["{per} 당", "per {per}"], + Total: ["합계", "Total"], + Won: ["원", "KRW"], + Labor: ["노무비", "Labor cost"], + Material: ["재료비", "Material cost"], + Expense: ["경비", "Expense"], + Sample: ["예시값", "Sample values"], + SampleHint: [ + "여기서 바꾸면 입력값 상자의 예시값도 바뀌어요", + "Changing here changes the input box too", + ], + Problems: ["고칠 것", "To fix"], + Empty: ["「{box}」 상자의 「{pill}」 칸이 비었어요", "“{pill}” in box “{box}” is empty"], + Bad: ["숫자를 넣어 주세요", "Enter a number"], + Range: ["{a} ∼ {b} 사이", "between {a} and {b}"], + Cant: ["지금은 바꿀 수 없어요", "Cannot change this now"], + Pick: ["고르세요", "Choose"], + RateOf: ["{base}의 %", "% of {base}"], +} as const; + +type TextKey = keyof typeof TEXT; +const tr = (key: TextKey, fill: Record = {}): string => + TEXT[key][currentLanguageIndex].replace(/\{(\w+)\}/g, (m, k: string) => fill[k] ?? m); + +const COSTS = ["노무비", "재료비", "경비"] as const; +const n0 = (v: unknown): number => (typeof v === "number" && Number.isFinite(v) ? v : 0); + +const sheetOf = (sl: SheetLogic, node: SlNode): Sheet | undefined => + sl.workbook.시트.find((s) => s.id === node.sheetId); +const colOf = (cell: string): { r: number; c: number } => ({ + r: Number(cell.replace(/\D/g, "")) - 1, + c: cell.charCodeAt(0) - 65, +}); + +/** 일위대가표 줄 — 항목 상자 하나 = 줄 하나(출력 알약을 읽음) */ +function linesOf(sl: SheetLogic, values: SlEvalResult): CalcLine[] { + return sl.nodes + .filter((n) => n.kind === "item") + .map((n) => { + const ref = itemRefOf(n); + const p = values.pills[n.id] ?? {}; + const unit = n.pillsOut.find((x) => x.name === USED)?.unit ?? ""; + const pickName = (name: string): number => n0(p[name]); + return { + 이름: n.name, + 단위: unit, + 수량: pickName(USED), + 단가: pickName(PILL_PRICE), + 금액: pickName(PILL_AMOUNT), + 비목: Object.fromEntries(COSTS.map((c) => [c, pickName(c)])), + ...(ref?.what === "비율" + ? { 품목: { 키: "", 규격: tr("RateOf", { base: ref.base ?? "노무비" }) } } + : ref?.spec + ? { 품목: { 키: "", 규격: ref.spec } } + : {}), + }; + }); +} + +/** 빈 입력 — 입력 알약 칸에 값도 참조도 없는 것 */ +function emptyInputs(sl: SheetLogic): string[] { + return sl.nodes.flatMap((n) => { + const cells = sheetOf(sl, n)?.칸 ?? {}; + return n.pillsIn + .filter( + (p) => !p.outside && !(cells[p.cell] && ("값" in cells[p.cell] || "식" in cells[p.cell])), + ) + .map((p) => tr("Empty", { box: n.name, pill: p.name })); + }); +} + +/** 예시값 칸 — 선택 목록(유효성)이 걸린 칸은 고르기 · 수범위는 범위 안내 */ +function ruleAt(sheet: Sheet, cell: string): DataValidationRule["규칙"] | null { + const { r, c } = colOf(cell); + const hit = (sheet.유효성 ?? []).find((v) => + v.범위.some((g) => r >= g.r0 && r <= g.r1 && c >= g.c0 && c <= g.c1), + ); + return hit?.규칙 ?? null; +} + +function sampleField( + sl: SheetLogic, + node: SlNode, + pill: SlPill, + put: (cell: string, v: number | string) => void, + msg: HTMLElement, +): HTMLElement { + const sheet = sheetOf(sl, node); + const cur: Cell | undefined = sheet?.칸[pill.cell]; + const rule = sheet ? ruleAt(sheet, pill.cell) : null; + const label = el("span", { text: pill.unit ? `${pill.name} (${pill.unit})` : pill.name }); + let control: HTMLElement; + if (rule?.종류 === "목록") { + const field = createSelectField({ + options: [{ value: "", text: tr("Pick") }, ...rule.값들.map((v) => ({ value: v, text: v }))], + value: cur?.값 === undefined ? "" : String(cur.값), + compact: true, + ariaLabel: pill.name, + onChange: (v) => { + if (v !== "") put(pill.cell, v); + }, + }); + control = field.root; + } else { + const box = el("input", { + className: "m01-slresult__input", + attrs: { type: "text", inputmode: "decimal", "aria-label": pill.name }, + }); + if (rule?.종류 === "수범위") { + box.placeholder = tr("Range", { a: String(rule.아래 ?? ""), b: String(rule.위 ?? "") }); + } + const linked = cur?.식 !== undefined; + box.value = linked || cur?.값 === undefined ? "" : String(cur.값); + box.readOnly = linked; + box.addEventListener("change", () => { + const raw = box.value.trim(); + const n = Number(raw); + if (raw === "" || Number.isNaN(n)) { + msg.textContent = tr("Bad"); + return; + } + put(pill.cell, n); + }); + control = box; + } + return el("label", { className: "m01-slresult__field", children: [label, control] }); +} + +/** 결과 칸 — values = 화면 계산 결과 · 예시값을 바꾸면 on.change(새 시트 로직) */ +export function renderResult( + host: HTMLElement, + sl: SheetLogic, + values: SlEvalResult, + on: { change(sl: SheetLogic): void }, +): void { + const { 계 } = values.result; + const per = sl.resultUnit.includes("/") ? sl.resultUnit.split("/").slice(1).join("/") : ""; + 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")}` }), + ], + }); + const costs = el("dl", { + className: "m01-slresult__costs", + children: COSTS.flatMap((c, i) => [ + el("dt", { text: tr((["Labor", "Material", "Expense"] as const)[i]) }), + el("dd", { text: values.result[c] === null ? "—" : formatMoney(values.result[c]) }), + ]), + }); + + const head = { name: sl.name, resultUnit: sl.resultUnit }; + const problems = [...values.errors, ...emptyInputs(sl)]; + const ans: CalcAnswer = + values.errors.length || 계 === null + ? { ok: false, reason: values.errors[0] ?? emptyInputs(sl)[0] ?? "" } + : { + ok: true, + lines: linesOf(sl, values), + sums: { + 노무비: n0(values.result.노무비), + 재료비: n0(values.result.재료비), + 경비: n0(values.result.경비), + 계: n0(계), + }, + middle: {}, + }; + const sheetHost = el("div", { className: "m01-slresult__sheet" }); + renderSheet(sheetHost, head, ans); + + const msg = el("p", { className: "m01-slresult__msg" }); + const put = + (node: SlNode) => + (cell: string, v: number | string): void => { + msg.textContent = ""; + try { + const 시트 = sl.workbook.시트.map((s) => + s.id === node.sheetId ? { ...s, 칸: { ...s.칸, [cell]: { 값: v } } } : s, + ); + on.change({ ...sl, workbook: { ...sl.workbook, 시트 } }); + } catch { + msg.textContent = tr("Cant"); + } + }; + const fields = sl.nodes + .filter((n) => n.kind === "input") + .flatMap((n) => n.pillsOut.map((p) => sampleField(sl, n, p, put(n), msg))); + const strip = fields.length + ? [ + el("div", { + className: "m01-slresult__strip", + children: [ + el("strong", { text: tr("Sample") }), + el("div", { className: "m01-slresult__fields", children: fields }), + el("p", { className: "m01-slresult__muted", text: tr("SampleHint") }), + msg, + ], + }), + ] + : []; + host.replaceChildren( + big, + costs, + ...strip, + ...(problems.length + ? [ + el("div", { + className: "m01-slresult__problems", + children: [ + el("strong", { text: tr("Problems") }), + el("ul", { children: problems.map((t) => el("li", { text: t })) }), + ], + }), + ] + : []), + sheetHost, + ); +}