- 새 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>
288 lines
10 KiB
TypeScript
288 lines
10 KiB
TypeScript
/* =============================================================================
|
||
* 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];
|
||
}
|