feat(794-1): M01 관리자 줄 식 시트 — 로직 상세 첫 보기 「시트」 · 식 칸 칠 때마다 검사 · 기존 [저장] 길

- 로직 상세 보기 첫 칸 「시트」(기본) — 이름 | 식 | 값 | 비목 · 근거 · 옵션(시험 값) · 변수 · 계산 · 자원 · 율 · 덧줄 · 단계 · 끝수 · 소계 · 합계 · 상태줄 지금 · 고치기 전
- 식 글 = 이름 꼴(M02 단가산출 시트와 같은 번역 · 새 서버 M01_MasterData_Sheet.py · POST /api/m01/logic/sheet · /logic/sheet/check · 저장 때와 같은 엔진 검사 · 쓰기 없음)
- 식 고침 = 시스템 관리자만 · 되는 식만 줄 사본에 · 캐시 → 기존 [저장](개정 확인 · 서버 검사 422) · [원래로]
- 식 칸 몸 = 공용 A00_Common/sheet_formula_cell.ts — M02 단가산출 시트도 같은 것을 씀(동작 그대로)
- 자체 로직(GX) 편집기 식 날 입력칸 → 읽기만(시트 식 칸으로 갈음)
- 시험: test_794_1_m01_sheet 새로 · test_790_1 식 칸 문구 자리를 공용 파일로 옮김(식 칸 몸이 공용으로 감) · test_717_2b · test_722 LogicLab.ts 줄 상한 627 → 637(시트 이음 10 줄 · 셈 · 고침은 시트 파일로 뺌 · 700 아래)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 18:45:48 +09:00
co-authored by Claude Opus 5.5
parent b989fe1724
commit 2e7328d3a5
17 changed files with 970 additions and 79 deletions
@@ -0,0 +1,324 @@
/* =============================================================================
* M01_MasterData_UI_LogicLab_Sheet.ts
* M01 단가산출 로직 상세 「시트」 보기(PLAN 794-1 · 요구 650~652 · 요구서_652 1 장 6) — M02 단가산출 시트와 같은 꼴.
* 한 줄 「이름 | 식 | 값 | 비목」(줄 꼴 = `logic/M01_Logic_Sheet_Model`) · 상태줄 = 지금 · 고치기 전 + [계산]
* 식 글 = 서버 이름 꼴(`POST /api/m01/logic/sheet` · 줄이 바뀔 때만 · 옛 요청 끊음)
* 고침 = 시스템 관리자만 — [식 고침] → 식 칸(공용 `sheet_formula_cell` · 칠 때마다 `/logic/sheet/check`)
* → 되면 엔진 식을 줄 사본에(`onEdit`) → 상세 캐시(drafts) → 기존 M01 [저장](서버 검사 · 개정 확인)
* 옵션 칸 = 시험 값(저장 안 함 · 고치면 시험 계산) · 자동저장 없음 · 부품 = calc_row · 입력 · 고르기 · 단추 · 배지
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { createBadge } from "@ui/ui_template_badge";
import { createButton } from "@ui/ui_template_button";
import { createCalcFormula, createCalcSheetRow } from "@ui/ui_template_calc_row";
import { el } from "@ui/ui_template_elements";
import { createInputField, createSelectField } from "@ui/ui_template_input";
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createText } from "@ui/ui_template_text";
import { createToolbar } from "@ui/ui_template_toolbar";
import { createFormulaCell, type FormulaVerdict } from "../A00_Common/sheet_formula_cell";
import { formatLineMoney, formatMoney } from "../common_util/common_util_money";
import type { CalcAnswer, LogicRow } from "./M01_MasterData_UI_Logic_Api";
import {
formulaAt,
labItems,
setLabFormula,
spotKey,
type LabItem,
type LabKind,
type LabNames,
type LabPart,
} from "./logic/M01_Logic_Sheet_Model";
import "../M02_MasterTemplete/M02_MasterTemplete_Sangeun_Sheet.css";
const TEXT = {
Sheet: ["시트", "Sheet"],
Now: ["지금", "Now"],
Before: ["고치기 전", "Before edit"],
Run: ["계산", "Calculate"],
근거: ["근거", "Basis"],
옵션: ["옵션 · 시험 값", "Options · trial values"],
계산: ["변수 · 계산", "Variables"],
자원: ["자원", "Resources"],
율: ["율 · 덧줄", "Rates"],
단계: ["단계", "Stages"],
소계: ["소계 · 합계", "Subtotals"],
Edit: ["식 고침", "Edit formula"],
Close: ["식 닫기", "Close formula"],
Back: ["원래로", "Revert"],
Wait: ["[계산] 을 누르면 값이 채워짐", "Press Calculate to fill values"],
Flow: ["흐름 로직 — 시트로 못 봄", "Flow logic — no sheet"],
Address: ["번지", "Address"],
} as const satisfies Record<string, readonly [string, string]>;
const t = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0];
const SECTION: Record<LabKind, keyof typeof TEXT> = {
근거: "근거",
옵션: "옵션",
계산: "계산",
자원: "자원",
율: "율",
단계: "단계",
소계: "소계",
합계: "소계",
};
const ORDER: (keyof typeof TEXT)[] = ["근거", "옵션", "계산", "자원", "율", "단계", "소계"];
const NUM = new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 4 });
export interface LabSheetDraw {
row: LogicRow;
/** 저장본 줄(새 로직 = null) */
original: LogicRow | null;
values: Record<string, string>;
answer: CalcAnswer | null;
}
export interface LabSheetOptions {
/** 시스템 관리자 — 식 칸 · [식 고침] · [원래로] */
admin: boolean;
onInput: (name: string, value: string) => void;
/** 줄 식 칸을 고친 뒤(줄 객체는 여기서 고침) — 상세가 캐시(touch) · 다시 그림 */
edited: () => void;
/** 시험 계산 [계산] 누름 */
run: () => void;
}
async function post<T>(path: string, body: unknown, signal?: AbortSignal): Promise<T> {
const r = await fetch(`${API_BASE_URL}/m01${path}`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal,
});
const data = (await r.json().catch(() => ({}))) as T & { detail?: unknown };
if (!r.ok) throw new Error(typeof data.detail === "string" ? data.detail : r.statusText);
return data;
}
export function createLabSheet(opt: LabSheetOptions) {
const status = el("div", { className: "m02-sks__status", attrs: { "data-lab-status": "" } });
const body = el("div", { className: "m02-sks__body" });
const run = createButton({ label: t("Run"), variant: "ghost", size: 28, onClick: opt.run });
const root = el("div", {
className: "m02-sks",
attrs: { "data-lab-sheet": "" },
children: [createToolbar({ items: [run, "divider", status], label: t("Sheet") }), body],
});
let last: LabSheetDraw | null = null;
let names: LabNames | null = null;
let asked = "";
let ctl: AbortController | null = null;
let before: number | null = null;
let beforeAt = ""; // 고치기 전 값을 잰 시험 값(옵션) — 옵션이 바뀌면 고친 줄의 「고치기 전」 은 모름
const open = new Set<string>();
const drafts = new Map<string, string>();
/** 줄이 바뀌면 이름 꼴 식 글을 다시 받음(옛 요청 끊음) */
const askNames = (row: LogicRow): void => {
const text = JSON.stringify(row);
if (text === asked) return;
asked = text;
ctl?.abort();
const mine = (ctl = new AbortController());
post<LabNames>("/logic/sheet", { row }, mine.signal).then(
(got) => {
if (mine !== ctl) return;
names = got;
if (last) paint(last);
},
() => undefined, // 못 받으면 엔진 식 그대로 보임
);
};
const check =
(row: LogicRow, part: LabPart, at: number) =>
(text: string, signal: AbortSignal): Promise<FormulaVerdict> =>
post<FormulaVerdict>("/logic/sheet/check", { row, 갈래: part, 차례: at, 식: text }, signal);
const money = (it: LabItem): string => {
const v = it.값;
if (typeof v !== "number") return it.kind === "옵션" ? (it.input?.단위 ?? "") : String(v ?? "");
if (it.kind === "자원" || it.kind === "율") return formatLineMoney(v);
return it.kind === "소계" || it.kind === "합계" ? formatMoney(v) : NUM.format(v);
};
function optionCell(d: LabSheetDraw, it: LabItem): HTMLElement {
const spec = it.input!;
const value = d.values[spec.이름] ?? "";
if (spec.고르기?.length) {
const sel = createSelectField({
options: ["", ...spec.고르기.map(String)].map((o) => ({ value: o, text: o })),
value,
compact: true,
size: 24,
ariaLabel: spec.이름,
onChange: (v) => opt.onInput(spec.이름, v),
});
sel.select.dataset.sheetCell = `opt|${spec.이름}`;
return sel.root;
}
const box = createInputField({ value, compact: true, size: 24 });
box.input.setAttribute("aria-label", spec.이름);
box.input.dataset.sheetCell = `opt|${spec.이름}`;
box.input.addEventListener("change", () => opt.onInput(spec.이름, box.input.value.trim()));
return box.root;
}
function tail(d: LabSheetDraw, it: LabItem): HTMLElement {
const kids: HTMLElement[] = it.비목 ? [createBadge(it.비목, "neutral")] : [];
if (it.번지) kids.push(createBadge(`${t("Address")} ${it.번지}`, "info"));
if (opt.admin && it.part && it.at !== undefined) {
const key = spotKey(it.part, it.at);
const on = open.has(key);
const toggle = createButton({
label: on ? t("Close") : t("Edit"),
variant: "ghost",
size: 22,
onClick: () => {
if (on) open.delete(key);
else open.add(key);
drafts.delete(key);
paint(d);
},
});
toggle.dataset.sheetFxToggle = key;
kids.push(toggle);
if (it.changed && d.original) {
const { part, at } = it;
const back = createButton({
label: t("Back"),
variant: "ghost",
size: 22,
onClick: () => edit(d, part, at, null),
});
back.dataset.sheetRevert = key;
kids.push(back);
}
}
return el("span", { children: kids });
}
function rowOf(d: LabSheetDraw, it: LabItem): HTMLElement {
const key = it.part && it.at !== undefined ? spotKey(it.part, it.at) : "";
const editing = opt.admin && key && open.has(key) && it.part && it.at !== undefined;
const middle =
it.kind === "옵션"
? optionCell(d, it)
: el("div", {
className: "m02-sks__mid",
children: [
createCalcFormula(it.식 || (["근거", "소계", "합계"].includes(it.kind) ? "" : "—")),
...(editing
? [
createFormulaCell({
key,
spot: key,
shown: it.식,
origin: it.식,
drafts,
check: check(d.row, it.part!, it.at!),
put: (engine) => {
if (engine === null) return;
open.delete(key);
edit(d, it.part!, it.at!, engine);
},
}),
]
: []),
],
});
const value = el("span", {
className: `ui-calc-row__out${it.kind === "소계" || it.kind === "합계" ? " is-key" : ""}`,
text: money(it),
});
const row = createCalcSheetRow(it.이름, middle, value, tail(d, it));
row.dataset.sheetKind = it.kind;
if (key) row.dataset.sheetSpot = key;
if (it.changed) row.classList.add("is-changed");
return row;
}
function drawStatus(d: LabSheetDraw): void {
const ok = d.answer?.ok ? d.answer : null;
const now = ok?.sums?.계 ?? null;
const at = JSON.stringify(d.values);
const same = !d.original || JSON.stringify(d.row) === JSON.stringify(d.original);
if (same && now !== null)
[before, beforeAt] = [now, at]; // 저장본 그대로 = 지금이 고치기 전
else if (!same && at !== beforeAt) before = null;
const part = (k: string, label: string, v: number | null): HTMLElement =>
el("span", {
className: "m02-sks__amount",
attrs: { "data-lab-amount": k },
children: [createText(`${label} ${v == null ? "—" : formatMoney(v)}`, "muted", 14)],
});
const diff = now !== null && before !== null && now !== before ? now - before : 0;
status.replaceChildren(
part("now", t("Now"), now),
part("before", t("Before"), before),
...(diff
? [createBadge(`${diff > 0 ? "+" : "−"}${NUM.format(Math.abs(diff))}`, "warning")]
: []),
);
}
function paint(d: LabSheetDraw): void {
last = d;
drawStatus(d);
if (d.row.흐름) return void body.replaceChildren(createText(t("Flow"), "muted"));
const items = labItems({ ...d, names });
const active = document.activeElement as HTMLElement | null;
const keep = active && body.contains(active) ? active.dataset.sheetCell : "";
const typed = active instanceof HTMLInputElement && keep ? active.value : null; // 치던 글 지킴
const out: HTMLElement[] = [];
for (const sec of ORDER) {
const mine = items.filter((it) => SECTION[it.kind] === sec);
if (!mine.length) continue;
out.push(
el("div", {
className: "m02-sks__head",
attrs: { "data-sheet-section": sec },
children: [createText(t(sec), "muted", 14)],
}),
...mine.map((it) => rowOf(d, it)),
);
}
if (!d.answer) out.push(createText(t("Wait"), "muted"));
body.replaceChildren(...out);
const back = keep
? body.querySelector<HTMLElement>(`[data-sheet-cell="${CSS.escape(keep)}"]`)
: null;
if (back instanceof HTMLInputElement && typed !== null) back.value = typed;
back?.focus();
}
/** 식 칸 고침(엔진 식) · null = 저장본 식 → 상세 캐시 · 시험 계산 다시 */
function edit(d: LabSheetDraw, part: LabPart, at: number, engine: string | null): void {
const text = engine ?? (d.original ? formulaAt(d.original, part, at) : null);
if (text === null || !setLabFormula(d.row, part, at, text)) return;
opt.edited();
opt.run();
}
let answer: CalcAnswer | null = null;
let answerOf: Record<string, string> | null = null; // 답을 받은 로직(시험 값 그릇 = 연 로직마다 하나)
let base: { src: string | null; row: LogicRow | null } = { src: null, row: null };
return {
root,
/** 시험 계산 답 — 상세 `buildCalc` onAnswer */
answer: (a: CalcAnswer) => void (answer = a),
/** 상세가 그릴 때마다 — 줄 · 저장본 글(`original`) · 시험 값 그릇 */
draw: (row: LogicRow, original: string | null, values: Record<string, string>) => {
if (answerOf !== values) [answer, answerOf] = [null, values]; // 다른 로직 · 저장 뒤
if (base.src !== original) {
base = { src: original, row: original ? (JSON.parse(original) as LogicRow) : null };
open.clear(); // 연 식 칸 · 치던 글 버림
drafts.clear();
}
askNames(row);
paint({ row, original: base.row, values, answer });
},
};
}