feat(796-2a): 수식 탭 화면 — 공통 수식 부품 · M01 수식 탭(첫 탭) · 고침 · 변수 창 · 옵션 가로

- ui_template_equation(.ts · _line.ts · .css): 분수 · 첨자 · 괄호 · % · 조건 칩 · 하위 로직 블록(접힘) · 결과명 = 이름 식 = 값 식 = 결과 + 변수 풀이 · 부품 모음 견본 한 줄
- M01 LogicLab_Equation: 옵션 가로(도구줄 + 값 칸 · 찾기형 · 말줄임) · 줄 고르면 식 칸 + 숨은 아이콘 줄 · 서버 미리 셈 다시 그림 · 고침 목록 캐시 → [수식 저장](개정 확인) · 떠나기 전 버림 물음
- 변수 창(이름 · 기호 · 위 · 아래첨자 · 단위 · 값 · 보이는 자리) · 변수 더함 · 지움(쓰는 곳 있으면 서버 까닭 그대로)
- 보기 고르기 = 수식 첫 탭 · 단가산출 상세 · 시트 · 인력재료경비 · 시험계산 · 텍스트 수식은 뒤로(걷지 않음)
- test_796_2_equation_screen 새로 · test_794_1 기대 갱신(첫 탭 수식 · LogicLab 700 줄 상한 — 수식 이음 14 줄)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 11:46:28 +09:00
co-authored by Claude Sonnet 5.5
parent 771e89c983
commit eaadb48a2b
15 changed files with 1973 additions and 12 deletions
+18 -2
View File
@@ -43,6 +43,7 @@ import { saveRevised } from "./M01_MasterData_UI_LogicLab_Revise";
import { buildLogicSide } from "./M01_MasterData_UI_LogicLab_Side";
import { createViewPicker } from "./M01_MasterData_UI_LogicLab_View";
import { createLabSheet } from "./M01_MasterData_UI_LogicLab_Sheet";
import { createLabEquation } from "./M01_MasterData_UI_LogicLab_Equation";
import { buildHeadBar } from "./M01_MasterData_UI_Head";
import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch";
import "./M01_MasterData_UI_Logic_Style.css";
@@ -111,6 +112,14 @@ export async function mountM01LogicLab(
edited: () => (touch(), drawEditor(), drawCalc()), // 줄 사본 → 캐시 → [저장](기존 길)
run: () => calc.querySelector<HTMLButtonElement>("[data-calc-run]")?.click(),
});
const equation = createLabEquation({
admin,
blocked: () => !!opened && opened.id in drafts, // 시트 · 시험 쪽 저장 안 한 고침이 있으면 수식 고침 잠김
onSaved: () => {
const was = opened; // reload 가 고름을 비우므로 먼저 기억
if (was?.row?.키) void reload().then(() => open(was.id, was.sub, was.row!.키));
},
});
const editor = el("div", { className: "m01-logic__editor" });
// 로직 상세 — 왼쪽 지금 내용 · 오른쪽 접는 패널 「원문」
const panes = buildLogicSide(editor);
@@ -128,7 +137,11 @@ export async function mountM01LogicLab(
(item) => void open(item.id, item.sub, item.key),
(item, alias) => void onAlias(item, alias),
);
const back = createButton({ label: tx("Bar_Back"), variant: "ghost", onClick: () => show(null) });
const back = createButton({
label: tx("Bar_Back"),
variant: "ghost",
onClick: () => void equation.canLeave().then((ok) => ok && show(null)),
});
const persist = (): void => {
try {
@@ -243,7 +256,9 @@ export async function mountM01LogicLab(
calcHost: calc,
admin,
sheet: sheet.root,
equation: equation.root,
});
if (current.origKey) equation.draw(current.origKey, current.version, current.row as LogicRow);
sheet.draw(current.row as LogicRow, current.original, current.values);
};
@@ -321,7 +336,7 @@ export async function mountM01LogicLab(
restore: true,
onPick: (sub, detail) => {
list.setPick(sub, detail);
if (opened) show(null);
if (opened) void equation.canLeave().then((ok) => ok && show(null));
},
}),
);
@@ -502,6 +517,7 @@ export async function mountM01LogicLab(
show(null);
showLoadingOverlay();
const openKeyOn = async (key: string): Promise<void> => {
if (!(await equation.canLeave())) return;
const found = allLogics.find((x) => x.키 === key);
if (found) await open(logicId(found.구분, found.키), found.구분, found.키);
else showToast(tx("Load_Failed"), "error");
@@ -1,6 +1,6 @@
/* =============================================================================
* M01_MasterData_UI_LogicLab_Detail.ts
* 「단가산출 로직」 상세 화면 — 컨테이너 넷을 위에서 아래로
* 「단가산출 로직」 상세 화면 — 컨테이너를 위에서 아래로(수식 탭 796-2 포함)
* ① 기본정보(정본은 읽기 전용 · 비고만 고침) ② 시험 계산(입력값 + 결과값 — `Logic_Calc` 그대로)
* ③ 단가산출 상세(공용 부품 `_LogicLab_Ho.ts` — 하위 박스 · 계산식 · 박스 옵션 · 그 자리 고침) ④ 텍스트 수식(`_LogicLab_Formula.ts`)
* 그 자리 고침(722-2) = 시험 값만(`_LogicLab_Cover.ts` trial — 줄 사본으로 셈 · 정본 · 요소 · 캐시 안 바뀜)
@@ -39,6 +39,8 @@ export interface DetailContext extends HoContext {
cover?: { ho: HTMLElement; est: HTMLElement; open?: ReturnType<typeof lineOpener> };
/** 794-1 M01 줄 식 시트(`_LogicLab_Sheet`) 그릇 — 있으면 「시트」 덩이(M02 는 안 넘김) */
sheet?: HTMLElement;
/** 796-2 수식 탭(`_LogicLab_Equation`) 그릇 — 있으면 「수식」 덩이(M02 는 795-1 사본 꼴 뒤에 이음) */
equation?: HTMLElement;
}
const section = (title: string, body: HTMLElement[], hint = ""): HTMLElement =>
@@ -235,6 +237,7 @@ export function buildLabDetail(host: HTMLElement, ctx: DetailContext): void {
// 보기 고르기(`_View.ts`) — 덩이마다 이름표 · 고른 덩이만 보임 · 시험 계산은 숨어도 그려 둠
host.replaceChildren(
...infoBox(ctx),
...(ctx.equation ? [block(ctx.equation, "equation")] : []),
...(ctx.sheet ? [block(ctx.sheet, "sheet")] : []),
block(section(tl("Calc_Title"), [ctx.calcHost]), "calc"),
block(
@@ -0,0 +1,69 @@
/* 수식 탭(796-2) — 배치만 · 식 그림은 공통 수식 부품 · 색은 공통 값 */
.m01-eq {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
min-width: 0;
container-type: inline-size;
}
.m01-eq__options .ui-toolbar {
align-items: flex-end;
gap: var(--spacing-8) var(--spacing-12);
}
.m01-eq__opt {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
max-width: 14rem;
}
.m01-eq__opt > .ui-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.m01-eq__ctl {
min-width: 0;
}
.m01-eq__ctl :is(select, input) {
width: 100%;
max-width: 14rem;
text-overflow: ellipsis;
}
.m01-eq__notice:empty {
display: none;
}
.m01-eq__edit {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
padding: var(--spacing-8);
border: 1px solid var(--color-border-strong);
border-radius: var(--radius-sm);
background: var(--color-surface-raised);
}
.m01-eq__fx {
min-width: 0;
}
.m01-eq__form {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
gap: var(--spacing-8) var(--spacing-12);
}
.m01-eq__why:empty {
display: none;
}
.m01-eq__why {
margin-top: var(--spacing-8);
}
@@ -0,0 +1,513 @@
/* =============================================================================
* M01_MasterData_UI_LogicLab_Equation.ts
* 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`(개정 확인)
* 계산 자리 = 서버 한 벌(③) — 화면은 고침 목록만 보내고 서버가 다시 셈한 나무를 다시 그림(옛 요청 끊음)
* 시트 · 시험 쪽에 저장 안 한 고침이 있으면 수식 고침은 잠김(두 갈래가 한 줄을 따로 고치지 않게)
* ========================================================================== */
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 { createEqChain } from "@ui/ui_template_equation_line";
import { eqText, type EqLine, type EqTree, type EqVarRow } from "@ui/ui_template_equation";
import {
el,
hideLoadingOverlay,
showConfirmDialog,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import { createInputField } 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 { 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";
import "./M01_MasterData_UI_LogicLab_Equation.css";
const TEXT = {
Title: ["수식", "Equation"],
Save: ["수식 저장", "Save equation"],
Discard: ["수식 고침 버림", "Discard edits"],
Add: ["변수 더함", "Add variable"],
Edits: ["수식 고침 {n} 건 · 저장 안 함", "{n} equation edits · unsaved"],
Now: ["지금", "Now"],
Before: ["고치기 전", "Before"],
Blocked: [
"시트 · 시험 쪽에 저장 안 한 고침이 있음 — 먼저 [저장] 또는 [버림] 한 뒤 수식을 고침(수식은 저장본 기준)",
"Unsaved edits elsewhere — save or discard first (showing saved version)",
],
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"],
Leave: ["저장 안 한 수식 고침 {n} 건 — 버리고 나감?", "Discard {n} unsaved equation edits?"],
Leave_Ok: ["버림", "Discard"],
Saved: ["수식을 저장함", "Equation saved"],
Total: ["계", "Total"],
} as const satisfies Record<string, readonly [string, string]>;
const t = (k: keyof typeof TEXT, n = 0): string =>
(TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0]).replace("{n}", String(n));
interface Read {
ok?: boolean;
reason?: string;
수식?: EqTree;
되나?: boolean;
까닭?: string[];
줄?: LogicRow;
전?: Record<string, unknown>;
후?: Record<string, unknown>;
}
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 ApiError(r.status, data.detail ?? r.statusText);
return data;
}
const CACHE = "m01_equation_edits";
const BIG = 12; // 고르기가 이보다 많으면 찾기형(입력 + 목록)
const loadCache = (): Record<string, EqEdit[]> => {
try {
return JSON.parse(sessionStorage.getItem(CACHE) ?? "{}") as Record<string, EqEdit[]>;
} catch {
return {};
}
};
export interface LabEquationOptions {
/** 시스템 관리자 — 고침 도구 */
admin: boolean;
/** 시트 · 시험 쪽에 저장 안 한 고침이 있나 */
blocked: () => boolean;
/** 저장 뒤 — 상세를 저장본으로 다시 엶 */
onSaved: () => void;
}
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": "" } });
const notice = el("div", { className: "m01-eq__notice" });
const body = el("div", { className: "m01-eq__body" });
const root = el("div", {
className: "m01-eq",
attrs: { "data-lab-equation": "" },
children: [bar, optionsBar, notice, body],
});
let key = "";
let version = "";
let row: LogicRow | null = null;
let rowNow: LogicRow | null = null;
let eq: EqTree | null = null;
let edits: EqEdit[] = [];
let inputs: Record<string, string> = {};
let before: Record<string, unknown> | null = null;
let why = "";
let selected: string | null = null;
let ctl: AbortController | null = null;
let timer = 0;
let paintedBlocked = false;
const open = new Set<string>();
const drafts = new Map<string, string>();
const canEdit = (): boolean => opt.admin && !opt.blocked();
const body0 = (): Record<string, unknown> => ({ key, inputs: inputsOf(inputs), 고침: edits });
const persist = (): void => {
const all = loadCache();
if (edits.length) all[key] = edits;
else delete all[key];
try {
sessionStorage.setItem(CACHE, JSON.stringify(all));
} catch {
/* 캐시가 막혀도 화면 안 고침은 남음 */
}
};
/** 서버 셈 — 고침 목록이 있으면 미리 셈 · 없으면 읽기 · 옛 요청 끊음 */
async function refresh(): Promise<void> {
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);
if (mine !== ctl) return;
if (got.되나 === false || got.ok === false) {
why = (got.까닭 ?? [got.reason ?? ""]).join(" · ");
} else {
why = "";
eq = got.수식 ?? null;
rowNow = got.줄 ?? null;
before = edits.length ? (got.전 ?? null) : null;
}
} catch (error) {
if (mine.signal.aborted) return;
why = error instanceof Error ? error.message : String(error);
}
paint();
}
/** 고침 하나를 더해 보고 되면 굳힘 — 안 되면 까닭 목록 */
async function commit(list: EqEdit[]): Promise<string[]> {
const got = await post<Read>("/logic/equation/preview", {
...body0(),
고침: [...edits, ...list],
}).catch((error: unknown): Read => ({
되나: false,
까닭: [error instanceof Error ? error.message : String(error)],
}));
if (!got.되나) return got.까닭 ?? [];
edits = [...edits, ...list];
persist();
ctl?.abort();
why = "";
eq = got.수식 ?? eq;
rowNow = got.줄 ?? rowNow;
before = got.전 ?? before;
paint();
drawOptions();
return [];
}
const askAgain = (): void => {
window.clearTimeout(timer);
timer = window.setTimeout(() => void refresh(), 150);
};
// ── 옵션 가로 ──────────────────────────────────────────────────────
function optionNode(spec: NonNullable<LogicRow["입력"]>[number], auto: unknown): HTMLElement {
const item: ItemInput = {
이름: spec.이름,
단위: spec.단위,
고르기: spec.고르기,
범위: spec.범위,
자동값: auto,
};
const label = createText(spec.이름 + (spec.단위 ? ` (${spec.단위})` : ""), "muted", 12);
const set = (v: string | number | null): void => {
if (v === null || v === "") delete inputs[spec.이름];
else inputs[spec.이름] = String(v);
askAgain();
};
let control: HTMLElement;
if ((spec.고르기?.length ?? 0) > BIG) {
// 찾기형 — 입력 + 목록(칠수록 좁혀짐)
const box = createInputField({
value: inputs[spec.이름] ?? "",
compact: true,
size: 24,
placeholder: auto === null || auto === undefined ? "" : String(auto),
});
const list = el("datalist", {
attrs: { id: `m01-eq-list-${spec.이름}` },
children: (spec.고르기 ?? []).map((o) => el("option", { attrs: { value: String(o) } })),
});
box.input.setAttribute("list", list.id);
box.input.title = box.input.value || box.input.placeholder;
box.input.addEventListener("change", () => {
box.input.title = box.input.value;
set(box.input.value.trim());
});
control = el("div", { children: [box.root, list] });
} else {
const made = createValueControl(item, inputs[spec.이름] ?? null, (v) => set(v));
const tip = (): void => {
const c = made.control;
c.title = c instanceof HTMLSelectElement ? (c.selectedOptions[0]?.text ?? "") : c.value;
};
made.control.addEventListener("change", tip);
made.control.addEventListener("input", tip);
tip();
control = el("div", { children: [made.control, made.error] });
}
control.classList.add("m01-eq__ctl");
const cell = el("div", { className: "m01-eq__opt", children: [label, control] });
cell.dataset.eqOption = spec.이름;
return cell;
}
function drawOptions(): void {
const specs = (rowNow ?? row)?.입력 ?? [];
const table = new Map((eq?.변수표 ?? []).map((v) => [v.열쇠, v]));
optionsBar.replaceChildren(
...(specs.length
? [
createToolbar({
items: specs.map((s) => optionNode(s, table.get(s.이름)?.기본값 ?? null)),
size: 24,
label: t("Options"),
}),
]
: []),
);
}
// ── 고침 도구 ──────────────────────────────────────────────────────
const ICONS: [InsertKind, string, "Frac" | "Paren" | "Sub" | "Sup" | "Percent"][] = [
["frac", "a⁄b", "Frac"],
["paren", "( )", "Paren"],
["sub", "x₂", "Sub"],
["sup", "x²", "Sup"],
["percent", "%", "Percent"],
];
const check =
(line: EqLine) =>
async (text: string, signal: AbortSignal): Promise<FormulaVerdict> => {
const got = await post<Read>(
"/logic/equation/preview",
{ ...body0(), 고침: [...edits, formulaEdit(line, text)] },
signal,
);
const now = got.후 ? eqText(got.후[total(got.후)]) : "";
return {
되나: !!got.되나,
까닭: 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 cell = createFormulaCell({
key: id,
spot: id,
shown,
origin: shown,
drafts,
check: check(line),
put: (text) => {
if (text === null) return;
void commit([formulaEdit(line, text)]).then((bad) => {
if (bad.length) showToast(bad.join(" · "), "error");
});
},
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 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;
}
function varDialog(node: { 열쇠?: string }): void {
const v = (eq?.변수표 ?? []).find((x: EqVarRow) => x.열쇠 === node.열쇠);
if (!v || !canEdit()) return;
openVarDialog({
row: v,
value: inputs[v.열쇠] ?? "",
commit,
onValue: (value) => {
if (value) inputs[v.열쇠] = value;
else delete inputs[v.열쇠];
drawOptions();
void refresh();
},
onDropped: (k) => {
delete inputs[k];
drawOptions();
},
});
}
// ── 그림 ──────────────────────────────────────────────────────────
function drawBar(): void {
const sums = eq?.합계 ?? {};
const mark = total(sums);
const now = sums[mark];
const old = before?.[mark];
const diff = Number(now) - Number(old);
const items: (HTMLElement | "divider" | "spacer")[] = [
createText(`${t("Now")} ${eqText(now) || "—"}`, "muted", 14),
];
if (before) {
items.push(createText(`${t("Before")} ${eqText(old) || "—"}`, "muted", 14));
if (Number.isFinite(diff) && diff)
items.push(createBadge(`${diff > 0 ? "+" : "−"}${eqText(Math.abs(diff))}`, "warning"));
}
if (canEdit()) {
items.push("spacer");
if (edits.length) {
items.push(
createBadge(t("Edits", edits.length), "warning"),
createButton({
label: t("Discard"),
variant: "ghost",
size: 24,
onClick: () => void discard(),
}),
createButton({ label: t("Save"), size: 24, onClick: () => void save() }),
);
} else items.push(createText(t("Hint"), "muted", 12));
}
bar.replaceChildren(createToolbar({ items, size: 28, label: t("Title") }));
}
function paint(): void {
paintedBlocked = opt.blocked();
drawBar();
notice.replaceChildren(
...(paintedBlocked && opt.admin
? [createBanner({ tone: "warning", text: t("Blocked") })]
: []),
...(why ? [createBanner({ tone: "danger", text: why })] : []),
);
if (!eq) {
body.replaceChildren(createText(t("Wait"), "muted"));
return;
}
const edit = canEdit();
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,
}),
);
}
async function save(): Promise<void> {
if (!canEdit() || !edits.length) return;
showLoadingOverlay();
try {
const done = await saveRevised((ok) =>
post("/logic/equation/save", { ...body0(), version, 개정확인: ok }),
);
if (!done) return; // 개정 확인 취소 = 고침 그대로
[edits, selected] = [[], null];
persist();
showToast(t("Saved"), "success");
await refresh();
drawOptions();
opt.onSaved();
} catch (error) {
const detail =
error instanceof ApiError ? (error.detail as { 까닭?: string[] } | null) : null;
showToast(
detail?.까닭?.join(" · ") ?? (error instanceof Error ? error.message : String(error)),
"error",
);
} finally {
hideLoadingOverlay();
}
}
async function discard(): Promise<void> {
edits = [];
selected = null;
drafts.clear();
persist();
await refresh();
drawOptions();
}
return {
root,
/** 상세가 그릴 때마다 — 로직이 바뀔 때만 서버를 부름 */
draw: (nextKey: string, nextVersion: string, nextRow: LogicRow): void => {
version = nextVersion;
row = nextRow;
if (nextKey !== key) {
[key, eq, rowNow, before, why, selected] = [nextKey, null, null, null, "", null];
inputs = {};
edits = loadCache()[nextKey] ?? [];
open.clear();
drafts.clear();
paint();
drawOptions();
void refresh().then(drawOptions);
} else if (opt.blocked() !== paintedBlocked) paint();
},
dirty: (): boolean => edits.length > 0,
/** 나가기 전 — 저장 안 한 고침이 있으면 버림 물음 · 버리면 true */
canLeave: async (): Promise<boolean> => {
if (!edits.length) return true;
if (!(await showConfirmDialog(t("Leave", edits.length), t("Leave_Ok")))) return false;
await discard();
return true;
},
};
}
@@ -0,0 +1,247 @@
/* =============================================================================
* M01_MasterData_UI_LogicLab_EquationVar.ts
* 수식 탭 변수 창(PLAN 796-2 · 요구 657) — 변수 고침 · 변수 더함 · 변수 지움(쓰는 곳 있으면 막고 목록 보임).
* 고침 창 = 이름 · 기호 · 위첨자 · 아래첨자 · 단위 · 값(입력 변수만 · 시험 값 · 저장 안 함) · [적용] · [변수 지움]
* 더함 창 = 갈래(입력 · 중간) · 열쇠 · 이름 · 기호 · 첨자 · 단위 · 기본값(입력) | 식 · 자리(중간)
* 적용 = 부르는 쪽 `commit` 이 서버 미리 셈(`/logic/equation/preview`)을 거쳐 되면 [] · 안 되면 까닭 — 까닭은 창 안 띠에 그대로
* 부품 = 모달 · 입력 · 고르기 · 단추 · 띠 알림 · 열쇠 · 식 검사 · 이름 겹침은 서버 몫
* ========================================================================== */
import type { EqVarRow } from "@ui/ui_template_equation";
import { createBanner } from "@ui/ui_template_banner";
import { createButton } from "@ui/ui_template_button";
import { el } from "@ui/ui_template_elements_base";
import { createInputField, createSelectField } from "@ui/ui_template_input";
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { openModal } from "@ui/ui_template_modal";
import { addEdit, varEdits, type EqEdit, type VarForm } from "./logic/M01_Logic_Equation_Model";
const TEXT = {
VarTitle: ["변수 고침", "Edit variable"],
AddTitle: ["변수 더함", "Add variable"],
Close: ["닫기", "Close"],
Apply: ["적용", "Apply"],
Drop: ["변수 지움", "Delete variable"],
Key: ["열쇠 (식이 부르는 이름 · 안 바뀜)", "Key (used by formulas · fixed)"],
Name: ["이름", "Name"],
Sign: ["기호", "Symbol"],
Up: ["위첨자", "Superscript"],
Down: ["아래첨자", "Subscript"],
Unit: ["단위", "Unit"],
Value: ["값 (시험 값 · 저장 안 함)", "Value (trial · not saved)"],
Digits: ["보이는 자리 (0~6 · 빈 칸 = 전체 자리)", "Digits (0-6 · blank = full)"],
Kind: ["갈래", "Kind"],
Input: ["입력", "Input"],
Mid: ["중간", "Intermediate"],
Default: ["기본값", "Default"],
Formula: ["식", "Formula"],
After: ["자리", "Place"],
End: ["맨 끝", "End"],
Top: ["맨 위", "Top"],
Why: ["안 됨", "Not applied"],
Hint: ["이름 · 기호 · 첨자를 바꾸면 쓰는 모든 식에 함께 보임", "Renames show in every formula"],
} as const satisfies Record<string, readonly [string, string]>;
const t = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0];
export type Commit = (edits: EqEdit[]) => Promise<string[]>;
const grid = (...kids: HTMLElement[]): HTMLElement =>
el("div", { className: "m01-eq__form", children: kids });
/** 까닭 띠 — 있으면 보임 · 없으면 비움 */
function reasonBox(): { root: HTMLElement; set: (why: string[]) => void } {
const root = el("div", { className: "m01-eq__why" });
return {
root,
set: (why) =>
root.replaceChildren(
...(why.length
? [createBanner({ tone: "danger", title: t("Why"), text: why.join(" · ") })]
: []),
),
};
}
export interface VarDialog {
row: EqVarRow;
/** 입력 변수 시험 값(옵션 칸 글) */
value: string;
commit: Commit;
/** 시험 값을 바꿈(저장 안 함) */
onValue: (value: string) => void;
/** 변수를 지운 뒤 — 시험 값 비움 */
onDropped: (key: string) => void;
}
export function openVarDialog(o: VarDialog): void {
const v = o.row;
const field = (label: string, value: string): ReturnType<typeof createInputField> =>
createInputField({ label, value, size: 28 });
const name = field(t("Name"), v.이름);
const sign = field(t("Sign"), v.기호);
const up = field(t("Up"), v.위);
const down = field(t("Down"), v.아래);
const unit = field(t("Unit"), v.단위);
const val = field(t("Value"), o.value);
const shown = v.표시 === undefined ? "" : String(v.표시);
const digits = field(t("Digits"), shown);
const why = reasonBox();
openModal({
title: `${t("VarTitle")} · ${v.열쇠}`,
closeLabel: t("Close"),
width: "40rem",
mount: (body, close, actions) => {
body.append(
el("p", { className: "ui-text ui-text--muted", text: t("Hint") }),
grid(
name.root,
sign.root,
up.root,
down.root,
unit.root,
...(v.갈래 === "입력" ? [val.root] : [digits.root]),
),
why.root,
);
actions.append(
createButton({
label: t("Drop"),
variant: "danger-ghost",
onClick: async () => {
const bad = await o.commit([{ 할일: "지움", 열쇠: v.열쇠 }]);
why.set(bad);
if (bad.length) return;
o.onDropped(v.열쇠);
close();
},
}),
createButton({
label: t("Apply"),
onClick: async () => {
const now: VarForm = {
열쇠: v.열쇠,
이름: name.input.value.trim(),
기호: sign.input.value.trim(),
위: up.input.value.trim(),
아래: down.input.value.trim(),
단위: unit.input.value.trim(),
표시: digits.input.value.trim(),
};
const edits = varEdits(
{
열쇠: v.열쇠,
이름: v.이름,
기호: v.기호,
위: v.위,
아래: v.아래,
단위: v.단위,
표시: shown,
},
now,
);
const bad = edits.length ? await o.commit(edits) : [];
why.set(bad);
if (bad.length) return;
if (v.갈래 === "입력" && val.input.value.trim() !== o.value)
o.onValue(val.input.value.trim());
close();
},
}),
);
},
});
}
export interface AddDialog {
/** 중간 줄 열쇠 차례(자리 고르기) */
mids: string[];
commit: Commit;
}
export function openAddDialog(o: AddDialog): void {
const field = (label: string, value = ""): ReturnType<typeof createInputField> =>
createInputField({ label, value, size: 28 });
const kind = createSelectField({
label: t("Kind"),
options: [
{ value: "입력", text: t("Input") },
{ value: "중간", text: t("Mid") },
],
value: "입력",
size: 28,
});
const key = field(t("Key"));
const name = field(t("Name"));
const sign = field(t("Sign"));
const up = field(t("Up"));
const down = field(t("Down"));
const unit = field(t("Unit"));
const dflt = field(t("Default"));
const expr = field(t("Formula"));
const place = createSelectField({
label: t("After"),
options: [
{ value: "", text: t("End") },
{ value: "\u0000top", text: t("Top") },
...o.mids.map((m) => ({ value: m, text: m })),
],
value: "",
size: 28,
});
const why = reasonBox();
const sync = (): void => {
const mid = kind.select.value === "중간";
dflt.root.hidden = mid;
expr.root.hidden = place.root.hidden = !mid;
};
kind.select.addEventListener("change", sync);
sync();
openModal({
title: t("AddTitle"),
closeLabel: t("Close"),
width: "40rem",
mount: (body, close, actions) => {
body.append(
grid(
kind.root,
key.root,
name.root,
sign.root,
up.root,
down.root,
unit.root,
dflt.root,
expr.root,
place.root,
),
why.root,
);
actions.append(
createButton({
label: t("Apply"),
onClick: async () => {
const mid = kind.select.value === "중간";
const at = place.select.value;
const edit = addEdit(
{
열쇠: key.input.value,
이름: name.input.value,
기호: sign.input.value,
위: up.input.value,
아래: down.input.value,
단위: unit.input.value,
갈래: mid ? "중간" : "입력",
기본값: dflt.input.value.trim(),
식: expr.input.value.trim(),
},
at === "\u0000top" ? "" : at || undefined,
);
const bad = await o.commit([edit]);
why.set(bad);
if (!bad.length) close();
},
}),
);
},
});
}
@@ -199,6 +199,7 @@
}
/* 보기 고르기(710-2 · `_View.ts`) — 그릇 data-view 와 다른 이름표 덩이는 숨김(시험 계산은 숨어도 돎) */
.m01-logic__editor[data-view="equation"] [data-block]:not([data-block="equation"]),
.m01-logic__editor[data-view="sheet"] [data-block]:not([data-block="sheet"]),
.m01-logic__editor[data-view="ho"] [data-block]:not([data-block="ho"]),
.m01-logic__editor[data-view="est"] [data-block]:not([data-block="est"]),
@@ -35,6 +35,7 @@ const TEXT = {
Calc_Title: ["시험 계산", "Trial calculation"],
View_Est: ["인력·재료·경비", "Labor·material·expense"],
View_Sheet: ["시트", "Sheet"],
View_Equation: ["수식", "Equation"],
Edit_SheetHint: ["식은 「시트」 보기에서 고침", "Edit formulas in the Sheet view"],
View_Label: ["보기", "View"],
G_labor: ["인력", "Labor"],
@@ -1,17 +1,17 @@
/* =============================================================================
* M01_MasterData_UI_LogicLab_View.ts
* 「단가산출 로직」 상세 보기 고르기 — 머리줄 [신규 로직] 왼쪽 「분할 고르기 | 세로 나눔선」
* 보기 다섯 = 시트(기본 · 794-1 줄 식 시트) · 단가산출 상세 · 인력·재료·경비 · 시험 계산 · 텍스트 수식 — 고른 덩이만 보임
* 보기 여섯 = 수식(기본 · 796-2) · 단가산출 상세 · 시트 · 인력·재료·경비 · 시험 계산 · 텍스트 수식 — 고른 덩이만 보임
* 덩이 = `data-block` 이름표 · 숨김 = 그릇 `data-view` + CSS(`LogicLab_Style.css`) — 다시 그려도 고름 유지
* 시험 계산은 숨겨도 그려 둠(자동 계산이 숨은 칸에서도 돎) · 기본정보는 이름표 없음(늘 맨 위)
* 기억 = 이 화면 안에서만(로직을 바꿔도 유지 · 새로 들어오면 시트)
* 기억 = 이 화면 안에서만(로직을 바꿔도 유지 · 새로 들어오면 수식)
* ========================================================================== */
import { createSegment } from "@ui/ui_template_segment";
import { createToolbar } from "@ui/ui_template_toolbar";
import { tl } from "./M01_MasterData_UI_LogicLab_Text";
export type LabView = "sheet" | "ho" | "est" | "calc" | "formula";
export type LabView = "equation" | "sheet" | "ho" | "est" | "calc" | "formula";
/** 덩이에 보기 이름표를 붙여 돌려줌 */
export const block = <T extends HTMLElement>(node: T, view: LabView): T => {
@@ -21,16 +21,17 @@ export const block = <T extends HTMLElement>(node: T, view: LabView): T => {
/** 보기 고르기 — `host`(상세 그릇)에 `data-view` 를 둠 · `root` 는 머리줄에 놓음 */
export function createViewPicker(host: HTMLElement): { root: HTMLElement } {
host.dataset.view = "sheet";
host.dataset.view = "equation";
const segment = createSegment({
options: [
{ value: "sheet", label: tl("View_Sheet") },
{ value: "equation", label: tl("View_Equation") },
{ value: "ho", label: tl("Ho_Title") },
{ value: "sheet", label: tl("View_Sheet") },
{ value: "est", label: tl("View_Est") },
{ value: "calc", label: tl("Calc_Title") },
{ value: "formula", label: tl("Formula_Title") },
],
value: "sheet",
value: "equation",
size: 32,
ariaLabel: tl("View_Label"),
onChange: (v) => (host.dataset.view = v),
@@ -0,0 +1,114 @@
/* =============================================================================
* M01_MasterData/logic/M01_Logic_Equation_Model.ts
* 수식 탭 계산(796-2 · 화면 · fetch 없음) — 줄 → 고침 자리 · 식 칸 아이콘 끼움 · 옵션 값 꼴 · 변수 고침 요청.
* 서버 고침 꼴 = `master_equation_edit.py` 머리(할일 식 · 더함 · 지움 · 변수 · 표시) — 여기서는 요청 목록만 만듦
* 셈 · 검사는 서버 한 벌(③ 서버 단독) — 브라우저는 모양만
* ========================================================================== */
import type { EqLine } from "@ui/ui_template_equation";
export interface EqEdit {
할일: "식" | "더함" | "지움" | "변수" | "표시";
[key: string]: unknown;
}
/** 식 줄의 고침 자리(`중간:<열쇠>` · `결과` · `호표:<n>` · `덧줄:<n>`) */
export const spotOf = (line: EqLine): string =>
line.갈래 === "호표" || line.갈래 === "덧줄"
? `${line.갈래}:${line.차례 ?? 0}`
: line.갈래 === "결과"
? "결과"
: `중간:${line.열쇠}`;
export const formulaEdit = (line: EqLine, text: string): EqEdit => ({
할일: "식",
자리: spotOf(line),
식: text,
});
/** 아이콘 줄 — 식 칸 글에 끼우는 꼴 */
export type InsertKind = "frac" | "paren" | "sub" | "sup" | "percent";
/** 선택 영역 `[from, to)` 를 아이콘 꼴로 바꾼 글 · 커서 자리 */
export function insertInto(
kind: InsertKind,
text: string,
from: number,
to: number,
): { text: string; caret: number } {
const head = text.slice(0, from);
const pick = text.slice(from, to);
const tail = text.slice(to);
const put = (mid: string, caret: number): { text: string; caret: number } => ({
text: head + mid + tail,
caret: head.length + caret,
});
switch (kind) {
case "frac": // 분수 = (위) / (아래) — 고른 글이 위 · 커서는 아래 괄호 안
return pick ? put(`(${pick}) / ()`, pick.length + 6) : put("() / ()", 1);
case "paren":
return pick ? put(`(${pick})`, pick.length + 2) : put("()", 1);
case "sub":
return put(`${pick}_`, pick.length + 1);
case "sup":
return put(`${pick}^`, pick.length + 1);
case "percent":
return put(`${pick} / 100`, pick.length + 6);
}
}
/** 옵션 값 — 수 꼴 글은 수로(엔진 입력 꼴) · 빈 값은 안 보냄(자동값) */
export function inputsOf(values: Record<string, string>): Record<string, number | string> {
const out: Record<string, number | string> = {};
for (const [name, raw] of Object.entries(values)) {
if (raw === "") continue;
out[name] = /^-?\d+(\.\d+)?$/.test(raw) ? Number(raw) : raw;
}
return out;
}
export interface VarForm {
열쇠: string;
이름: string;
기호: string;
위: string;
아래: string;
단위: string;
갈래?: "입력" | "중간";
설명?: string;
기본값?: string;
식?: string;
/** 보이는 자리 글(중간 줄 · 빈 글 = 전체) */
표시?: string;
}
/** 변수 고침 창 → 요청 목록(바꾼 칸만) — `보이는 자리`(중간 줄) = 0~6 · 빈 칸 = 전체 자리(`표시` 지움) */
export function varEdits(before: VarForm, now: VarForm): EqEdit[] {
const out: EqEdit = { 할일: "변수", 열쇠: before.열쇠 };
let changed = false;
for (const k of ["이름", "기호", "위", "아래", "단위"] as const)
if (now[k] !== before[k]) {
out[k] = now[k];
changed = true;
}
const list: EqEdit[] = changed ? [out] : [];
if (now.표시 !== before.표시) {
const digits = now.표시 === "" || now.표시 === undefined ? null : Number(now.표시);
list.push({ 할일: "표시", 열쇠: before.열쇠, 표시: digits });
}
return list;
}
/** 변수 더함 창 → 요청 — 빈 칸은 안 보냄 */
export function addEdit(form: VarForm, after?: string): EqEdit {
const out: EqEdit = { 할일: "더함", 갈래: form.갈래 ?? "입력", 열쇠: form.열쇠.trim() };
for (const k of ["이름", "기호", "위", "아래", "단위", "설명"] as const) {
const v = (form[k] ?? "").trim();
if (v) out[k] = v;
}
if (form.갈래 === "중간") {
out.식 = form.식 || "0";
if (after !== undefined) out.뒤 = after;
} else if (form.기본값) out.기본값 = form.기본값;
return out;
}