- 로직 상세 보기 첫 칸 「시트」(기본) — 이름 | 식 | 값 | 비목 · 근거 · 옵션(시험 값) · 변수 · 계산 · 자원 · 율 · 덧줄 · 단계 · 끝수 · 소계 · 합계 · 상태줄 지금 · 고치기 전 - 식 글 = 이름 꼴(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>
325 lines
13 KiB
TypeScript
325 lines
13 KiB
TypeScript
/* =============================================================================
|
||
* 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 });
|
||
},
|
||
};
|
||
}
|