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