Files
Aislo/M01_MasterData/SheetLogic/M01_SL_LogicTools.ts
T
eomsangdonandClaude Sonnet 5.5 e5955b705d 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
2026-10-01 13:55:04 +09:00

298 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* 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,
);
}
}
}