feat(M01): 로직 개선 시험 「텍스트 수식」 컨테이너 — 서버 /text 가 준 줄(비목 묶음 · 소계 · 끝수 · 까닭 옅게)을 그대로 찍음

- `runText` API + 시험 계산 때 `/calc` 와 같은 몸으로 같이 부름 · 화면은 다시 계산하지 않음
- 멈추면 서버 까닭 한 줄
- ORCA 13-4-1 · 12-4 · 12-17-1 소계 합이 호표 계와 같음 · 모달 표 뜸

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WRFJmmhPi4exAzHgPZHMT
This commit is contained in:
2026-09-21 19:46:56 +09:00
co-authored by Claude Sonnet 5
parent b99585da59
commit 448ef6080f
7 changed files with 118 additions and 13 deletions
+17 -1
View File
@@ -23,6 +23,7 @@ import {
fetchLogicSubs,
saveFiles,
type CalcLine,
type TextAnswer,
type ElementBrief,
type LogicRow,
type LogicSummary,
@@ -52,6 +53,7 @@ interface Opened extends Draft {
prices: Record<string, ElementBrief | null>;
reasons: string[];
lines: CalcLine[] | null;
text: TextAnswer | null;
values: Record<string, string>;
}
@@ -151,6 +153,9 @@ export async function mountM01LogicLab(
row: current.row as LogicRow,
dirty: () => current.id in drafts,
values: current.values,
onText: (text) => {
current.text = text;
},
onLines: (lines) => {
current.lines = lines;
drawEditor();
@@ -178,6 +183,7 @@ export async function mountM01LogicLab(
reasons: current.reasons,
prices: current.prices,
lines: current.lines,
text: current.text,
values: current.values,
onChange: touch,
calcHost: calc,
@@ -194,7 +200,16 @@ export async function mountM01LogicLab(
const open = async (id: string, sub: string, key: string): Promise<void> => {
const draft = drafts[id];
if (draft?.origKey === null) {
show({ ...draft, id, original: null, prices: {}, reasons: [], lines: null, values: {} });
show({
...draft,
id,
original: null,
prices: {},
reasons: [],
lines: null,
text: null,
values: {},
});
return;
}
try {
@@ -210,6 +225,7 @@ export async function mountM01LogicLab(
prices: one.prices,
reasons: one.reasons,
lines: null,
text: null,
values: {},
});
} catch (error) {
@@ -13,6 +13,7 @@ import type {
HoLine,
LogicRow,
NamedFormula,
TextAnswer,
} from "./M01_MasterData_UI_Logic_Api";
import { formatNumber, qtyKind } from "./M01_MasterData_UI_Logic_Edit";
import { tx } from "./M01_MasterData_UI_Logic_Text";
@@ -28,6 +29,8 @@ export interface DetailContext {
prices: Record<string, ElementBrief | null>;
/** 마지막 시험 계산의 줄 — 호표 차례 뒤에 덧줄 */
lines: CalcLine[] | null;
/** 마지막 시험 계산의 읽는 식 줄(서버가 줌) */
text: TextAnswer | null;
values: Record<string, string>;
onChange: () => void;
/** 시험 계산 칸(`buildCalc` 가 채움) — 넷째 컨테이너 안에 놓음 */
@@ -280,7 +283,7 @@ function hoBox(ctx: DetailContext): HTMLElement {
/** 상세 화면 넷을 `host` 에 쌓음 */
export function buildLabDetail(host: HTMLElement, ctx: DetailContext): void {
const formula = el("div", { className: "m01lab__formula" });
buildFormula(formula, { row: ctx.row, lines: ctx.lines });
buildFormula(formula, { text: ctx.text });
host.replaceChildren(
infoBox(ctx),
hoBox(ctx),
@@ -1,19 +1,60 @@
/* =============================================================================
* M01_MasterData_UI_LogicLab_Formula.ts
* 「텍스트 수식」 컨테이너 자리 — 비목 묶음별 `이름 : 단가 * 수량식 = 금액` 줄은 여기서 채움(PLAN 3-2)
* 「텍스트 수식」 컨테이너 — 서버(`POST /text`)가 준 줄을 그대로 찍음(화면에서 다시 계산하지 않음)
* 비목 묶음 제목 · 줄(`이름 : 글`) · 소계 · 끝수 한 마디 · 까닭 있는 줄은 옅게
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
import type { CalcLine, LogicRow } from "./M01_MasterData_UI_Logic_Api";
import type { TextAnswer } from "./M01_MasterData_UI_Logic_Api";
import { formatNumber } from "./M01_MasterData_UI_Logic_Edit";
import { tx } from "./M01_MasterData_UI_Logic_Text";
import { tl } from "./M01_MasterData_UI_LogicLab_Text";
export interface FormulaContext {
row: LogicRow;
/** 마지막 시험 계산의 줄 — 호표 차례 뒤에 덧줄 */
lines: CalcLine[] | null;
text: TextAnswer | null;
}
/** 자리만 — 줄을 채우는 일감이 `host` 안에 그림 */
export function buildFormula(host: HTMLElement, _ctx: FormulaContext): void {
host.replaceChildren(el("p", { className: "m01-logic__muted", text: tl("Formula_Slot") }));
const muted = (text: string): HTMLElement => el("p", { className: "m01-logic__muted", text });
export function buildFormula(host: HTMLElement, ctx: FormulaContext): void {
const text = ctx.text;
if (!text) {
host.replaceChildren(muted(tl("Formula_Slot")));
return;
}
if (!text.ok) {
host.replaceChildren(
el("div", {
className: "m01-logic__reasons",
children: [el("strong", { text: tx("Calc_Stopped") }), el("div", { text: text.reason })],
}),
);
return;
}
const blocks = text.groups.flatMap((g) => [
el("h4", { className: "m01lab__group", text: g.비목 }),
...g..map((line) =>
el("div", {
className: `m01lab__text${line. ? " m01lab__text--off" : ""}`,
attrs: { "data-text-line": line. },
children: [
el("div", { text: `${line.} : ${line.}` }),
...(line. ? [el("div", { className: "m01-logic__muted", text: line.까닭 })] : []),
],
}),
),
el("p", {
className: "m01lab__subtotal-text",
attrs: { "data-text-sub": String(g.) },
text: `${g.} ${tl("Subtotal")} ${formatNumber(g.)}${g. ? ` (${g.})` : ""}`,
}),
]);
blocks.push(
el("p", {
className: "m01lab__total",
attrs: { "data-text-sum": String(text.) },
text: `${tl("Total")} ${formatNumber(text.)}`,
}),
);
host.replaceChildren(...blocks);
}
@@ -92,3 +92,20 @@
.m01lab__calc > h3 {
display: none;
}
.m01lab__text {
font-family: var(--font-mono, monospace);
font-size: var(--text-body-sm);
word-break: break-all;
padding: var(--spacing-4) 0;
}
.m01lab__text--off {
opacity: 0.55;
}
.m01lab__subtotal-text {
margin: var(--spacing-4) 0;
font-weight: 600;
text-align: right;
}
@@ -13,7 +13,7 @@ const TEXT = {
],
Ho_Title: ["일위대가 호표", "Unit-cost table"],
Formula_Title: ["텍스트 수식", "Text formula"],
Formula_Slot: ["(준비 중)", "(coming)"],
Formula_Slot: ["시험 계산을 누르면 식이 글로 나옴", "Run the trial calc to see the formula"],
Calc_Title: ["시험 계산", "Trial calculation"],
G_labor: ["인력", "Labor"],
G_material: ["자재", "Material"],
@@ -97,6 +97,21 @@ export type CalcAnswer =
}
| { ok: false; reason: string };
export interface TextLine {
이름: string;
: string;
금액: number;
까닭?: string;
}
/** `POST /text` 답 — 서버가 만든 읽는 식 줄(화면은 그대로 찍음) */
export type TextAnswer =
| {
ok: true;
groups: { 비목: string; : TextLine[]; 소계: number; 끝수: string }[];
: number;
}
| { ok: false; reason: string };
export interface SubBrief {
name: string;
book: string | null;
@@ -183,6 +198,13 @@ export const runCalc = (body: {
file?: string;
}): Promise<CalcAnswer> => request("/calc", body);
export const runText = (body: {
key: string;
inputs: Record<string, unknown>;
row?: LogicRow;
file?: string;
}): Promise<TextAnswer> => request("/text", body);
export const saveFiles = (
files: SaveFile[],
): Promise<{ files: { file: string; version: string }[] }> => request("/save", { files });
@@ -7,8 +7,10 @@
import { createButton, createSelectField, el, showToast } from "@ui/ui_template_elements";
import {
runCalc,
runText,
type CalcAnswer,
type CalcLine,
type TextAnswer,
type LogicRow,
} from "./M01_MasterData_UI_Logic_Api";
import { formatNumber } from "./M01_MasterData_UI_Logic_Edit";
@@ -23,6 +25,8 @@ export interface CalcContext {
/** 로직마다 넣은 값 — 다시 그려도 남음 */
values: Record<string, string>;
onLines: (lines: CalcLine[] | null) => void;
/** 읽는 식 줄(`/text`)도 같이 받고 싶을 때 — 호출이 `onLines` 보다 먼저 */
onText?: (answer: TextAnswer) => void;
}
const SUMS = ["노무비", "재료비", "경비", "계"];
@@ -68,11 +72,13 @@ export function buildCalc(host: HTMLElement, ctx: CalcContext): void {
}
const draft = ctx.dirty() || ctx.savedKey === null;
try {
const answer = await runCalc({
const body = {
key: ctx.savedKey ?? ctx.row.,
inputs,
...(draft ? { row: ctx.row, file: ctx.file } : {}),
});
};
const answer = await runCalc(body);
if (ctx.onText) ctx.onText(await runText(body));
ctx.onLines(answer.ok ? (answer.lines ?? null) : null);
out.replaceChildren(...answerView(answer, draft));
} catch (error) {