Files
Aislo/M02_MasterTemplete/M02_MasterTemplete_Sangeun_SheetFormula.ts
T
eomsangdonandClaude Opus 5.5 09a67ce91f feat(792): 단가산출 시트 식 칸 화면 — 줄 식 고침 · 칠 때마다 검사 · 빨간 칸 까닭 · 변수 줄
- 새 M02_MasterTemplete_Sangeun_SheetFormula.ts — 줄마다 [식 고침] → 이름 꼴 식 칸 · 칠 때마다 formula-check(옛 요청 끊기) · 틀리면 빨간 칸 + 까닭 · 바꿀 때 엔진 식을 식덮개로(같은 자리 상수 · 표값 · 품 덮개 걷음) · 빈 칸 · M01 식 = 풂 · 더한줄 수량식(율 · 로직 줄 뺌)
- 변수 줄 — [+ 변수 줄] · 이름 · 식 · 값 · 빼기(Formula_Model addVar · editVar · dropVar) · 자리 뒤에 그림
- Sheet — 식 덮개 줄은 품 칸 안 둠 · 숫자 칸 대신 식 칸 · [전체 M01 원래로]에 식 덮개 셈
- Detail — 시트 옵션에 층(scope)만(줄 수 그대로)
- test_790_1 — 식 칸 정적 단언

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 17:40:07 +09:00

288 lines
10 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.
/* =============================================================================
* M02_MasterTemplete_Sangeun_SheetFormula.ts
* 단가산출(SK) 시트 식 칸(PLAN 792 · E4 · _분석_650-H3 2 장 「식 고침」) — 시트(`_Sangeun_Sheet`)가 부름.
* 식 칸 = 이름 꼴 글(`[노임 보통인부] × 무게 / 운반량`) · 칠 때마다 검사 길(`formula-check` · 옛 요청 끊기)
* → 틀리면 빨간 칸 + 까닭 한 줄 · 되면 바꿀 때(Enter · 떠남) 엔진 식을 문서에(`setFormula`) → 서버 셈
* 변수 줄 = 이름 · 식 · 값 · [+ 변수 줄] · 빼기(`addVar` · `editVar` · `dropVar`)
* 빈 칸 · M01 식 그대로 = 식 덮개 풂 · 같은 자리 상수 · 표값 · 품 덮개는 식 덮개와 같이 못 둠 → 걷고 넣음
* 문서 고치기 = `logic/M02_Sangeun_Formula_Model`(792-1 짝) · 금액은 늘 서버 셈 · 자동저장 없음
* ========================================================================== */
import { createBadge } from "@ui/ui_template_badge";
import { createButton } from "@ui/ui_template_button";
import { createCalcSheetRow } from "@ui/ui_template_calc_row";
import { el } from "@ui/ui_template_elements";
import { createInputField } from "@ui/ui_template_input";
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createText } from "@ui/ui_template_text";
import { call, type Layer } from "./M02_MasterTemplete_Api_Fetch";
import type { EditCtx } from "./M02_MasterTemplete_Sangeun_SheetEdit";
import {
addVar,
dropVar,
editVar,
setFormula,
type FormulaDoc,
type VarLine,
} from "./logic/M02_Sangeun_Formula_Model";
import { num, type SpotInfo } from "./logic/M02_Sangeun_Sheet_Model";
const TEXT = {
Formula: ["식", "Formula"],
Edit: ["식 고침", "Edit formula"],
Close: ["식 닫기", "Close formula"],
Hint: ["예) 무게 / 운반량 × (1 + 할증율 / 100)", "e.g. weight / load × 1.2"],
Var: ["변수", "Variable"],
VarName: ["변수 이름", "Variable name"],
AddVar: ["+ 변수 줄", "+ Variable"],
DropVar: ["변수 빼기", "Remove variable"],
Bad: ["식이 틀려 안 넣음", "Invalid formula — not applied"],
Checking: ["검사 중", "Checking…"],
} as const satisfies Record<string, readonly [string, string]>;
const t = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0];
export interface CheckAnswer {
되나: boolean;
까닭: string[];
알림: string[];
/** 엔진 식(문서에 넣을 것) */
식: string;
/** 이름 꼴 글 */
글: string;
}
export interface FormulaCtx extends EditCtx {
scope: { layer: Layer; owner: string | null };
/** 식 칸 연 자리(화면 캐시) */
open: Set<string>;
/** 치는 중 글(다시 그려도 안 잃음) — 넣으면 지움 */
drafts: Map<string, string>;
redraw: () => void;
}
const q = (c: FormulaCtx): string =>
c.scope.owner && c.scope.layer !== "system" ? `?owner=${encodeURIComponent(c.scope.owner)}` : "";
/** 검사 길(792-1) — 셈 · 저장 없음 */
export const checkFormula = (
c: FormulaCtx,
spot: string,
text: string,
signal?: AbortSignal,
): Promise<CheckAnswer> =>
call<CheckAnswer>(`/layers/${c.scope.layer}/sangeun/formula-check${q(c)}`, {
method: "POST",
body: JSON.stringify({ 문서: c.doc, 자리: spot, 식: text }),
signal,
});
/** 식 덮개와 같이 못 두는 같은 자리 상수 · 표값 · 품 덮개 걷음(배율은 둠) */
export function clearNums<T extends FormulaDoc>(doc: T, spot: string): T {
const out: FormulaDoc = { ...doc };
for (const name of ["상수덮개", "표값덮개"] as const) {
const all = { ...(doc[name] ?? {}) };
delete all[spot];
if (Object.keys(all).length) out[name] = all;
else delete out[name];
}
const one = doc.덮개?.[spot];
if (one && "qty" in one) {
const rest = { ...one };
delete rest.qty;
const covers = { ...doc.덮개 };
if (Object.keys(rest).length) covers[spot] = rest;
else delete covers[spot];
out.덮개 = covers;
}
return out as T;
}
/** 식 칸 — 칠 때마다 검사(앞 요청 끊음) · 바꿀 때 넣음 · `put(엔진 식 | null)` */
function formulaBox(
c: FormulaCtx,
spot: string,
key: string,
shown: string,
origin: string,
put: (engine: string | null) => void,
): HTMLElement {
const box = createInputField({
value: c.drafts.get(key) ?? shown,
compact: true,
size: 24,
placeholder: t("Hint"),
});
box.input.setAttribute("aria-label", t("Formula"));
box.input.dataset.sheetCell = key;
box.input.spellcheck = false;
const note = createText("", "muted", 12);
note.dataset.sheetCheck = key;
let timer = 0;
let ctl: AbortController | null = null;
let last: Promise<CheckAnswer | null> | null = null;
const run = (text: string): Promise<CheckAnswer | null> => {
ctl?.abort();
const mine = (ctl = new AbortController());
note.textContent = t("Checking");
last = checkFormula(c, spot, text, mine.signal).then(
(got) => {
if (mine !== ctl) return got;
box.setError(got.되나 ? undefined : got.까닭.join(" · "));
note.textContent = got.알림.join(" · ");
return got;
},
(error: unknown) => {
if (mine !== ctl || mine.signal.aborted) return null;
box.setError(error instanceof Error ? error.message : String(error));
note.textContent = "";
return null;
},
);
return last;
};
box.input.addEventListener("input", () => {
c.drafts.set(key, box.input.value);
window.clearTimeout(timer);
const text = box.input.value.trim();
if (!text) return void (ctl?.abort(), box.setError(undefined), (note.textContent = ""));
timer = window.setTimeout(() => void run(text), 150);
});
box.input.addEventListener("change", async () => {
window.clearTimeout(timer);
const text = box.input.value.trim();
if (!text || text === origin) {
c.drafts.delete(key);
return put(null);
}
const got = await run(text);
if (!got?.되나) return void (note.textContent = t("Bad"));
c.drafts.delete(key);
put(got.식);
});
return el("div", {
className: "m02-sks__fx",
attrs: { "data-sheet-fx": spot },
children: [box.root, note],
});
}
/** 줄 식 칸 — 열었거나 식 덮개 있는 자리만 · 못 고치는 자리(로직 · 율 더한줄) = null */
export function rowFormula(
c: FormulaCtx,
spot: string,
info: SpotInfo | undefined,
): HTMLElement | null {
if (!c.edit || !spot || !info || (!c.open.has(spot) && !info.식덮개)) return null;
const shown = info.식글 ?? info.식 ?? "";
const origin = info.원식글 ?? "";
return formulaBox(c, spot, `${spot}|f`, shown, origin, (engine) => {
const doc = engine ? clearNums(c.doc as FormulaDoc, spot) : (c.doc as FormulaDoc);
c.apply(setFormula(doc, spot, engine));
});
}
/** 꼬리 [식 고침] · [식 닫기] — 식 덮개 있으면 늘 열림(닫기 없음) */
export function formulaToggle(
c: FormulaCtx,
spot: string,
info: SpotInfo | undefined,
): HTMLElement[] {
const addOne = /^add:#\d+$/.test(spot); // 더한줄 수량식(율 · 로직 줄은 부르는 쪽이 뺌)
if (!c.edit || !spot || !info || info.식덮개 || (info.식 == null && !addOne)) return [];
const on = c.open.has(spot);
const b = createButton({
label: on ? t("Close") : t("Edit"),
variant: "ghost",
size: 22,
onClick: () => {
if (on) c.open.delete(spot);
else c.open.add(spot);
c.drafts.delete(`${spot}|f`);
c.focus(on ? "" : `${spot}|f`);
c.redraw();
},
});
b.dataset.sheetFxOpen = spot;
return [b];
}
const vars = (c: FormulaCtx): VarLine[] => (c.doc as FormulaDoc).변수줄 ?? [];
/** 자리 뒤 변수 줄들(이어 붙은 변수까지) */
export function varRows(c: FormulaCtx, after: (v: VarLine) => boolean): HTMLElement[] {
return vars(c).flatMap((v) =>
after(v) ? [varRow(c, v), ...varRows(c, (x) => x.자리 === `mid:${v.이름}`)] : [],
);
}
function varRow(c: FormulaCtx, v: VarLine): HTMLElement {
const spot = `mid:${v.이름}`;
const info = c.spots?.자리[spot];
const label = c.edit
? (() => {
const box = createInputField({ value: v.이름, compact: true, size: 24 });
box.input.setAttribute("aria-label", t("VarName"));
box.input.dataset.sheetCell = `var|${v.이름}`;
box.input.addEventListener("change", () => {
const to = box.input.value.trim();
if (!to || to === v.이름 || vars(c).some((x) => x.이름 === to))
return void (box.input.value = v.이름);
c.focus(`var|${to}`);
c.apply(editVar(c.doc as FormulaDoc, v.이름, { 이름: to }));
});
return box.root;
})()
: el("span", { className: "ui-calc-row__label", text: v.이름 });
const middle = c.edit
? formulaBox(c, spot, `${spot}|f`, info?.식글 ?? v.식, "", (engine) =>
c.apply(editVar(c.doc as FormulaDoc, v.이름, { 식: engine ?? "0" })),
)
: el("span", { className: "ui-calc-formula", text: info?.식글 ?? v.식 });
const value = el("span", {
className: "ui-calc-row__out",
text: typeof info?.지금 === "number" ? num(info.지금) : "—",
});
const tail = el("span", {
children: [
createBadge(t("Var"), "accent"),
...(c.edit
? [
createButton({
label: t("DropVar"),
variant: "danger-ghost",
size: 22,
onClick: () => c.apply(dropVar(c.doc as FormulaDoc, v.이름)),
}),
]
: []),
],
});
const row = createCalcSheetRow(label, middle, value, tail);
row.dataset.sheetKind = "변수";
row.dataset.sheetSpot = spot;
row.classList.add("is-changed");
return row;
}
/** [+ 변수 줄] — 맨 위(자리 "") · 이름 = 안 겹치는 「변수n」 */
export function addVarButton(c: FormulaCtx): HTMLElement[] {
if (!c.edit) return [];
const b = createButton({
label: t("AddVar"),
variant: "ghost",
size: 22,
onClick: () => {
const used = new Set([
...vars(c).map((v) => v.이름),
...(c.spots?.이름표 ?? []).map((x) => x.글),
]);
let n = 1;
while (used.has(`변수${n}`)) n++;
c.focus(`var|변수${n}`);
c.apply(addVar(c.doc as FormulaDoc, { 이름: `변수${n}`, 식: "0", 자리: "" }, 0));
},
});
b.dataset.sheetAddVar = "";
return [b];
}