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:
@@ -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 });
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user