feat(799-2): M01 로직 상세 · M02 단가산출 상세 옛 탭 넷(시트 · 인력/재료/경비 · 시험 계산 · 텍스트 수식) 걷음 — 보기는 수식 · 단가산출 상세 · 셈 구동 따로

- 셈 구동 떼기: 새 M01_MasterData_UI_Logic_Drive.ts(driveRun · previewText · autoFill · createDrive) — M01 상세는 숨은 시험 계산 칸 단추 대신 구동 함수 · 시험 계산 칸(Logic_Calc)은 로직 복사 화면이 쓰므로 남기고 같은 구동을 부름 · 재료 고름은 rerun 고리
- M02: 새 _Sangeun_Tabs.ts(수식 · 단가산출 상세 보기 고르기) · Detail 의 시험 계산 · 인력/재료/경비 걷음
- 걷은 파일(git rm): M01 LogicLab_Sheet · Logic_Sheet_Model · LogicLab_Formula(.ts .css) · FormulaTokens · LogicLab_Est / M02 Sangeun_Sheet(.ts .css) · SheetEdit · SheetFormula · logic Sheet_Model · Const_Model · Formula_Model · 부품 시트 줄(createCalcSheetRow) · Cover_Model.sheetGroups · Calc 의 M02 고리(answer · shown · held) · 죽은 CSS · 글
- 서버 길 · 문서 칸(변수줄 · 글줄 · 덮개)은 남김 — 금액 그대로
- 낡은 시험: test_790_1(시트 화면 전부) · test_714_3 · test_748_2(+ helper)는 화면이 없어져 지움 · test_794_1 · test_790_2 는 화면 · TS 거울만 덜고 서버 시험 남김 · test_714_2 · 717_2b · 721 · 722(원래 틀린 한 줄 고침) · 734 · 737 · 750_2 · 793_3b · 796_2 · 796_2b 는 보기 둘 · 새 파일에 맞춤 · 새 test_799_2_old_tabs_gone

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 12:57:30 +09:00
co-authored by Claude Sonnet 5.5
parent d13b5b1753
commit c9bdf6d891
49 changed files with 389 additions and 3817 deletions
+11 -21
View File
@@ -1,6 +1,7 @@
/* =============================================================================
* M01_MasterData_UI_LogicLab.ts
* 관리자 화면 — 로직 칸. 로직을 열면 두 칸 — 왼쪽 시트(794-1 · 식 고침 = 관리자) · 단가산출 상세 · 시험 계산 / 오른쪽 원문
* 관리자 화면 — 로직 칸. 로직을 열면 두 칸 — 왼쪽 수식(796-2) · 단가산출 상세 / 오른쪽 원문
* 셈 = 화면 칸 없이 구동(`_Logic_Drive.ts` · 799-2) — 열 때 첫 셈 · 입력 · 시험 고침 · 재료 고름이 `drive.run`
* 로직 목록(원문·장 고르기 · 찾기 · 막힘 거름)은 왼쪽 패널 「단가산출 로직」 컨테이너(`listHost`) 안
*
* 데이터 흐름(CLAUDE.md 5장) — 고친 것은 캐시(sessionStorage)에 쌓고 [저장] 한 번에 `POST /save`.
@@ -30,7 +31,7 @@ import {
type LogicSummary,
type SaveFile,
} from "./M01_MasterData_UI_Logic_Api";
import { buildCalc } from "./M01_MasterData_UI_Logic_Calc";
import { createDrive } from "./M01_MasterData_UI_Logic_Drive";
import { buildList, logicId, type ListItem, type ListMark } from "./M01_MasterData_UI_Logic_List";
import type { SideHandle } from "./M01_MasterData_UI_Side";
import { tx } from "./M01_MasterData_UI_Logic_Text";
@@ -42,7 +43,6 @@ import { askBasicInfo } from "./M01_MasterData_UI_LogicLab_Basic";
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";
@@ -106,12 +106,7 @@ export async function mountM01LogicLab(
.then((u) => u?.role === "SYSTEM_ADMIN")
.catch(() => false);
let recalc: number | undefined;
const sheet = createLabSheet({
admin,
onInput: (name, v) => opened && syncLeft(opened, { [name]: v }),
edited: () => (touch(), drawEditor(), drawCalc()), // 줄 사본 → 캐시 → [저장](기존 길)
run: () => calc.querySelector<HTMLButtonElement>("[data-calc-run]")?.click(),
});
const drive = createDrive();
const equation = createLabEquation({
admin,
blocked: () => !!opened && opened.id in drafts, // 시트 · 시험 쪽 저장 안 한 고침이 있으면 수식 고침 잠김
@@ -128,7 +123,6 @@ export async function mountM01LogicLab(
className: "ui-banner ui-banner--danger m01-logic__reasons",
attrs: { hidden: "" },
});
const calc = el("div", { className: "m01lab__calc" });
const filterHost = el("div");
const waiting = el("p", { className: "ui-text ui-text--muted", text: tx("Loading") });
let loaded = false; // 첫 목록이 오기 전에는 「0 줄」 빈 표를 보이지 않음
@@ -189,7 +183,7 @@ export async function mountM01LogicLab(
if (isOwnLogic(opened.row)) {
// 자체 로직 — 고친 뒤 잠시 쉬면 다시 계산해 호표 소계와 텍스트 수식을 새로 그림
window.clearTimeout(recalc);
recalc = window.setTimeout(() => calc.querySelector("button")?.click(), 700);
recalc = window.setTimeout(() => void drive.run(), 700);
}
};
@@ -201,18 +195,18 @@ export async function mountM01LogicLab(
row,
});
/** 구동을 지금 로직으로 (다시) 시작 — 읽는 식 미리보기 · 첫 셈 */
const drawCalc = (): void => {
if (!opened?.row) {
calc.replaceChildren();
drive.stop();
return;
}
const current = opened;
calcInputs = JSON.stringify(calcRow(current.row)?.입력 ?? []);
buildCalc(calc, {
drive.start({
savedKey: current.origKey,
file: current.file,
row: calcRow(current.row) as LogicRow,
prices: current.prices,
dirty: () => current.id in drafts || trial.has(current.row),
values: current.values,
onText: (text) => {
@@ -220,7 +214,6 @@ export async function mountM01LogicLab(
if (text.ok) current.lastText = text;
drawEditor(); // [계산] 전 미리보기 응답도 왼쪽 이름 식 칸에 바로 비치게 — onLines 와 같은 자리
},
onAnswer: sheet.answer,
onLines: (lines) => {
current.lines = lines;
drawEditor();
@@ -253,23 +246,20 @@ export async function mountM01LogicLab(
onChange: touch,
onInput: (name, v) => syncLeft(current, { [name]: v }),
onTrial: drawCalc,
calcHost: calc,
rerun: () => void drive.run(),
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);
};
/** 텍스트 수식 · 시트 입력 → 왼쪽 시험 계산 입력(이름이 있는 칸만) · 바뀌면 다시 그리고 셈 */
/** 단가산출 상세 옵션 입력 → 시험 입력(이름이 있는 칸만) · 바뀌면 다시 셈 */
const syncLeft = (o: Opened, ex: Record<string, string>): void => {
const names = new Set((calcRow(o.row)?.입력 ?? []).map((x) => x.이름));
const changed = Object.entries(ex).filter(([k, v]) => names.has(k) && o.values[k] !== v);
if (!changed.length) return;
for (const [k, v] of changed) o.values[k] = v;
drawCalc();
calc.querySelector<HTMLButtonElement>("[data-calc-run]")?.click();
void drive.run();
};
const show = (next: Opened | null): void => {
@@ -1,8 +1,8 @@
/* =============================================================================
* M01_MasterData_UI_LogicLab_Detail.ts
* 「단가산출 로직」 상세 화면 — 컨테이너를 위에서 아래로(수식 탭 796-2 포함)
* ① 기본정보(정본은 읽기 전용 · 비고만 고침) ② 시험 계산(입력값 + 결과값 — `Logic_Calc` 그대로)
* ③ 단가산출 상세(공용 부품 `_LogicLab_Ho.ts` — 하위 박스 · 계산식 · 박스 옵션 · 그 자리 고침) ④ 텍스트 수식(`_LogicLab_Formula.ts`)
* ① 기본정보(정본은 읽기 전용 · 비고만 고침) ② 수식(796-2) ③ 단가산출 상세(공용 부품 `_LogicLab_Ho.ts` — 하위 박스 · 계산식 · 박스 옵션 · 그 자리 고침)
* 시트 · 인력/재료/경비 · 시험 계산 · 텍스트 수식 보기는 799-2 에 걷음 — 셈은 화면 칸 없이 `_Logic_Drive.ts` 구동
* 그 자리 고침(722-2) = 시험 값만(`_LogicLab_Cover.ts` trial — 줄 사본으로 셈 · 정본 · 요소 · 캐시 안 바뀜)
* ========================================================================== */
@@ -11,15 +11,16 @@ import type { LogicRow } from "./M01_MasterData_UI_Logic_Api";
import { buildEditor } from "./M01_MasterData_UI_Logic_Edit";
import { openPicker } from "./M01_MasterData_UI_Logic_Pick";
import { safeReason, tx } from "./M01_MasterData_UI_Logic_Text";
import { buildFormula } from "./M01_MasterData_UI_LogicLab_Formula";
import { buildEst } from "./M01_MasterData_UI_LogicLab_Est";
import { buildHo, lineOpener, type HoContext, type HoEdit } from "./M01_MasterData_UI_LogicLab_Ho";
import { trial } from "./M01_MasterData_UI_LogicLab_Cover";
import { tl } from "./M01_MasterData_UI_LogicLab_Text";
import "./M01_MasterData_UI_LogicLab_Style.css";
import "./M01_MasterData_UI_LogicLab_Pill.css";
import { block } from "./M01_MasterData_UI_LogicLab_View";
import { revisionText } from "./logic/M01_Logic_Sheet_Model";
/** 원문 절 머리 개정 연도 글 — [2008, 2020, 2025] → 「'08 · '20 · '25」(없으면 빈 글) */
const revisionText = (row: LogicRow): string =>
(row.개정 ?? []).map((y) => `'${String(y).slice(-2)}`).join(" · ");
export { trial };
@@ -29,16 +30,12 @@ export interface DetailContext extends HoContext {
/** 자체 로직 편집기 자리 — 다시 그려도 같은 칸을 씀(고치던 값이 안 날아감) */
ownHost: HTMLElement;
onChange: () => void;
/** 시험 계산 칸(`buildCalc` 가 채움) — 둘째 컨테이너 안에 놓음 */
calcHost: HTMLElement;
/** 시스템 관리자 — 기본 로직 안내 글이 다름(시트에서 고침 / 보기만) */
admin?: boolean;
/** 722-2 시험 고침 뒤 — 시험 계산 칸을 고친 줄로 다시 그림(있으면 그 자리 고침 켜짐 · 셈은 여기서 누름) */
/** 722-2 시험 고침 뒤 — 구동을 고친 줄로 다시 시작(있으면 그 자리 고침 켜짐 · 셈은 `rerun`) */
onTrial?: () => void;
/** 717-2b M02 단가산출 — 단가산출 상세 · 인력·재료·경비 덩이를 이 칸으로(SK 셈 답 · 고침 표) */
cover?: { ho: HTMLElement; est: HTMLElement; open?: ReturnType<typeof lineOpener> };
/** 794-1 M01 줄 식 시트(`_LogicLab_Sheet`) 그릇 — 있으면 「시트」 덩이(M02 는 안 넘김) */
sheet?: HTMLElement;
/** 717-2b M02 단가산출 — 단가산출 상세 덩이를 이 칸으로(SK 셈 답 · 고침 표) */
cover?: { ho: HTMLElement; open?: ReturnType<typeof lineOpener> };
/** 796-2 수식 탭(`_LogicLab_Equation`) 그릇 — 있으면 「수식」 덩이(M02 는 795-1 사본 꼴 뒤에 이음) */
equation?: HTMLElement;
}
@@ -189,7 +186,7 @@ function trialEdit(ctx: DetailContext): HoEdit | undefined {
if (cover) covers[idx] = cover;
else delete covers[idx];
redraw();
ctx.calcHost.querySelector<HTMLButtonElement>("[data-calc-run]")?.click();
ctx.rerun?.();
},
};
}
@@ -220,34 +217,19 @@ function hoBox(ctx: DetailContext): HTMLElement {
return block(section(tl("Ho_Title"), blocks, hint), "ho");
}
/** 상세 화면 넷을 `host` 에 쌓음 */
/** 상세 화면(기본정보 · 수식 · 단가산출 상세)을 `host` 에 쌓음 */
export function buildLabDetail(host: HTMLElement, ctx: DetailContext): void {
const formula = el("div", { className: "m01lab__formula" });
const fx = { inputs: ctx.row.입력, values: ctx.values, onInput: ctx.onInput };
// 748-2 텍스트 수식 변수 · 값 누름 = 상세 줄과 같은 모달(M02 = 그 상세가 준 열기 · M01 = 시험 고침 고리)
const open = ctx.cover ? ctx.cover.open : lineOpener({ ...ctx, edit: trialEdit(ctx) });
buildFormula(formula, { text: ctx.text, ...fx, open });
// 다시 그리면 포커스가 빠짐 — 고치던 칸(편집기 · 기본정보)을 기억해 되돌림
const active = document.activeElement;
const kept =
active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement
? { node: active, from: active.selectionStart, to: active.selectionEnd }
: null;
// 737-3 단가산출 상세 · 텍스트 수식 두 보기 상자 제목 같음 · 「지금 입력」 꼬리표 없음
// 보기 고르기(`_View.ts`) — 덩이마다 이름표 · 고른 덩이만 보임 · 시험 계산은 숨어도 그려 둠
// 보기 고르기(`_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(
section(tl("View_Est"), [
ctx.cover?.est ?? buildEst({ text: ctx.text, unit: ctx.row.결과단위, ...fx }),
]),
"est",
),
ctx.cover ? block(section(tl("Ho_Title"), [ctx.cover.ho]), "ho") : hoBox(ctx),
block(section(tl("Ho_Title"), [formula]), "formula"),
);
const own = kept?.node.dataset.own; // 기본정보 칸은 새로 만들어지므로 이름표로 다시 찾음
const back = own ? host.querySelector<HTMLInputElement>(`[data-own="${own}"]`) : kept?.node;
@@ -1,46 +0,0 @@
/* =============================================================================
* M01_MasterData_UI_LogicLab_Est.ts
* 「인력·재료·경비」 보기(PLAN 714-2) — 공용 단가산출 계산서(`A00_Common/calc_sheet.ts`)에 시험 계산 읽는 식(`/text`)을 그대로.
* 인력 · 재료 · 경비(장비) 묶음(714-1 줄 덧칸 `묶음`) · 묶음 머리 변수 칸 = 줄들의 `쓰는입력` · 바꾸면 시험 계산 입력 고침 + 다시 셈(정본 안 바뀜)
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
import { createText } from "@ui/ui_template_text";
import { calcSheetRows } from "../A00_Common/calc_sheet";
import type { LogicInput } from "./M01_MasterData_UI_Logic_Api";
import { safeReason } from "./M01_MasterData_UI_Logic_Text";
import type { PreviewTextAnswer } from "./M01_MasterData_UI_LogicLab_Formula";
import { tl } from "./M01_MasterData_UI_LogicLab_Text";
export interface EstContext {
text: PreviewTextAnswer | null;
unit: string;
inputs: LogicInput[];
values: Record<string, string>;
onInput?: (name: string, value: string) => void;
}
export function buildEst(ctx: EstContext): HTMLElement {
const host = el("div", { className: "m01lab-est", attrs: { "data-est": "" } });
const text = ctx.text;
if (!text || (text.ok && text.계산됨 === false)) {
host.append(createText(tl("Formula_Slot"), "muted"));
return host;
}
if (!text.ok) {
host.append(createText(safeReason(text.reason), "error"));
return host;
}
host.append(
...calcSheetRows({
묶음: text.groups,
계: text.계,
sumLabel: `${tl("Total")} (${ctx.unit || "원"})`,
bundle: true,
inputs: ctx.inputs,
values: ctx.values,
onInput: ctx.onInput,
}),
);
return host;
}
@@ -1,57 +0,0 @@
/* M01 로직 개선 시험 — 텍스트 수식 두 칸(왼쪽 이름 식 · 오른쪽 값 식) */
.m01lab-fx {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
column-gap: var(--spacing-12);
min-width: 0;
}
.m01lab-fx__head,
.m01lab-fx__row {
display: grid;
grid-template-columns: subgrid;
grid-column: 1 / -1;
}
.m01lab-fx__head {
padding-bottom: var(--spacing-4);
border-bottom: 1px solid var(--color-border);
}
.m01lab-fx__wide {
grid-column: 1 / -1;
}
/* 748-2 한 줄 = 변수 이름 · 값을 하나하나 누름(행 고르기 없음) */
.m01lab-fx__toks,
.m01lab-fx__val {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-4);
}
.m01lab-fx__cell {
min-width: 0;
padding: var(--spacing-4);
}
/* 714-3 줄이 쓰는 입력 — 누르면 그 자리 고르기/글 칸 */
.m01lab-fx__vars {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-4);
margin-top: var(--spacing-4);
}
.m01lab-fx__var .ui-field {
min-width: 6rem;
}
@media (max-width: 600px) {
.m01lab-fx,
.m01lab-fx__head,
.m01lab-fx__row {
grid-template-columns: minmax(0, 1fr);
}
}
@@ -1,237 +0,0 @@
/* =============================================================================
* M01_MasterData_UI_LogicLab_Formula.ts
* 「텍스트 수식」 컨테이너 — 서버(`POST /text`)가 준 줄을 그대로 찍음(화면에서 다시 계산하지 않음)
* 두 칸 — 왼쪽 = 변수 이름 식(`이름글`) · 오른쪽 = 값이 들어간 식(`글`) · 줄은 `짝` 번호로 같은 높이
* 한쪽에 올리거나 누르면 같은 줄이 밝아짐 · 비목 제목 · 소계 · 끝수 한 마디 · 까닭 있는 줄은 옅게
* 737-2 — 비목(노무비 · 재료비 · 경비)마다 구분 상자 · 맨 위 옵션 고르기(그 상자 줄이 쓰는 입력 · 서버 `쓰는입력` · 없으면 표 찾기 행 값 · 이름 조각 + 어느 줄도 안 쓰는 공통 입력 · 6 → 4 → 3 → 2 → 1 열) →
* 바꾸면 `onInput`(시험 계산 입력 고침 + 다시 셈) · 정본은 안 바뀜(요구서 4-1 확정 8)
* ========================================================================== */
import { createButton, el } from "@ui/ui_template_elements";
import { createCard } from "@ui/ui_template_card";
import { createInputField, createSelectField } from "@ui/ui_template_input";
import type { LogicInput, TextAnswer, TextLine } from "./M01_MasterData_UI_Logic_Api";
import { optionGrid, varRow } from "../A00_Common/calc_sheet";
import { formatMoney } from "./M01_MasterData_UI_Logic_Money";
import { safeReason, tx } from "./M01_MasterData_UI_Logic_Text";
import { tl } from "./M01_MasterData_UI_LogicLab_Text";
import { buildFoot, FOOT_KEYS, FOOT_OF, type FootKey } from "./M01_MasterData_UI_LogicLab_Foot";
import { nameTokens, tokenRow, valueTokens } from "./M01_MasterData_UI_LogicLab_FormulaTokens";
import "./M01_MasterData_UI_LogicLab_Formula.css";
/** 로직 열자마자 값 없이 부른 미리보기 응답은 `계산됨` 을 false 로 표시 — [계산] 을 거친 응답은 안 붙어 true 취급 */
export type PreviewTextAnswer = TextAnswer & { 계산됨?: boolean };
export interface FormulaContext {
text: PreviewTextAnswer | null;
/** 714-3 — 시험 계산 입력 · 지금 값 · 고친 뒤 할 일(값 넣고 다시 셈) · 없으면 단추 없음 */
inputs?: LogicInput[];
values?: Record<string, string>;
onInput?: (name: string, value: string) => void;
/** 748-2 줄 → 그 줄 자료 모달 열기(변수 · 값을 누르면) — 없으면 글자만 */
open?: (line: TextLine) => (() => void) | undefined;
}
/* ── 순수 셈: 줄이 쓰는 입력 ── */
/** 줄의 조각에서 시험 입력 이름 → 그 줄이 쓴 값 — 표 찾기 행 열쇠 · 이름 조각(`값` 이 붙은 이름) · 차례 = 처음 나온 차례 */
export function usedInputs(
line: Pick<TextLine, "조각" | "요소조각">,
names: string[],
): [string, string][] {
const out = new Map<string, string>();
for (const p of [...(line.조각 ?? []), ...(line.요소조각 ?? [])]) {
const row = p.kind === "표" ? p.참조?.행 : null;
if (row && typeof row === "object")
for (const [k, v] of Object.entries(row))
if (names.includes(k) && !out.has(k)) out.set(k, String(v));
if (p.kind === "값" && p.값 !== undefined && names.includes(p.글) && !out.has(p.글))
out.set(p.글, String(p.값));
}
return [...out];
}
/* ── 순수 셈 끝 ── */
/** 입력 단추 하나 — 누르면 그 자리에 고르기(고르기 있는 입력) · 글 칸 · 바꾸면 `onInput` · 722-1 계산식 칸도 씀 */
export function inputChip(
spec: LogicInput,
shown: string,
onInput: (v: string) => void,
): HTMLElement {
const wrap = el("span", { className: "m01lab-fx__var", attrs: { "data-fx-var": spec.이름 } });
const chip = createButton({
label: `${spec.이름} ${shown}${spec.단위 ? ` ${spec.단위}` : ""}`,
variant: "ghost",
size: 20,
tone: "option",
onClick: (ev) => {
ev.stopPropagation(); // 줄 누름(밝히기 고정)과 안 겹치게
let over = false; // Enter 뒤 blur · 고름 뒤 blur 가 두 번 부르지 않게
const done = (v: string | null): void => {
if (over) return;
over = true;
if (v !== null && v.trim() !== "" && v !== shown) onInput(v.trim());
else wrap.replaceChildren(chip);
};
let field: HTMLElement;
if (spec.고르기?.length) {
const pick = createSelectField({
options: spec.고르기.map((o) => ({ value: String(o), text: String(o) })),
value: shown,
size: 24,
ariaLabel: spec.이름,
onChange: (v) => done(v),
});
pick.select.addEventListener("blur", () => done(null));
field = pick.root;
} else {
const box = createInputField({ value: shown, size: 24 });
box.input.setAttribute("aria-label", spec.이름);
box.input.addEventListener("keydown", (k) => {
if (k.isComposing) return;
if (k.key === "Enter") done(box.input.value);
if (k.key === "Escape") (k.preventDefault(), done(null));
});
box.input.addEventListener("blur", () => done(box.input.value));
field = box.root;
}
field.addEventListener("click", (e) => e.stopPropagation());
wrap.replaceChildren(field);
field.querySelector<HTMLElement>("select, input")?.focus();
},
});
chip.title = `${tl("Var_Tone")} · ${tl("Formula_Var")}`;
wrap.append(chip);
return wrap;
}
const muted = (text: string): HTMLElement => el("p", { className: "ui-text ui-text--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: "ui-banner ui-banner--danger m01-logic__reasons",
children: [
el("strong", { text: tx("Calc_Stopped") }),
el("div", { text: safeReason(text.reason) }),
],
}),
);
return;
}
const heads = (): HTMLElement =>
el("div", {
className: "m01lab-fx__head",
children: [
el("strong", { text: tl("Formula_Left") }),
el("strong", { text: tl("Formula_Right") }),
],
});
const specs = ctx.onInput ? (ctx.inputs ?? []) : [];
// 737-2 옵션 칸 = 비목 상자 맨 위 — 그 상자 줄이 쓰는 입력 + 어느 줄도 안 쓰는 입력(공통 · 737-3)
const names = specs.map((x) => x.이름);
const usedBy = (line: TextLine): string[] =>
line.쓰는입력
? line.쓰는입력.filter((n) => names.includes(n))
: usedInputs(line, names).map(([n]) => n);
const everyUsed = new Set(text.groups.flatMap((g) => g.줄.flatMap(usedBy)));
const common = names.filter((n) => !everyUsed.has(n));
const options = (g: (typeof text.groups)[number]): HTMLElement[] => {
const onInput = ctx.onInput;
if (!onInput) return [];
const mine = new Set([...g.줄.flatMap(usedBy), ...common]);
return optionGrid(
specs
.filter((x) => mine.has(x.이름))
.map((x) => varRow(x, ctx.values?.[x.이름] ?? "", (v) => onInput(x.이름, v))),
);
};
const calculated = text.계산됨 !== false; // [계산] 뒤 응답은 표식이 안 붙음 — 미리보기만 false
const onInput = ctx.onInput;
/** 변수 이름이 시험 입력이면 옵션 고르기 · 아니면 그 줄 모달 */
const spec = (name: string): LogicInput | undefined =>
onInput ? specs.find((x) => x.이름 === name) : undefined;
// 737-2 비목마다 구분 상자 — 맨 위 옵션 칸 · 아래 두 칸 식 줄들 · 소계
const boxes = text.groups.map((g) => {
const visible = g.줄.filter((l) => !l.옵션꺼짐); // 753-2 옵션으로 빠진 줄은 안 그림
const lines = visible.map((line, i) => {
const pair = String(line.짝 ?? `${g.비목}${i}`);
const open = ctx.open?.(line);
return el("div", {
className: `m01lab-fx__row m01lab__text${line.까닭 ? " m01lab__text--off" : ""}`,
attrs: { "data-text-line": line.이름, "data-pair": pair },
children: [
el("div", {
className: "m01lab-fx__cell",
children: [
tokenRow(nameTokens(line.이름글 ?? line.이름), open, (name) => {
const s = spec(name);
return s && onInput
? inputChip(s, ctx.values?.[name] ?? "", (v) => onInput(name, v))
: null;
}),
],
}),
el("div", {
className: "m01lab-fx__cell",
children: calculated
? [
el("div", {
className: "m01lab-fx__val",
children: [
el("span", { text: `${line.이름} :` }),
tokenRow(valueTokens(line.글), open),
],
}),
...(line.까닭
? [
el("div", {
className: "ui-text ui-text--muted",
text: safeReason(line.까닭),
}),
]
: []),
]
: [el("div", { className: "ui-text ui-text--muted", text: tl("Formula_Slot") })],
}),
],
});
});
const card = createCard({
title: g.비목,
pad: 12,
body: [
...options(g),
el("div", {
className: "m01lab-fx",
children: [
heads(),
...lines,
el("p", {
className: "m01lab__subtotal-text m01lab-fx__wide",
attrs: { "data-text-sub": String(g.소계) },
text: `${g.비목} ${tl("Subtotal")} ${formatMoney(g.소계)}${g.끝수 ? ` (${g.끝수})` : ""}`,
}),
],
}),
],
});
card.classList.add("m01lab__ho-box");
card.dataset.fxBox = g.비목;
return card;
});
// 748-3 맨 아래 비목 소계 고정 줄(노무비 · 재료비 · 경비 — 계 칸 없음 · 상자 소계는 그대로)
const sums = Object.fromEntries(
FOOT_KEYS.map((k) => [k, text.groups.find((g) => FOOT_OF[g.비목] === k)?.소계 ?? 0]),
) as Record<FootKey, number>;
const divide =
text.나눔 && text.나눔 !== 1
? [muted(`${tl("Formula_Divide")} ÷ ${text.나눔}`)] // M02 750-1 단위 환산 — 식은 바탕 단위 그대로
: [];
host.replaceChildren(...divide, ...boxes, buildFoot(sums, calculated));
}
@@ -1,71 +0,0 @@
/* =============================================================================
* M01_MasterData_UI_LogicLab_FormulaTokens.ts
* 텍스트 수식 한 줄 글 → 누를 수 있는 조각(PLAN 748-2) —
* 왼쪽 변수 이름 식(`이름글`) = 변수 이름 하나하나 · 오른쪽 값 든 식(`글`) = 값(수) 하나하나
* 누르면 그 줄 자료 모달(748-1) · 변수가 시험 입력(옵션)이면 옵션 고르기(`inputChip`)
* 조각 나누기는 순수 셈(화면 · fetch · sessionStorage 없음) — 칸 그리기는 `tokenRow` 만 화면.
* ========================================================================== */
import { createButton } from "@ui/ui_template_button";
import { el } from "@ui/ui_template_elements";
/* ── 순수 셈 ── */
export interface Tok {
text: string;
/** 눌러서 모달을 열 조각 */
hot: boolean;
}
const NAME_RE = /\s+|[*+/=()×÷,%:]|[^\s*+/=()×÷,%:]+/g;
/** 변수 이름 식 → 조각 — 글자(한글 · 영문)가 든 덩이 = 변수 이름(함수 이름 「이름(」 · 수 · 기호는 아님) · 빈칸은 버림 */
export function nameTokens(text: string): Tok[] {
const out: Tok[] = [];
for (const m of text.matchAll(NAME_RE)) {
const t = m[0];
if (/^\s+$/.test(t)) continue;
const next = text[(m.index ?? 0) + t.length];
out.push({ text: t, hot: /[가-힣A-Za-z]/.test(t) && next !== "(" });
}
return out;
}
/** 값 든 식 → 조각 — 수(콤마 · 소수점 든 것까지) 하나하나 = 값 · 나머지(기호 · 글)는 그대로 */
export function valueTokens(text: string): Tok[] {
const out: Tok[] = [];
let last = 0;
const plain = (s: string): void => {
const t = s.trim();
if (t) out.push({ text: t, hot: false });
};
for (const m of text.matchAll(/\d[\d,]*(?:\.\d+)?/g)) {
plain(text.slice(last, m.index));
out.push({ text: m[0], hot: true });
last = (m.index ?? 0) + m[0].length;
}
plain(text.slice(last));
return out;
}
/* ── 순수 셈 끝 ── */
/** 조각 한 줄 — `hot` 은 낮은 단추(요소 연결 갈래 색) · `make` 가 단추를 주면 그걸로(옵션 칩) · 아니면 글자 */
export function tokenRow(
toks: Tok[],
open: (() => void) | undefined,
make?: (text: string) => HTMLElement | null,
): HTMLElement {
return el("div", {
className: "m01lab-fx__toks",
children: toks.map((t) => {
if (!t.hot) return el("span", { text: t.text });
const own = make?.(t.text);
if (own) return own;
if (!open) return el("span", { text: t.text });
const b = createButton({ label: t.text, variant: "ghost", size: 20, tone: "link" });
b.classList.add("m01lab-fx__tok");
b.dataset.fxTok = t.text;
b.addEventListener("click", open);
return b;
}),
});
}
@@ -141,8 +141,8 @@ export interface HoContext {
values: Record<string, string>;
/** 박스 옵션 · 입력 칩 고침 — 시험 계산 입력 고침 + 다시 셈(없으면 고르기 · 칩 없음) */
onInput?: (name: string, value: string) => void;
/** 시험 계산 칸 — 재료 후보 고르기가 씀(없으면 후보만 보임) */
calcHost?: HTMLElement;
/** 지금 값으로 다시 셈(구동) — 재료 후보 고르기가 씀(없으면 후보만 보임) */
rerun?: () => void;
/** 그 자리 고침 고리(없으면 모달은 보기만) */
edit?: HoEdit;
/** (M02 750-2) Ho 줄 차례 → SK 줄 cid — 읽는 식 줄을 `cid` 로 이음(이름 차례 추측 안 함) */
@@ -278,8 +278,8 @@ function modalOf(e: Entry, ctx: HoContext, extra: { cond?: PickCond; item?: HoLi
usedSlot: line?.품목?.값칸,
...(item?.종류 === "재료" && ctx.pickMat
? { onPick: ctx.pickMat(e.idx) }
: item?.종류 === "재료" && ctx.calcHost
? { onPick: (it) => pickMaterial(ctx.calcHost!, ctx.values, e.idx, it) }
: item?.종류 === "재료" && ctx.rerun
? { onPick: (it) => pickMaterial(ctx.values, e.idx, it, ctx.rerun!) }
: {}),
// 덧줄(%) = M02 덮개만 율 고침(M01 시험 값은 호표 줄만)
...(edit && (e.extra ? edit.scope === "m02" : item)
@@ -1,322 +0,0 @@
/* =============================================================================
* 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"],
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);
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 });
},
};
}
@@ -35,10 +35,6 @@
grid-column: 1 / -1;
}
.m01lab__group {
margin: var(--spacing-8) 0 var(--spacing-4);
}
/* 칸 폭 고정(`table-layout: fixed`) — 인력·재료·경비 세 표가 저마다 딴 `<table>` 이라
내용 기준으로 폭이 갈리던 것을 막고 셋이 같은 폭으로 맞춰짐(일감35 이어 — 수량 칸 알약화). */
.m01lab__ho {
@@ -143,23 +139,6 @@
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;
}
/* 복사해서 만들기(3-4) — 별칭 꼴 식 시트 */
.ui-modal.m01copy__sheet {
width: min(1040px, 94vw);
@@ -198,13 +177,9 @@
font-weight: 600;
}
/* 보기 고르기(710-2 · `_View.ts`) — 그릇 data-view 와 다른 이름표 덩이는 숨김(시험 계산은 숨어도 돎) */
/* 보기 고르기(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"]),
.m01-logic__editor[data-view="calc"] [data-block]:not([data-block="calc"]),
.m01-logic__editor[data-view="formula"] [data-block]:not([data-block="formula"]) {
.m01-logic__editor[data-view="ho"] [data-block]:not([data-block="ho"]) {
display: none;
}
@@ -230,14 +205,6 @@
overflow: auto;
}
/* 714-2 「인력·재료·경비」 — 공용 계산서 줄을 세로로 */
.m01lab-est {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
min-width: 0;
}
/* 722-1 공용 단가산출 상세 — 하위 박스(카드) 안 · 옵션 줄 · 표 사이 간격 · 맨 아래 비목 소계 */
.m01lab__ho-box > .ui-card__body {
display: flex;
@@ -9,16 +9,16 @@ const TEXT = {
Info_Title: ["기본정보", "Basic info"],
New: ["신규 로직", "New logic"],
Info_Base: [
"기본 로직 — 식은 시트에서 고침(시스템 관리자) · 여기서는 비고만",
"Base logic — edit formulas in the sheet (system admin) · only the note here",
"기본 로직 — 식은 수식 탭에서 고침(시스템 관리자) · 여기서는 비고만",
"Base logic — edit formulas in the Equation tab (system admin) · only the note here",
],
Info_ReadOnly: [
"기본 로직 — 보기만 · 비고만 고칠 수 있음",
"Base logic — view only · only the note can be edited",
],
Info_Own: [
"자체 로직 — 칸을 고치면 잠시 뒤 호표 소계와 텍스트 수식이 다시 계산됨",
"Own logic — edits recalculate the subtotals and formula shortly",
"자체 로직 — 칸을 고치면 잠시 뒤 호표 소계가 다시 계산됨",
"Own logic — edits recalculate the subtotals shortly",
],
Edit_Title: [
"고치기 (호표 · 중간 값 · 덧줄 · 끝수)",
@@ -26,17 +26,9 @@ const TEXT = {
],
Ho_Fold: ["묶음 소계 표 (읽기용 — 고치기는 위 편집기)", "Group subtotals (read-only)"],
Ho_Title: ["단가산출 상세", "Unit-price detail"],
Formula_Title: ["텍스트 수식", "Text formula"],
Formula_Slot: ["시험 계산을 누르면 식이 글로 나옴", "Run the trial calc to see the formula"],
Formula_Divide: [
"식은 바탕 단위 기준 · 금액 · 소계 = 식 값",
"Formulas use the base unit · amounts and subtotals = formula value",
],
Calc_Title: ["시험 계산", "Trial calculation"],
View_Est: ["인력·재료·경비", "Labor·material·expense"],
View_Sheet: ["시트", "Sheet"],
View_Equation: ["수식", "Equation"],
Edit_SheetHint: ["식은 「시트」 보기에서 고침", "Edit formulas in the Sheet view"],
Edit_SheetHint: ["식은 「수식」 보기에서 고침", "Edit formulas in the Equation view"],
View_Label: ["보기", "View"],
G_labor: ["인력", "Labor"],
G_material: ["재료", "Material"],
@@ -55,20 +47,10 @@ const TEXT = {
Col_Amount: ["금액", "Amount"],
Empty_Group: ["줄 없음", "No lines"],
Not_Money: ["호표가 없는 로직 — 결과 식", "No unit-cost lines — result formula"],
Calc_First: [
"금액은 아래 「시험 계산」 을 누르면 채워짐",
"Amounts fill after the trial calculation",
],
Calc_First: ["금액은 셈이 끝나면 채워짐", "Amounts fill when the calculation finishes"],
Click_Hint: ["줄을 누르면 그 줄이 쓰는 자료가 뜸", "Click a line to see the data it uses"],
Modal_Close: ["닫기", "Close"],
Formula_Left: ["변수 이름 식", "Formula with names"],
Formula_Right: ["값이 들어간 식", "Formula with values"],
Formula_Var: [
"누르면 시험 계산 입력을 고침 · 다시 셈(정본 안 바뀜)",
"Click to change the trial input and recalculate (source unchanged)",
],
Pill_Tone: ["요소 연결", "Linked element"],
Var_Tone: ["옵션", "Option"],
Modal_Sum: ["한 줄 풀이", "In one line"],
Pill_Hint: ["쓰인 자료 — 누르면 펼침", "Data used — tap to open"],
Modal_UnitPrice: ["단가", "Unit price"],
@@ -1,9 +1,9 @@
/* =============================================================================
* M01_MasterData_UI_LogicLab_View.ts
* 「단가산출 로직」 상세 보기 고르기 — 머리줄 [신규 로직] 왼쪽 「분할 고르기 | 세로 나눔선」
* 보기 여섯 = 수식(기본 · 796-2) · 단가산출 상세 · 시트 · 인력·재료·경비 · 시험 계산 · 텍스트 수식 — 고른 덩이만 보임
* 보기 둘 = 수식(기본 · 796-2) · 단가산출 상세 — 고른 덩이만 보임(시트 · 인력·재료·경비 · 시험 계산 · 텍스트 수식은 799-2 에 걷음)
* 덩이 = `data-block` 이름표 · 숨김 = 그릇 `data-view` + CSS(`LogicLab_Style.css`) — 다시 그려도 고름 유지
* 시험 계산은 숨겨도 그려 둠(자동 계산이 숨은 칸에서도 돎) · 기본정보는 이름표 없음(늘 맨 위)
* 셈 구동은 화면 칸 없이 `_Logic_Drive.ts` · 기본정보는 이름표 없음(늘 맨 위)
* 기억 = 이 화면 안에서만(로직을 바꿔도 유지 · 새로 들어오면 수식)
* ========================================================================== */
@@ -11,7 +11,7 @@ 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 = "equation" | "sheet" | "ho" | "est" | "calc" | "formula";
export type LabView = "equation" | "ho";
/** 덩이에 보기 이름표를 붙여 돌려줌 */
export const block = <T extends HTMLElement>(node: T, view: LabView): T => {
@@ -26,10 +26,6 @@ export function createViewPicker(host: HTMLElement): { root: HTMLElement } {
options: [
{ 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: "equation",
size: 32,
+9 -87
View File
@@ -2,28 +2,25 @@
* M01_MasterData_UI_Logic_Calc.ts
* 시험 계산 — 받을 값을 넣고 [계산] → 줄별 금액 · 비목 합 · 멈춘 까닭(엔진 글 그대로)
* 고친 로직은 저장 전 줄(`row`)을 같이 보내 메모리에서 셈(`POST /calc`) — 파일에 안 씀
* 셈 구동(입력 → 서버 → 답) = `_Logic_Drive.ts` — 로직 상세는 이 칸 없이 그 구동만 씀(799-2) · 이 칸은 로직 복사 화면이 씀
* ========================================================================== */
import { createButton, createSelectField, el, showToast } from "@ui/ui_template_elements";
import {
fetchAuto,
pctView,
runCalc,
runText,
searchElements,
type CalcAnswer,
type CalcLine,
type ElementBrief,
type TextAnswer,
type LogicRow,
} from "./M01_MasterData_UI_Logic_Api";
import {
isMaterialKeyInput,
machineControl,
materialRows,
pickBody,
showItems,
} from "./M01_MasterData_UI_Logic_CalcPick";
import { autoFill, driveRun } from "./M01_MasterData_UI_Logic_Drive";
import { formatNumber } from "./M01_MasterData_UI_Logic_Edit";
import { formatLineMoney, formatMoney } from "./M01_MasterData_UI_Logic_Money";
import { safeReason, tx } from "./M01_MasterData_UI_Logic_Text";
@@ -52,12 +49,6 @@ async function keyName(key: string): Promise<string | null> {
return name;
}
/** (M02 750-2) SK 셈 답으로 그리는 시험 계산 — 답 + 호표 차례 줄(재료 칸 잡힌 품목 글용) */
export interface CalcShown {
answer: CalcAnswer;
items?: (CalcLine | undefined)[];
}
export interface CalcContext {
/** 저장된 키 — 새 로직은 null */
savedKey: string | null;
@@ -70,16 +61,6 @@ export interface CalcContext {
/** 로직마다 넣은 값 — 다시 그려도 남음 */
values: Record<string, string>;
onLines?: (lines: CalcLine[] | null) => void;
/** 794-1 셈 답 통째(중간 값 · 소계) — M01 줄 식 시트 값 */
onAnswer?: (answer: CalcAnswer) => void;
/** 읽는 식 줄(`/text`)도 같이 받고 싶을 때 — 호출이 `onLines` 보다 먼저 */
onText?: (answer: TextAnswer) => void;
/** (M02 750-2) 있으면 [계산] = 이 셈(SK 셈 답 하나) · `/calc` · `/text` · 미리보기 안 부름 */
answer?: () => Promise<CalcShown | null>;
/** (M02 750-2) 이미 받은 SK 셈 답 — 그릴 때 바로 결과로 */
shown?: CalcShown | null;
/** (M02 750-2) 재료 고름 = SK 문서 `고름` 에도 */
held?: (i: number, it: { ref: string; label: string }) => void;
}
const grid = (children: HTMLElement[]): HTMLElement =>
@@ -87,12 +68,9 @@ const grid = (children: HTMLElement[]): HTMLElement =>
const SUMS = ["노무비", "재료비", "경비", "계"];
/** 로직마다 자동값을 한 번만 받음(다시 그려도 안 부름) */
/** 로직마다 첫 셈을 한 번만(다시 그려도 안 부름) */
const autoAsked = new WeakSet<Record<string, string>>();
/** 로직을 열 때마다 새로 부르는 미리보기 — 늦게 온 응답이 새 로직을 덮지 않게 순번으로 거름 */
let previewSeq = 0;
export function buildCalc(host: HTMLElement, ctx: CalcContext): void {
// 그릇 폭 280px 고정 칸은 옛 테스트 컨테이너(UI_Test*, 걷힘)의 죽은 CSS 규칙 —
// 지금 쓰는 LogicLab 화면은 그 칸에 안 걸려 폭이 이미 넓음(실측 730px) · 따로 안 풂
@@ -147,78 +125,23 @@ export function buildCalc(host: HTMLElement, ctx: CalcContext): void {
children: [el("span", { text: label }), control],
});
});
const materials = materialRows(ctx.row, ctx.prices ?? {}, ctx.values, again, ctx.held);
const materials = materialRows(ctx.row, ctx.prices ?? {}, ctx.values, again);
const out = el("div", { className: "m01-logic__calc-out" });
const mySeqAtOpen = ctx.onText && !ctx.answer ? previewSeq + 1 : previewSeq; // 이 그리기가 받을 순번
let manualRun = false; // [계산] 이 이미 답했으면 뒤늦게 오는 미리보기로 안 덮음
if (ctx.onText && !ctx.answer) {
const mySeq = ++previewSeq;
const draft = ctx.dirty() || ctx.savedKey === null;
const body = {
key: ctx.savedKey ?? ctx.row.키,
inputs: {},
...(draft ? { row: ctx.row, file: ctx.file } : {}),
};
void runText(body).then((answer) => {
if (manualRun || mySeq !== previewSeq) return; // [계산] 을 눌렀거나 다른 로직이 이미 열림
(answer as TextAnswer & { 계산됨?: boolean }).계산됨 = false;
ctx.onText?.(answer);
});
}
if (!fields.length && !autoAsked.has(ctx.values)) {
autoAsked.add(ctx.values); // 입력이 없으면 채울 것이 없음 — 열 때 한 번 바로 계산
queueMicrotask(() => {
if (previewSeq === mySeqAtOpen) again();
});
queueMicrotask(again);
} else if (ctx.savedKey !== null && !autoAsked.has(ctx.values)) {
autoAsked.add(ctx.values);
void fetchAuto(ctx.savedKey).then((auto) => {
const names = (ctx.row.입력 ?? [])
.map((spec) => spec.이름)
.filter((n) => auto[n] !== undefined && auto[n] !== null && !(ctx.values[n] ?? "").trim());
if (!names.length || previewSeq !== mySeqAtOpen) return; // 값이 없거나 다른 로직이 이미 열림
for (const n of names) ctx.values[n] = String(auto[n]);
buildCalc(host, ctx); // 채운 값을 새로 그리고 바로 계산(결과 · 텍스트 수식까지)
void autoFill(ctx).then((filled) => {
if (!filled) return;
buildCalc(host, ctx); // 채운 값을 새로 그리고 바로 계산
again();
});
}
const money = (ctx.row.결과단위 ?? "").startsWith("원");
const paint = (s: CalcShown): void => {
out.replaceChildren(...answerView(s.answer, false, money));
if (s.answer.ok) showItems(host, ctx.values, s.items ?? s.answer.lines ?? []);
};
const run = async (): Promise<void> => {
manualRun = true;
if (ctx.answer) {
try {
const got = await ctx.answer();
if (got) paint(got);
} catch (error) {
showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error");
}
return;
}
const inputs: Record<string, unknown> = {};
for (const spec of ctx.row.입력 ?? []) {
const raw = (ctx.values[spec.이름] ?? "").trim();
if (raw === "") continue; // 빈 칸은 안 보냄 — 엔진이 「입력 없음」 으로 멈춤
// 고르기는 원래 값(수면 수) 그대로 — 글 "35" 로 보내면 「고르기 밖」
const option = spec.고르기?.find((o) => String(o) === raw);
inputs[spec.이름] =
option !== undefined ? option : Number.isNaN(Number(raw)) ? raw : Number(raw);
}
const draft = ctx.dirty() || ctx.savedKey === null;
try {
const body = {
key: ctx.savedKey ?? ctx.row.키,
inputs,
...pickBody(ctx.values),
...(draft ? { row: ctx.row, file: ctx.file } : {}),
};
const answer = await runCalc(body);
if (ctx.onText) ctx.onText(await runText(body));
ctx.onAnswer?.(answer);
ctx.onLines?.(answer.ok ? (answer.lines ?? null) : null);
const { answer, draft } = await driveRun(ctx);
if (answer.ok && answer.lines) showItems(host, ctx.values, answer.lines);
out.replaceChildren(...answerView(answer, draft, money));
} catch (error) {
@@ -240,7 +163,6 @@ export function buildCalc(host: HTMLElement, ctx: CalcContext): void {
runButton,
out,
);
if (ctx.shown) paint(ctx.shown);
}
/** 중간 값 이름이 돈 꼴(경비 · 노무비 · 공구손료 · 금액 · 단가 · 노임) — 1,000 미만이어도 돈 꼴 */
@@ -25,17 +25,15 @@ const picksOf = (values: Record<string, string>): Map<number, Chosen> => {
return mine;
};
/** 재료 고름(시험 입력 · 저장 안 함) — 시험 계산 칸(`host`)의 자재 글을 바꾸고 다시 셈 · 상세 표 줄 모달이 씀 */
/** 재료 고름(시험 입력 · 저장 안 함) — 고른 뒤 `again`(다시 셈) · 상세 표 줄 모달이 씀 */
export function pickMaterial(
host: HTMLElement,
values: Record<string, string>,
i: number,
it: Chosen,
again: () => void,
): void {
picksOf(values).set(i, it);
const link = host.querySelector<HTMLElement>(`[data-mat-idx="${i}"]`);
if (link) link.textContent = it.label;
host.querySelector<HTMLButtonElement>("[data-calc-run]")?.click();
again();
}
/** 계산 요청 몸의 `고름` — 고른 것이 없으면 안 실음 */
@@ -76,8 +74,6 @@ export function materialRows(
prices: Record<string, ElementBrief | null>,
values: Record<string, string>,
again: () => void,
/** (M02 750-2) 고르면 SK 문서 `고름`에 — 줄 차례 → 고른 재료 · 없으면 시험 입력(M01) */
sink?: (i: number, it: Chosen) => void,
): HTMLElement[] {
const mine = picksOf(values);
return (row.호표 ?? [])
@@ -119,7 +115,6 @@ export function materialRows(
onPick: (it) => {
mine.set(i, it);
current.textContent = it.label;
sink?.(i, it);
again();
},
});
@@ -0,0 +1,136 @@
/* =============================================================================
* M01_MasterData_UI_Logic_Drive.ts
* 셈 구동(PLAN 799-2) — 화면 없이 「입력값 → 서버 셈 → 답」을 돌림. 시험 계산 칸(`Logic_Calc`)과 로직 상세(`LogicLab`)가 같이 씀.
* `driveRun` = 한 번 셈(`/calc` + `/text`) · `previewText` = 열 때 읽는 식 미리보기 · `autoFill` = 자동값 채움
* `createDrive` = 로직 상세용 묶음 — 열 때 미리보기 + 첫 셈(입력 없으면 바로 · 있으면 자동값 채운 뒤) · `run` 은 칸 없이 다시 셈
* 셈 자리 = 서버(CLAUDE.md 5 장 ③) · 화면 · sessionStorage 없음 · 고른 재료(`CalcPick`) 는 값 객체에 딸림
* ========================================================================== */
import { showToast } from "@ui/ui_template_elements";
import {
fetchAuto,
runCalc,
runText,
type CalcAnswer,
type CalcLine,
type LogicRow,
type TextAnswer,
} from "./M01_MasterData_UI_Logic_Api";
import { isMaterialKeyInput, pickBody } from "./M01_MasterData_UI_Logic_CalcPick";
import { tx } from "./M01_MasterData_UI_Logic_Text";
export interface DriveContext {
/** 저장된 키 — 새 로직은 null */
savedKey: string | null;
/** 저장 전 새 초안이면 비움 — 서버가 임시 자리에 얹어 셈 */
file?: string;
row: LogicRow;
dirty: () => boolean;
/** 로직마다 넣은 값 — 다시 그려도 남음 */
values: Record<string, string>;
onLines?: (lines: CalcLine[] | null) => void;
/** 읽는 식 줄(`/text`)도 같이 받고 싶을 때 — 호출이 `onLines` 보다 먼저 */
onText?: (answer: TextAnswer) => void;
}
/** 칸에 든 값 → 엔진 입력 — 빈 칸은 안 보냄(엔진이 「입력 없음」 으로 멈춤) · 고르기는 원래 값 그대로 */
function inputsOf(row: LogicRow, values: Record<string, string>): Record<string, unknown> {
const inputs: Record<string, unknown> = {};
for (const spec of row.입력 ?? []) {
const raw = (values[spec.이름] ?? "").trim();
if (raw === "") continue;
const option = spec.고르기?.find((o) => String(o) === raw); // 글 "35" 로 보내면 「고르기 밖」
inputs[spec.이름] =
option !== undefined ? option : Number.isNaN(Number(raw)) ? raw : Number(raw);
}
return inputs;
}
const draftBody = (ctx: DriveContext): { row?: LogicRow; file?: string } =>
ctx.dirty() || ctx.savedKey === null ? { row: ctx.row, file: ctx.file } : {};
/** 한 번 셈 — 답을 `onText` → `onLines` 차례로 알리고 돌려줌(`draft` = 저장 전 고침으로 셈) */
export async function driveRun(
ctx: DriveContext,
inputs = inputsOf(ctx.row, ctx.values),
): Promise<{ answer: CalcAnswer; draft: boolean }> {
const draft = ctx.dirty() || ctx.savedKey === null;
const body = {
key: ctx.savedKey ?? ctx.row.키,
inputs,
...pickBody(ctx.values),
...draftBody(ctx),
};
const answer = await runCalc(body);
if (ctx.onText) ctx.onText(await runText(body));
ctx.onLines?.(answer.ok ? (answer.lines ?? null) : null);
return { answer, draft };
}
/** 열 때 읽는 식 미리보기 — [계산] 전에도 이름 식 칸이 비치게 · 늦게 온 응답은 `live()` 로 거름 */
export function previewText(ctx: DriveContext, live: () => boolean): void {
if (!ctx.onText) return;
const body = { key: ctx.savedKey ?? ctx.row.키, inputs: {}, ...draftBody(ctx) };
void runText(body).then((answer) => {
if (!live()) return;
(answer as TextAnswer & { 계산됨?: boolean }).계산됨 = false;
ctx.onText?.(answer);
});
}
/** 자동값으로 빈 칸 채움 — 채웠으면 true */
export async function autoFill(ctx: DriveContext): Promise<boolean> {
if (ctx.savedKey === null) return false;
const auto = await fetchAuto(ctx.savedKey);
const names = (ctx.row.입력 ?? [])
.map((spec) => spec.이름)
.filter((n) => auto[n] !== undefined && auto[n] !== null && !(ctx.values[n] ?? "").trim());
for (const n of names) ctx.values[n] = String(auto[n]);
return names.length > 0;
}
/** 로직마다 첫 셈을 한 번만(다시 그려도 안 부름) */
const first = new WeakSet<Record<string, string>>();
/** 로직 상세용 구동 — `start` = 로직을 열거나 입력 칸이 바뀔 때 · `run` = 지금 값으로 다시 셈 */
export function createDrive(): {
start: (ctx: DriveContext) => void;
run: () => Promise<void>;
stop: () => void;
} {
let seq = 0; // 로직을 바꾸면 늦게 온 응답을 거름
let now: DriveContext | null = null;
let ran = false; // 셈 답이 이미 왔으면 뒤늦은 미리보기로 안 덮음
const run = async (): Promise<void> => {
const ctx = now;
if (!ctx) return;
ran = true;
try {
await driveRun(ctx);
} catch (error) {
showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error");
}
};
const start = (ctx: DriveContext): void => {
now = ctx;
ran = false;
const mine = ++seq;
previewText(ctx, () => !ran && mine === seq);
if (first.has(ctx.values)) return;
if (!(ctx.row.입력 ?? []).some((spec) => !isMaterialKeyInput(spec))) {
first.add(ctx.values); // 입력이 없으면 채울 것이 없음 — 열 때 한 번 바로 셈
queueMicrotask(() => mine === seq && void run());
} else if (ctx.savedKey !== null) {
first.add(ctx.values);
void autoFill(ctx).then((filled) => filled && mine === seq && void run());
}
};
return {
start,
run,
stop: () => {
now = null;
seq++;
},
};
}
@@ -1,163 +0,0 @@
/* =============================================================================
* M01_MasterData/logic/M01_Logic_Sheet_Model.ts
* M01 로직 줄 식 시트 꼴(PLAN 794-1 · 요구 650~652 · 요구서_652 1 장 6) — M02 단가산출 시트와 같은 꼴
* 「이름 | 식 | 값 | 비목」 · 줄 갈래 = 근거(출처 · 개정 · 비고 · 끝수) · 옵션(입력) · 계산(중간) · 자원(호표) ·
* 율(덧줄) · 단계 · 소계 · 합계 · 식 글 = 서버 이름 꼴(`/logic/sheet` · 없으면 엔진 식 「*」 → 「×」)
* 값 = 시험 계산 답(줄 금액 = 호표 → 덧줄 차례 · 중간 값 · 비목 소계)
* 고침 = 줄 사본 식 칸 하나(`setLabFormula`) — 저장은 화면이 기존 M01 [저장] 길로
* 짝 = 서버 `M01_MasterData_Sheet.py`(자리 = 갈래 · 차례 꼴만 같음) · 화면 · fetch · sessionStorage 없음
* ========================================================================== */
import type {
CalcAnswer,
CalcLine,
LogicInput,
LogicRow,
NamedFormula,
} from "../M01_MasterData_UI_Logic_Api";
export type LabPart = "중간" | "호표" | "덧줄";
export type LabKind = "근거" | "옵션" | "계산" | "자원" | "율" | "단계" | "소계" | "합계";
/** 줄마다 식 칸 이름(갈래 → 칸) — 서버 `FIELD` 와 같음 */
export const FIELD: Record<LabPart, "식" | "수량"> = { 중간: "식", 호표: "수량", 덧줄: "식" };
export interface LabNames {
중간: string[];
호표: string[];
덧줄: string[];
이름표?: { 글: string; 갈래: string }[];
}
export interface LabItem {
kind: LabKind;
이름: string;
/** 이름 꼴 식 글 · 빈 글 = 식 없음 */
식: string;
값: number | string | null;
/** 비목 줄임(노 · 재 · 경) */
비목: string;
/** 식 칸 자리(중간 · 호표 · 덧줄) */
part?: LabPart;
at?: number;
input?: LogicInput;
/** 금액번지(791-1) */
번지?: string;
/** 저장본과 식이 다름 */
changed: boolean;
}
const SHORT: Record<string, string> = { 노무비: "노", 재료비: "재", 경비: "경" };
const short = (v: unknown): string => (typeof v === "string" ? (SHORT[v] ?? v) : "");
/** 엔진 식 → 보기 글(이름 꼴이 아직 없을 때) */
export const plain = (s: string): string => s.replace(/\s*\*\s*/g, " × ");
export const spotKey = (part: LabPart, at: number): string => `${part}#${at}`;
/** 줄 식 칸 값(엔진 식) */
export function formulaAt(row: LogicRow, part: LabPart, at: number): string {
const list = (row[part] ?? []) as unknown as Record<string, unknown>[];
return String(list[at]?.[FIELD[part]] ?? "");
}
/** 줄 식 칸 하나를 엔진 식으로 바꿈(줄 객체 그대로 고침) · 없는 자리 = false */
export function setLabFormula(row: LogicRow, part: LabPart, at: number, engine: string): boolean {
const list = row[part] as unknown as Record<string, unknown>[] | undefined;
if (!list || at < 0 || at >= list.length) return false;
list[at][FIELD[part]] = engine;
return true;
}
/** 끝수 글 — 글자면 그대로 · 묶음이면 「대상 방법 자리」 */
export function cutText(rule: unknown): string {
if (rule == null || rule === "") return "";
if (typeof rule === "string") return rule;
const r = rule as Record<string, unknown>;
return [r.대상, r.방법, r.자리 != null ? `${String(r.자리)} 자리` : ""]
.filter((x) => x !== undefined && x !== "")
.join(" · ");
}
const valueOf = (v: unknown): number | string | null =>
typeof v === "number" || typeof v === "string" ? v : null;
/** 원문 절 머리 개정 연도 글 — [2008, 2020, 2025] → 「'08 · '20 · '25」(794-2b · 없으면 빈 글) */
export const revisionText = (row: LogicRow): string =>
(row.개정 ?? []).map((y) => `'${String(y).slice(-2)}`).join(" · ");
/** 시트 줄 — 근거 · 옵션 · 계산 · 자원 · 율 · 단계 · 소계 · 합계 */
export function labItems(o: {
row: LogicRow;
/** 저장본 줄(새 로직 = null) — 식이 다르면 고친 줄 */
original: LogicRow | null;
values: Record<string, string>;
names: LabNames | null;
answer: CalcAnswer | null;
}): LabItem[] {
const { row, original } = o;
const out: LabItem[] = [];
const item = (x: Partial<LabItem> & { kind: LabKind; 이름: string }): void =>
void out.push({ 식: "", 값: null, 비목: "", changed: false, ...x });
if (row.출처) item({ kind: "근거", 이름: row.원문번호 || "", 식: row.출처 });
if (row.개정?.length) item({ kind: "근거", 이름: "개정", 식: revisionText(row) });
for (const line of (row.비고 ?? "").split(/\r?\n/).filter((x) => x.trim()))
item({ kind: "근거", 이름: "", 식: line.trim() });
for (const input of row.입력 ?? [])
item({ kind: "옵션", 이름: input.이름, 값: o.values[input.이름] ?? "", input });
const ok = o.answer?.ok ? o.answer : null;
const mid = ok?.middle ?? {};
const shown = (part: LabPart, at: number): string =>
o.names?.[part]?.[at] ?? plain(formulaAt(row, part, at));
const changed = (part: LabPart, at: number): boolean =>
!!original && formulaAt(original, part, at) !== formulaAt(row, part, at);
const spot = (part: LabPart, at: number) => ({
part,
at,
식: shown(part, at),
changed: changed(part, at),
});
(row.중간 ?? []).forEach((f: NamedFormula, at) =>
item({ kind: "계산", 이름: f.이름, 값: valueOf(mid[f.이름]), ...spot("중간", at) }),
);
const ho = row.호표 ?? [];
const extra = row.덧줄 ?? [];
const lines: (CalcLine | undefined)[] =
ok?.lines && ok.lines.length === ho.length + extra.length ? ok.lines : [];
ho.forEach((h, at) => {
const b = (h as { 번지?: string }).번지;
item({
kind: "자원",
이름: h.단위 ? `${h.이름 ?? ""} (${h.단위})` : (h.이름 ?? ""),
값: lines[at]?.금액 ?? null,
비목: short(h.비목),
...(b ? { 번지: b } : {}),
...spot("호표", at),
});
});
extra.forEach((x, at) => {
const b = (x as { 번지?: string }).번지;
item({
kind: "율",
이름: x.이름,
값: lines[ho.length + at]?.금액 ?? null,
비목: short(x.비목),
...(b ? { 번지: b } : {}),
...spot("덧줄", at),
});
});
const steps = (row.단계 ?? []) as { 이름?: string; 줄?: unknown }[];
for (const s of Array.isArray(steps) ? steps : [])
item({
kind: "단계",
이름: String(s.이름 ?? ""),
식: Array.isArray(s.줄) ? s.줄.map(String).join(" + ") : "",
});
const cut = cutText(row.끝수);
if (cut) item({ kind: "근거", 이름: "끝수", 식: cut });
const sums = ok?.sums ?? null;
for (const k of Object.keys(SHORT))
if (sums && k in sums) item({ kind: "소계", 이름: k, 값: sums[k], 비목: SHORT[k] });
if (sums && "계" in sums) item({ kind: "합계", 이름: "계", 값: sums.계 });
return out;
}