feat(796-2b): M02 단가산출 「수식」 첫 탭 — M01 수식 탭 부품 다시 씀(페이지 갈래) · 사본 [단가 갱신] · [재생성] · M01 새 판 배지 · 엑셀 원래

- M01 수식 탭 부품에 `page` 갈래 — 길 · 고침 목록 · 옵션 값을 페이지가 줌 · 저장 = 페이지 [저장] · M01 동작 그대로(판 바뀌면 다시 셈만 더함)
- 새 M02_MasterTemplete_Sangeun_Equation — 문서 `수식고침` 캐시 · 사본 표지 줄 · 가져온 식 「엑셀 원래」 · 일위대가 줄 모달 = 보기만
- 단가산출 상세 보기 = 수식(첫 · 기본) · 단가산출 상세 · 시트 · 옛 셋 뒤로(걷지 않음) · [저장] 뒤 할일 칸 뗌
- 낡은 시험 test_796_2 — 길 글이 앞머리 + 갈래로 바뀜(`/logic/equation${suffix}`) → 고침 · 화면 지킴 시험 하나 더함

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 12:20:39 +09:00
co-authored by Claude Opus 5.5
parent f02c39c98a
commit 0959ec3c98
5 changed files with 257 additions and 27 deletions
@@ -7,6 +7,7 @@
* · 변수를 누르면 변수 창(`_EquationVar`) · 고친 목록(`edits`)은 캐시(sessionStorage)에 쌓고 [저장] 한 번에 `/equation/save`(개정 확인)
* 계산 자리 = 서버 한 벌(③) — 화면은 고침 목록만 보내고 서버가 다시 셈한 나무를 다시 그림(옛 요청 끊음)
* 시트 · 시험 쪽에 저장 안 한 고침이 있으면 수식 고침은 잠김(두 갈래가 한 줄을 따로 고치지 않게)
* 페이지 갈래(`page` · 796-2b M02 단가산출 사본) = 같은 부품 · 길 · 고침 목록(문서 `수식고침`) · 옵션 값이 페이지 것 · 저장 = 페이지 [저장]
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
@@ -47,6 +48,8 @@ const TEXT = {
Discard: ["수식 고침 버림", "Discard edits"],
Add: ["변수 더함", "Add variable"],
Edits: ["수식 고침 {n} 건 · 저장 안 함", "{n} equation edits · unsaved"],
EditsPage: ["수식 고침 {n} 건 · 사본 위", "{n} equation edits · on copy"],
DiscardPage: ["수식 원래로", "Back to copy"],
Now: ["지금", "Now"],
Before: ["고치기 전", "Before"],
Blocked: [
@@ -104,6 +107,19 @@ const loadCache = (): Record<string, EqEdit[]> => {
}
};
/** 페이지 캐시 갈래(796-2b M02 단가산출 사본) — 길 · 고침 목록 · 옵션 값이 페이지 것 · 저장 = 페이지 [저장] */
export interface EqPage {
ask: <T>(
suffix: "" | "/preview",
body: Record<string, unknown>,
signal?: AbortSignal,
) => Promise<T>;
edits: () => EqEdit[];
keep: (edits: EqEdit[]) => void;
values: () => Record<string, string>;
setValue: (name: string, value: string | null) => void;
}
export interface LabEquationOptions {
/** 시스템 관리자 — 고침 도구 */
admin: boolean;
@@ -111,6 +127,8 @@ export interface LabEquationOptions {
blocked: () => boolean;
/** 저장 뒤 — 상세를 저장본으로 다시 엶 */
onSaved: () => void;
/** 없으면 M01(길 `/m01/logic/equation` · 캐시 sessionStorage · [수식 저장]) */
page?: EqPage;
}
export function createLabEquation(opt: LabEquationOptions) {
@@ -139,9 +157,14 @@ export function createLabEquation(opt: LabEquationOptions) {
const open = new Set<string>();
const drafts = new Map<string, string>();
const canEdit = (): boolean => opt.admin && !opt.blocked();
const page = opt.page;
const vals = (): Record<string, string> => page?.values() ?? inputs;
const ask = <T>(suffix: "" | "/preview", body: Record<string, unknown>, signal?: AbortSignal) =>
page ? page.ask<T>(suffix, body, signal) : post<T>(`/logic/equation${suffix}`, body, signal);
const body0 = (): Record<string, unknown> => ({ key, inputs: inputsOf(inputs), 고침: edits });
const body0 = (): Record<string, unknown> => ({ key, inputs: inputsOf(vals()), 고침: edits });
const persist = (): void => {
if (page) return page.keep(edits);
const all = loadCache();
if (edits.length) all[key] = edits;
else delete all[key];
@@ -157,9 +180,7 @@ export function createLabEquation(opt: LabEquationOptions) {
ctl?.abort();
const mine = (ctl = new AbortController());
try {
const got = edits.length
? await post<Read>("/logic/equation/preview", body0(), mine.signal)
: await post<Read>("/logic/equation", body0(), mine.signal);
const got = await ask<Read>(edits.length ? "/preview" : "", body0(), mine.signal);
if (mine !== ctl) return;
if (got.되나 === false || got.ok === false) {
why = (got.까닭 ?? [got.reason ?? ""]).join(" · ");
@@ -178,7 +199,7 @@ export function createLabEquation(opt: LabEquationOptions) {
/** 고침 하나를 더해 보고 되면 굳힘 — 안 되면 까닭 목록 */
async function commit(list: EqEdit[]): Promise<string[]> {
const got = await post<Read>("/logic/equation/preview", {
const got = await ask<Read>("/preview", {
...body0(),
고침: [...edits, ...list],
}).catch((error: unknown): Read => ({
@@ -214,6 +235,7 @@ export function createLabEquation(opt: LabEquationOptions) {
};
const label = createText(spec.이름 + (spec.단위 ? ` (${spec.단위})` : ""), "muted", 12);
const set = (v: string | number | null): void => {
if (page) return page.setValue(spec.이름, v === null || v === "" ? null : String(v));
if (v === null || v === "") delete inputs[spec.이름];
else inputs[spec.이름] = String(v);
askAgain();
@@ -222,7 +244,7 @@ export function createLabEquation(opt: LabEquationOptions) {
if ((spec.고르기?.length ?? 0) > BIG) {
// 찾기형 — 입력 + 목록(칠수록 좁혀짐)
const box = createInputField({
value: inputs[spec.이름] ?? "",
value: vals()[spec.이름] ?? "",
compact: true,
size: 24,
placeholder: auto === null || auto === undefined ? "" : String(auto),
@@ -239,7 +261,7 @@ export function createLabEquation(opt: LabEquationOptions) {
});
control = el("div", { children: [box.root, list] });
} else {
const made = createValueControl(item, inputs[spec.이름] ?? null, (v) => set(v));
const made = createValueControl(item, vals()[spec.이름] ?? null, (v) => set(v));
const tip = (): void => {
const c = made.control;
c.title = c instanceof HTMLSelectElement ? (c.selectedOptions[0]?.text ?? "") : c.value;
@@ -283,8 +305,8 @@ export function createLabEquation(opt: LabEquationOptions) {
const check =
(line: EqLine) =>
async (text: string, signal: AbortSignal): Promise<FormulaVerdict> => {
const got = await post<Read>(
"/logic/equation/preview",
const got = await ask<Read>(
"/preview",
{ ...body0(), 고침: [...edits, formulaEdit(line, text)] },
signal,
);
@@ -369,15 +391,17 @@ export function createLabEquation(opt: LabEquationOptions) {
if (!v || !canEdit()) return;
openVarDialog({
row: v,
value: inputs[v.열쇠] ?? "",
value: vals()[v.열쇠] ?? "",
commit,
onValue: (value) => {
if (page) return page.setValue(v.열쇠, value || null);
if (value) inputs[v.열쇠] = value;
else delete inputs[v.열쇠];
drawOptions();
void refresh();
},
onDropped: (k) => {
if (page) return page.setValue(k, null);
delete inputs[k];
drawOptions();
},
@@ -403,14 +427,16 @@ export function createLabEquation(opt: LabEquationOptions) {
items.push("spacer");
if (edits.length) {
items.push(
createBadge(t("Edits", edits.length), "warning"),
createBadge(t(page ? "EditsPage" : "Edits", edits.length), "warning"),
createButton({
label: t("Discard"),
label: t(page ? "DiscardPage" : "Discard"),
variant: "ghost",
size: 24,
onClick: () => void discard(),
}),
createButton({ label: t("Save"), size: 24, onClick: () => void save() }),
...(page
? []
: [createButton({ label: t("Save"), size: 24, onClick: () => void save() })]),
);
} else items.push(createText(t("Hint"), "muted", 12));
}
@@ -488,17 +514,21 @@ export function createLabEquation(opt: LabEquationOptions) {
root,
/** 상세가 그릴 때마다 — 로직이 바뀔 때만 서버를 부름 */
draw: (nextKey: string, nextVersion: string, nextRow: LogicRow): void => {
const moved = nextVersion !== version; // 판 · 페이지 문서 · 옵션 바뀜 = 다시 셈
version = nextVersion;
row = nextRow;
if (nextKey !== key) {
[key, eq, rowNow, before, why, selected] = [nextKey, null, null, null, "", null];
inputs = {};
edits = loadCache()[nextKey] ?? [];
edits = page ? page.edits() : (loadCache()[nextKey] ?? []);
open.clear();
drafts.clear();
paint();
drawOptions();
void refresh().then(drawOptions);
} else if (moved) {
if (page) edits = page.edits();
void refresh().then(drawOptions);
} else if (opt.blocked() !== paintedBlocked) paint();
},
dirty: (): boolean => edits.length > 0,