feat(800-2): 수식 영역 / 변수 정의 영역 화면 · 식 바로 고침 · 새 기호 연결 지정(800-3)
- 수식 영역 줄 번호 · 구분선 · 줄마다 풀이 걷음 · 오른쪽 변수 정의 영역 부품 ui_template_equation_defs - 식 칸을 눌러 그 자리 고침(줄 선택 · 아이콘 줄 걷음) · 틀린 식 그 자리 빨강 + 까닭 · 미리 셈 - 새 기호 = 정의 영역 맨 위 줄에서 연결(입력 · 표 · 요소 · 로직 · 상수) · 이름 · 기호 · 첨자 고침 · 지움(쓰는 변수 막음) - 낡은 시험 test_796_2_equation_screen 두 건: 아이콘 줄 · 줄 선택 고침 → 바로 고침 꼴로 바꿈(동작 걷음) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,8 +3,10 @@
|
||||
* M01 단가산출 로직 상세 「수식」 탭(PLAN 796-2 · 요구 655 · 656 · 657) — PPT 수식 꼴(결과명 = 이름 식 = 값 식 = 결과 + 변수 풀이).
|
||||
* 그림 = 공통 수식 부품(`ui_template_equation*.ts`) · 서버 나무 = `POST /api/m01/logic/equation`(796-1)
|
||||
* 위 = 옵션 가로(도구줄 + 값 칸 · 바꾸면 다시 셈) · 합계 · 고침 도구(시스템 관리자만)
|
||||
* 고침 = 줄을 고르면 그 줄만 식 칸(공용 `sheet_formula_cell` · 칠 때마다 `/equation/preview` 검사) + 숨은 아이콘 줄(분수 · 괄호 · 첨자 · %)
|
||||
* · 변수를 누르면 변수 창(`_EquationVar`) · 고친 목록(`edits`)은 캐시(sessionStorage)에 쌓고 [저장] 한 번에 `/equation/save`(개정 확인)
|
||||
* 배치(800-2) = 왼쪽 수식 영역(줄 구분 없이 식이 이어짐 + 소계 표) · 오른쪽 변수 정의 영역(고른 옵션 갈래의 변수만 · 값 · 단위 · DB 열쇠 없음)
|
||||
* 고침(800-3) = 수식 영역의 식 칸을 눌러 그 자리에서(공용 `sheet_formula_cell` · 칠 때마다 `/equation/preview` 검사 · 틀리면 그 자리 빨강 + 까닭)
|
||||
* · 새 기호를 쓰면 정의 영역에 그 기호 줄이 생겨 연결 지정(`_EquationDefs` · 고침 `연결`) · 이름 · 기호 · 첨자 고침 · 지움도 정의 줄에서
|
||||
* · 고친 목록(`edits`)은 캐시(sessionStorage)에 쌓고 [저장] 한 번에 `/equation/save`(개정 확인)
|
||||
* 계산 자리 = 서버 한 벌(③) — 화면은 고침 목록만 보내고 서버가 다시 셈한 나무를 다시 그림(옛 요청 끊음)
|
||||
* 시트 · 시험 쪽에 저장 안 한 고침이 있으면 수식 고침은 잠김(두 갈래가 한 줄을 따로 고치지 않게)
|
||||
* 페이지 갈래(`page` · 796-2b M02 단가산출 사본) = 같은 부품 · 길 · 고침 목록(문서 `수식고침`) · 옵션 값이 페이지 것 · 저장 = 페이지 [저장]
|
||||
@@ -13,16 +15,18 @@
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { createBanner } from "@ui/ui_template_banner";
|
||||
import { createBadge } from "@ui/ui_template_badge";
|
||||
import { createButton, createIconButton } from "@ui/ui_template_button";
|
||||
import { createButton } from "@ui/ui_template_button";
|
||||
import { createEqChain } from "@ui/ui_template_equation_line";
|
||||
import { createEqDefs, defSign, flashEqDef } from "@ui/ui_template_equation_defs";
|
||||
import { createEqSum } from "@ui/ui_template_equation_sum";
|
||||
import {
|
||||
eqText,
|
||||
type EqLine,
|
||||
type EqSumHooks,
|
||||
type EqNode,
|
||||
type EqTree,
|
||||
type EqVarRow,
|
||||
} from "@ui/ui_template_equation";
|
||||
import { lineId } from "@ui/ui_template_equation_line";
|
||||
import {
|
||||
el,
|
||||
hideLoadingOverlay,
|
||||
@@ -38,15 +42,15 @@ import { createFormulaCell, type FormulaVerdict } from "../A00_Common/sheet_form
|
||||
import { createValueControl } from "../M02_MasterTemplete/M02_Labor_Values";
|
||||
import type { ItemInput } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch";
|
||||
import { ApiError, type LogicRow } from "./M01_MasterData_UI_Logic_Api";
|
||||
import { openAddDialog, openVarDialog } from "./M01_MasterData_UI_LogicLab_EquationVar";
|
||||
import { saveRevised } from "./M01_MasterData_UI_LogicLab_Revise";
|
||||
import {
|
||||
formulaEdit,
|
||||
inputsOf,
|
||||
insertInto,
|
||||
type EqEdit,
|
||||
type InsertKind,
|
||||
} from "./logic/M01_Logic_Equation_Model";
|
||||
draftRow,
|
||||
renameEditor,
|
||||
type Draft,
|
||||
type DefsHost,
|
||||
} from "./M01_MasterData_UI_LogicLab_EquationDefs";
|
||||
import { openAddDialog } from "./M01_MasterData_UI_LogicLab_EquationVar";
|
||||
import { saveRevised } from "./M01_MasterData_UI_LogicLab_Revise";
|
||||
import { formulaEdit, inputsOf, unknownOf, type EqEdit } from "./logic/M01_Logic_Equation_Model";
|
||||
import "./M01_MasterData_UI_LogicLab_Equation.css";
|
||||
|
||||
const TEXT = {
|
||||
@@ -65,13 +69,11 @@ const TEXT = {
|
||||
],
|
||||
Options: ["옵션", "Options"],
|
||||
Wait: ["수식을 불러오는 중", "Loading equation"],
|
||||
Close: ["식 닫기", "Close formula"],
|
||||
Hint: ["줄을 눌러 고침 · 키보드 + − × ÷ / ( )", "Click a line to edit · keyboard + − × ÷ / ( )"],
|
||||
Frac: ["분수 (위) / (아래)", "Fraction"],
|
||||
Paren: ["괄호", "Parentheses"],
|
||||
Sub: ["아래첨자 _", "Subscript"],
|
||||
Sup: ["위첨자 ^", "Superscript"],
|
||||
Percent: ["% (/ 100)", "Percent"],
|
||||
Hint: [
|
||||
"식을 눌러 바로 고침 · 키보드 + − × ÷ / ( )",
|
||||
"Click a formula to edit · keyboard + − × ÷ / ( )",
|
||||
],
|
||||
Apply: ["연결 적용 · 식 넣기", "Apply links and formula"],
|
||||
Leave: ["저장 안 한 수식 고침 {n} 건 — 버리고 나감?", "Discard {n} unsaved equation edits?"],
|
||||
Leave_Ok: ["버림", "Discard"],
|
||||
Saved: ["수식을 저장함", "Equation saved"],
|
||||
@@ -117,7 +119,7 @@ const loadCache = (): Record<string, EqEdit[]> => {
|
||||
/** 페이지 캐시 갈래(796-2b M02 단가산출 사본) — 길 · 고침 목록 · 옵션 값이 페이지 것 · 저장 = 페이지 [저장] */
|
||||
export interface EqPage {
|
||||
ask: <T>(
|
||||
suffix: "" | "/preview",
|
||||
suffix: "" | "/preview" | "/links",
|
||||
body: Record<string, unknown>,
|
||||
signal?: AbortSignal,
|
||||
) => Promise<T>;
|
||||
@@ -140,6 +142,15 @@ export interface LabEquationOptions {
|
||||
page?: EqPage;
|
||||
}
|
||||
|
||||
/** 식에 처음 쓴 새 기호 — 연결을 고르면 고침 `연결` + 그 식 고침을 한 벌로 보냄 */
|
||||
interface PendingLink {
|
||||
line: EqLine;
|
||||
text: string;
|
||||
symbols: string[];
|
||||
rows: Map<string, Draft>;
|
||||
apply: HTMLElement;
|
||||
}
|
||||
|
||||
export function createLabEquation(opt: LabEquationOptions) {
|
||||
const bar = el("div", { className: "m01-eq__bar" });
|
||||
const optionsBar = el("div", { className: "m01-eq__options", attrs: { "data-eq-options": "" } });
|
||||
@@ -159,7 +170,10 @@ export function createLabEquation(opt: LabEquationOptions) {
|
||||
let inputs: Record<string, string> = {};
|
||||
let before: Record<string, unknown> | null = null;
|
||||
let why = "";
|
||||
let selected: string | null = null;
|
||||
let editingLine: string | null = null;
|
||||
let defEditing: string | null = null;
|
||||
let pending: PendingLink | null = null;
|
||||
let defsBox: HTMLElement = el("div");
|
||||
let ctl: AbortController | null = null;
|
||||
let timer = 0;
|
||||
let paintedBlocked = false;
|
||||
@@ -168,7 +182,11 @@ export function createLabEquation(opt: LabEquationOptions) {
|
||||
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) =>
|
||||
const ask = <T>(
|
||||
suffix: "" | "/preview" | "/links",
|
||||
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(vals()), 고침: edits });
|
||||
@@ -302,14 +320,97 @@ export function createLabEquation(opt: LabEquationOptions) {
|
||||
);
|
||||
}
|
||||
|
||||
// ── 고침 도구 ──────────────────────────────────────────────────────
|
||||
const ICONS: [InsertKind, string, "Frac" | "Paren" | "Sub" | "Sup" | "Percent"][] = [
|
||||
["frac", "a⁄b", "Frac"],
|
||||
["paren", "( )", "Paren"],
|
||||
["sub", "x₂", "Sub"],
|
||||
["sup", "x²", "Sup"],
|
||||
["percent", "%", "Percent"],
|
||||
];
|
||||
// ── 바로 고침 · 연결 지정 ──────────────────────────────────────────
|
||||
const total = (sums: Record<string, unknown>): string => ("계" in sums ? "계" : "결과");
|
||||
|
||||
const defsHost: DefsHost = {
|
||||
commit,
|
||||
links: (body) => ask("/links", body),
|
||||
onDropped: (k) => {
|
||||
if (page) page.setValue(k, null);
|
||||
else {
|
||||
delete inputs[k];
|
||||
drawOptions();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
function buildDefs(): HTMLElement {
|
||||
const edit = canEdit();
|
||||
const mids = (eq?.변수표 ?? []).filter((v) => v.갈래 === "중간").map((v) => v.열쇠);
|
||||
return createEqDefs(eq?.정의 ?? [], {
|
||||
editing: defEditing,
|
||||
onEdit: edit
|
||||
? (d) => {
|
||||
defEditing = defEditing === defSign(d) ? null : defSign(d);
|
||||
drawDefs();
|
||||
}
|
||||
: undefined,
|
||||
editor: (d) =>
|
||||
renameEditor(d, defsHost, () => {
|
||||
defEditing = null;
|
||||
drawDefs();
|
||||
}),
|
||||
drafts: pending ? [...[...pending.rows.values()].map((d) => d.root), pending.apply] : [],
|
||||
head: edit
|
||||
? [
|
||||
createButton({
|
||||
label: t("Add"),
|
||||
variant: "ghost",
|
||||
size: 24,
|
||||
iconStart: "plus",
|
||||
onClick: () => openAddDialog({ mids, commit }),
|
||||
}),
|
||||
]
|
||||
: [],
|
||||
});
|
||||
}
|
||||
|
||||
function drawDefs(): void {
|
||||
const next = buildDefs();
|
||||
defsBox.replaceWith(next);
|
||||
defsBox = next;
|
||||
}
|
||||
|
||||
/** 식 검사가 「모르는 기호」 를 알려 오면 정의 영역에 그 기호 줄을 세움(같은 기호들이면 입력한 것을 지키며 글만 갱신) */
|
||||
function setPending(line: EqLine, text: string, miss: string[]): void {
|
||||
if (!miss.length) {
|
||||
if (!pending) return;
|
||||
pending = null;
|
||||
return drawDefs();
|
||||
}
|
||||
if (pending && pending.symbols.join("\u0000") === miss.join("\u0000")) {
|
||||
[pending.line, pending.text] = [line, text];
|
||||
return;
|
||||
}
|
||||
const rows = new Map(miss.map((m) => [m, draftRow(m, defsHost)] as const));
|
||||
const apply = el("div", {
|
||||
className: "m01-eq__def-actions",
|
||||
children: [createButton({ label: t("Apply"), size: 24, onClick: () => void applyPending() })],
|
||||
});
|
||||
pending = { line, text, symbols: miss, rows, apply };
|
||||
drawDefs();
|
||||
defsBox.scrollTo({ top: 0 });
|
||||
}
|
||||
|
||||
async function applyPending(): Promise<void> {
|
||||
const p = pending;
|
||||
if (!p) return;
|
||||
const list: EqEdit[] = [];
|
||||
let missing = false;
|
||||
for (const d of p.rows.values()) {
|
||||
const made = d.read();
|
||||
d.say(typeof made === "string" ? made : "");
|
||||
if (typeof made === "string") missing = true;
|
||||
else list.push(made);
|
||||
}
|
||||
if (missing) return;
|
||||
const bad = await commit([...list, formulaEdit(p.line, p.text)]);
|
||||
if (bad.length) return [...p.rows.values()].forEach((d) => d.say(bad.join(" · ")));
|
||||
drafts.delete(lineId(p.line));
|
||||
[pending, editingLine] = [null, null];
|
||||
paint();
|
||||
}
|
||||
|
||||
const check =
|
||||
(line: EqLine) =>
|
||||
@@ -319,19 +420,26 @@ export function createLabEquation(opt: LabEquationOptions) {
|
||||
{ ...body0(), 고침: [...edits, formulaEdit(line, text)] },
|
||||
signal,
|
||||
);
|
||||
const miss = got.되나 ? [] : unknownOf(got.까닭 ?? []);
|
||||
setPending(line, text, miss);
|
||||
const now = got.후 ? eqText(got.후[total(got.후)]) : "";
|
||||
return {
|
||||
되나: !!got.되나,
|
||||
까닭: got.까닭 ?? [],
|
||||
까닭: miss.length
|
||||
? [`모르는 기호 ${miss.join(" · ")} — 오른쪽 변수 정의에서 연결을 지정`]
|
||||
: (got.까닭 ?? []),
|
||||
알림: got.되나 && now ? [`${t("Total")} ${now}`] : [],
|
||||
식: got.되나 ? text : null,
|
||||
};
|
||||
};
|
||||
|
||||
const total = (sums: Record<string, unknown>): string => ("계" in sums ? "계" : "결과");
|
||||
|
||||
/** 수식 영역의 식 칸 자리에 들어가는 식 칸 — 바꾸면 미리 셈 · 되면 고침 목록에 굳힘 */
|
||||
function editor(line: EqLine, id: string): HTMLElement {
|
||||
const shown = line.입력글 ?? line.식;
|
||||
const close = (): void => {
|
||||
[editingLine, pending] = [null, null];
|
||||
paint();
|
||||
};
|
||||
const cell = createFormulaCell({
|
||||
key: id,
|
||||
spot: id,
|
||||
@@ -340,82 +448,28 @@ export function createLabEquation(opt: LabEquationOptions) {
|
||||
drafts,
|
||||
check: check(line),
|
||||
put: (text) => {
|
||||
if (text === null) return;
|
||||
if (text === null) return pending ? undefined : close();
|
||||
void commit([formulaEdit(line, text)]).then((bad) => {
|
||||
if (bad.length) showToast(bad.join(" · "), "error");
|
||||
else close();
|
||||
});
|
||||
},
|
||||
className: "m01-eq__fx",
|
||||
});
|
||||
const input = (): HTMLInputElement | null => cell.querySelector("input");
|
||||
const icons = ICONS.map(([kind, glyph, label]) =>
|
||||
createIconButton({
|
||||
glyph,
|
||||
title: t(label),
|
||||
size: 24,
|
||||
onClick: () => {
|
||||
const box = input();
|
||||
if (!box) return;
|
||||
const from = box.selectionStart ?? box.value.length;
|
||||
const got = insertInto(kind, box.value, from, box.selectionEnd ?? from);
|
||||
box.value = got.text;
|
||||
box.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
box.focus();
|
||||
box.setSelectionRange(got.caret, got.caret);
|
||||
},
|
||||
}),
|
||||
const input = cell.querySelector("input");
|
||||
// 안 바꾸고 다른 데를 누르면 닫음 · 바꾼 글이 있거나 연결 지정 중이면 그대로
|
||||
cell.addEventListener("focusout", () =>
|
||||
window.setTimeout(() => {
|
||||
if (editingLine === id && !pending && !cell.contains(document.activeElement)) {
|
||||
if ((input?.value.trim() ?? shown) === shown) close();
|
||||
}
|
||||
}, 120),
|
||||
);
|
||||
const add = createButton({
|
||||
label: t("Add"),
|
||||
variant: "ghost",
|
||||
size: 24,
|
||||
iconStart: "plus",
|
||||
onClick: () =>
|
||||
openAddDialog({
|
||||
mids: (eq?.변수표 ?? []).filter((v) => v.갈래 === "중간").map((v) => v.열쇠),
|
||||
commit,
|
||||
}),
|
||||
});
|
||||
const close = createButton({
|
||||
label: t("Close"),
|
||||
variant: "ghost",
|
||||
size: 24,
|
||||
onClick: () => ((selected = null), paint()),
|
||||
});
|
||||
// 아이콘 줄 — 고른 줄에만(평소 숨김)
|
||||
const tools = createToolbar({
|
||||
items: [...icons, "divider", add, "spacer", close],
|
||||
size: 24,
|
||||
label: t("Hint"),
|
||||
});
|
||||
tools.dataset.eqTools = id;
|
||||
const wrap = el("div", { className: "m01-eq__edit", children: [cell, tools] });
|
||||
wrap.dataset.eqEditor = id;
|
||||
queueMicrotask(() => input()?.focus());
|
||||
return wrap;
|
||||
queueMicrotask(() => input?.focus());
|
||||
return cell;
|
||||
}
|
||||
|
||||
function varDialog(node: { 열쇠?: string }): void {
|
||||
const v = (eq?.변수표 ?? []).find((x: EqVarRow) => x.열쇠 === node.열쇠);
|
||||
if (!v || !canEdit()) return;
|
||||
openVarDialog({
|
||||
row: 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();
|
||||
},
|
||||
});
|
||||
}
|
||||
const showDef = (node: EqNode): void => flashEqDef(defsBox, node);
|
||||
|
||||
// ── 그림 ──────────────────────────────────────────────────────────
|
||||
function drawBar(): void {
|
||||
@@ -467,23 +521,28 @@ export function createLabEquation(opt: LabEquationOptions) {
|
||||
}
|
||||
const edit = canEdit();
|
||||
const sum = createEqSum(eq, open, page?.sum);
|
||||
body.replaceChildren(
|
||||
createEqChain(eq.줄, {
|
||||
path: "",
|
||||
open,
|
||||
selected,
|
||||
resultUnit: eq.결과단위,
|
||||
onSelect: edit
|
||||
? (_line, id) => {
|
||||
selected = selected === id ? null : id;
|
||||
paint();
|
||||
}
|
||||
: undefined,
|
||||
onVar: edit ? varDialog : undefined,
|
||||
slot: edit ? editor : undefined,
|
||||
}),
|
||||
...(sum ? [sum] : []), // 799-1 소계 · 끝수 · 합계
|
||||
);
|
||||
const left = el("div", {
|
||||
className: "m01-eq__left",
|
||||
children: [
|
||||
createEqChain(eq.줄, {
|
||||
path: "",
|
||||
open,
|
||||
resultUnit: eq.결과단위,
|
||||
onVar: edit ? undefined : showDef, // 고칠 수 있으면 식 어디를 눌러도 그 자리 고침(변수 칩 포함)
|
||||
editing: editingLine,
|
||||
onEdit: edit
|
||||
? (_line, id) => {
|
||||
editingLine = id;
|
||||
paint();
|
||||
}
|
||||
: undefined,
|
||||
editor: edit ? editor : undefined,
|
||||
}),
|
||||
...(sum ? [sum] : []), // 799-1 소계 · 끝수 · 합계
|
||||
],
|
||||
});
|
||||
defsBox = buildDefs();
|
||||
body.replaceChildren(el("div", { className: "m01-eq__split", children: [left, defsBox] }));
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
@@ -494,7 +553,7 @@ export function createLabEquation(opt: LabEquationOptions) {
|
||||
post("/logic/equation/save", { ...body0(), version, 개정확인: ok }),
|
||||
);
|
||||
if (!done) return; // 개정 확인 취소 = 고침 그대로
|
||||
[edits, selected] = [[], null];
|
||||
[edits, editingLine] = [[], null];
|
||||
persist();
|
||||
showToast(t("Saved"), "success");
|
||||
await refresh();
|
||||
@@ -514,7 +573,7 @@ export function createLabEquation(opt: LabEquationOptions) {
|
||||
|
||||
async function discard(): Promise<void> {
|
||||
edits = [];
|
||||
selected = null;
|
||||
[editingLine, defEditing, pending] = [null, null, null];
|
||||
drafts.clear();
|
||||
persist();
|
||||
await refresh();
|
||||
@@ -529,7 +588,8 @@ export function createLabEquation(opt: LabEquationOptions) {
|
||||
version = nextVersion;
|
||||
row = nextRow;
|
||||
if (nextKey !== key) {
|
||||
[key, eq, rowNow, before, why, selected] = [nextKey, null, null, null, "", null];
|
||||
[key, eq, rowNow, before, why, editingLine] = [nextKey, null, null, null, "", null];
|
||||
[defEditing, pending] = [null, null];
|
||||
inputs = {};
|
||||
edits = page ? page.edits() : (loadCache()[nextKey] ?? []);
|
||||
open.clear();
|
||||
|
||||
Reference in New Issue
Block a user