fix(m01-sl): 비율 바탕 이음은 아는 상자만 · 손선 보존, 결과 칸 값/단위, 다른 로직 손잡이

- refreshBases 를 Model connect/disconnect 로 바꿔 모르는 항목 상자와 손으로 이은 선을 안 건드림, applyItemLayout 내보냄
- 결과 칸: 돈 아닌 결과는 값과 단위, 「100㎡ 당」 표기
- 새 LogicTools: 다른 로직 찾기 · 고르기 · 넘길 값 · 결과 알약

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr
This commit is contained in:
2026-10-01 13:55:04 +09:00
co-authored by Claude Sonnet 5.5
parent a02ea70d2e
commit e5955b705d
4 changed files with 445 additions and 33 deletions
+33 -28
View File
@@ -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<string, string> = {
};
/** 상자 시트를 항목 틀로 채움 — 예전 수량 칸(값 · 이어진 참조)은 살림 */
/** 상자 시트를 항목 틀로 채움 — 가져오기(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;
}
@@ -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);
}
@@ -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<string, unknown> = {},
): 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<string, Cell> = {
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<string, LogicRow>();
/** 다른 로직 상자 손잡이 — 찾기 → 고르기 → 넘길 값 칸 */
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<void> => {
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<void> => {
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,
);
}
}
}
+28 -5
View File
@@ -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, string> = {}): 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
? [