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:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -17,18 +17,6 @@
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.m02-sk__cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.m02-sk__cell .ui-input {
|
||||
width: 9em;
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
.m02-sk__tags,
|
||||
.m02-sk__basehead,
|
||||
.m02-sk__removed,
|
||||
@@ -56,16 +44,6 @@
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
/* 고친 칸 — B09 고친 칸과 같은 꼴(색) · 곁 글 = 바탕 값 */
|
||||
.m02-sk__cover td.is-edited {
|
||||
background: color-mix(in srgb, var(--color-warning) 22%, var(--color-canvas));
|
||||
}
|
||||
|
||||
.m02-sk__total {
|
||||
text-align: end;
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
/* 740-2 이름 + 출처 꼬리표 한 줄 — 이름이 말줄임으로 줄고 꼬리표는 그대로 */
|
||||
.m02-sk .m01lab__namerow {
|
||||
display: flex;
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Sangeun_Detail.ts
|
||||
* M02 단가산출(SK) 상세(PLAN 717-2b · 724-1 · 요구서 4장 3 · 4 · 13 · 17 · 24) — M01 「단가산출 로직」 상세 부품을 M02 길로.
|
||||
* 보기 넷(`LogicLab_View`) = 단가산출 상세(공용 상세 `LogicLab_Ho` · SK 셈 답 줄 = `hoView` · 줄 모달 그 자리 고침 =
|
||||
* HoEdit → SK 덮개 · 더한줄 · 줄 빼기 · 별도계상) · 인력·재료·경비(공용 계산서 · SK 셈 답) ·
|
||||
* 시험 계산(`Logic_Calc` · 바탕 M01 로직 + 덮개 품 · 단가 사본) · 텍스트 수식(같은 사본 식) · 기본정보 = 바탕 로직(맨 위 접힘)
|
||||
* 보기 둘(`_Sangeun_Tabs` · 799-2) = 수식(796-2b) · 단가산출 상세(공용 상세 `LogicLab_Ho` · SK 셈 답 줄 = `hoView` · 줄 모달 그 자리 고침 =
|
||||
* HoEdit → SK 덮개 · 더한줄 · 줄 빼기 · 별도계상) · 기본정보 = 바탕 로직(맨 위 접힘) — 시트 · 인력·재료·경비 · 시험 계산 · 텍스트 수식은 걷음
|
||||
* 고침 = SK 덮개 · 더한줄(`Sangeun_Pick`) · 뺀줄(꼴 = 서버 `M02_Sangeun_Doc`) · 값 = 서버 셈 길 최신 응답만
|
||||
* 옵션 칸 = SK `입력값`(727-3 · 저장 · 열린입력만 시험 값) · [저장] = 화면 문서 → 서버가 다시 셈(원값 · 바탕판)
|
||||
* 둘째 기초(727-2) = 서버 `펼침` 세부 줄 · 인력 · 재료 · 경비 상자에 함께 + 꼬리표(기초 둘 이상) · 세부 덮개 ·
|
||||
@@ -19,9 +18,7 @@ import { el } from "@ui/ui_template_elements";
|
||||
import { createInputField } from "@ui/ui_template_input";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import { calcSheetRows } from "../A00_Common/calc_sheet";
|
||||
import { swapUnit } from "../A00_Common/master_unit_swap";
|
||||
import { buildCalc, type CalcShown } from "../M01_MasterData/M01_MasterData_UI_Logic_Calc";
|
||||
import {
|
||||
fetchLogic,
|
||||
setLogicApiPaths,
|
||||
@@ -38,7 +35,7 @@ import { baseHeads } from "./M02_MasterTemplete_Sangeun_BaseHead";
|
||||
import { calcSangeun, removedStrip } from "./M02_MasterTemplete_Sangeun_Cover";
|
||||
import { openSangeunLinePicker } from "./M02_MasterTemplete_Sangeun_Pick";
|
||||
import { openRateLine } from "./M02_MasterTemplete_Sangeun_Rate";
|
||||
import { createSangeunSheet } from "./M02_MasterTemplete_Sangeun_Sheet";
|
||||
import { createSangeunTabs } from "./M02_MasterTemplete_Sangeun_Tabs";
|
||||
import { createSangeunSide, tabOf, type SourceTab } from "./M02_MasterTemplete_Sangeun_Side";
|
||||
import { baseUnitOf, buildUnitSwap, type SwapHow } from "./M02_MasterTemplete_Sangeun_Unit";
|
||||
import {
|
||||
@@ -52,7 +49,6 @@ import {
|
||||
rowsOf,
|
||||
setApart,
|
||||
setLineCover,
|
||||
sheetGroups,
|
||||
type CoverRow,
|
||||
type HoView,
|
||||
type SkAnswer,
|
||||
@@ -90,7 +86,6 @@ export function mountSangeunDetail(
|
||||
let lastText: TextAnswer | undefined;
|
||||
let ans: SkAnswer | null = null;
|
||||
let bare: SkAnswer | null = null;
|
||||
let okKey = ""; // 마지막으로 잘 받은 셈의 입력(문서 + 시험 값) — 같으면 [계산] 이 다시 안 셈
|
||||
let fail = "";
|
||||
let extra: LogicInput[] = []; // 더한줄 로직 입력 중 바탕에 없는 것 = 옵션 칸(725-6 · 727-3)
|
||||
const inputsOf = new Map<string, LogicInput[]>();
|
||||
@@ -103,11 +98,12 @@ export function mountSangeunDetail(
|
||||
const head = el("div", { className: "m02-sk__head", attrs: { "data-sk-head": "" } });
|
||||
const notes = el("div");
|
||||
const editor = el("div", { className: "m01-logic__editor" });
|
||||
const sheet = createSangeunSheet(editor, {
|
||||
...{ edit, opened: () => opened(), touch: () => touch(), onInput: (n, v) => onInput(n, v) },
|
||||
...{ addLine: () => addButton.click(), addRate: () => rateButton.click(), scope }, // 790-1 · 792 식 검사 층
|
||||
const tabs = createSangeunTabs(editor, {
|
||||
edit,
|
||||
scope,
|
||||
touch: () => touch(),
|
||||
onInput: (n, v) => onInput(n, v),
|
||||
});
|
||||
const calcHost = el("div", { className: "m01lab__calc" });
|
||||
const ownHost = el("div");
|
||||
const addButton = createButton({
|
||||
label: L("M02_SangeunAddLine"),
|
||||
@@ -169,8 +165,8 @@ export function mountSangeunDetail(
|
||||
children: [
|
||||
head,
|
||||
notes,
|
||||
el("div", { className: "m02-sk__head", children: [sheet.picker, addButton, rateButton] }),
|
||||
sheet.host,
|
||||
el("div", { className: "m02-sk__head", children: [tabs.picker, addButton, rateButton] }),
|
||||
tabs.host,
|
||||
editor,
|
||||
],
|
||||
});
|
||||
@@ -227,21 +223,18 @@ export function mountSangeunDetail(
|
||||
else drawHead();
|
||||
};
|
||||
|
||||
const inputKey = (): string => JSON.stringify([doc, values]);
|
||||
|
||||
async function recalc(): Promise<void> {
|
||||
const mine = ++seq;
|
||||
await syncExtra();
|
||||
if (mine !== seq || !alive) return;
|
||||
const plain = { ...doc, 덮개: {}, 뺀줄: [], 더한줄: [] };
|
||||
const key = inputKey();
|
||||
try {
|
||||
const [got, was] = await Promise.all([
|
||||
calcSangeun(scope, doc, values),
|
||||
doc.바탕 ? calcSangeun(scope, plain, values, false) : Promise.resolve(null), // 바탕 원값 = 보기 덧칸 없이
|
||||
]);
|
||||
if (mine !== seq || !alive) return; // 늦게 온 답 — 버림
|
||||
[ans, bare, fail, okKey] = [got, was, "", key];
|
||||
[ans, bare, fail] = [got, was, ""];
|
||||
text = got.글 ?? null; // 750-2 텍스트 수식 · 시험 계산 = 이 답 하나(바탕 로직 따로 셈 없음)
|
||||
if (text?.ok) lastText = text;
|
||||
} catch (error) {
|
||||
@@ -340,7 +333,7 @@ export function mountSangeunDetail(
|
||||
];
|
||||
const row = base ? { ...base.logic, 입력: [...(base.logic.입력 ?? []), ...extra] } : standIn();
|
||||
const view = ans ? hoView(ans, bare, base?.logic ?? null, doc) : null;
|
||||
sheet.draw({ doc, ans, bare, base: row, text, rows, view, values, removed, fail });
|
||||
tabs.draw({ doc, ans, base: row, values });
|
||||
const hoCtx = {
|
||||
row: view ? { ...row, 호표: view.호표, 덧줄: view.덧줄 } : row,
|
||||
prices: base?.prices ?? {},
|
||||
@@ -349,7 +342,6 @@ export function mountSangeunDetail(
|
||||
lastText,
|
||||
values,
|
||||
onInput,
|
||||
calcHost,
|
||||
edit: view && edit ? hoEdit(view, rows) : undefined,
|
||||
cids: view?.cids,
|
||||
pickMat: (idx: number) => pickMat(view?.cids[idx]),
|
||||
@@ -377,20 +369,6 @@ export function mountSangeunDetail(
|
||||
...buildHo(hoCtx),
|
||||
],
|
||||
});
|
||||
const est = el("div", {
|
||||
attrs: { "data-est": "" },
|
||||
children: ans
|
||||
? calcSheetRows({
|
||||
묶음: sheetGroups(rows),
|
||||
계: ans.기준?.계 ?? null,
|
||||
sumLabel: `${L("M02_SangeunTotal")} (${doc.단위 && doc.단위 !== "식" ? `원/${doc.단위}` : row.결과단위 || "원"})`,
|
||||
bundle: true,
|
||||
inputs: row.입력,
|
||||
values,
|
||||
onInput,
|
||||
})
|
||||
: [createText(L("M02_SangeunWait"))],
|
||||
});
|
||||
buildLabDetail(editor, {
|
||||
row: base?.logic ?? row,
|
||||
file: base?.file ?? "",
|
||||
@@ -402,18 +380,16 @@ export function mountSangeunDetail(
|
||||
values,
|
||||
ownHost,
|
||||
onChange: () => undefined,
|
||||
calcHost,
|
||||
onInput,
|
||||
cover: { ho, est, open: lineOpener(hoCtx) }, // 748-2 텍스트 수식 변수 · 값 누름 = 같은 줄 모달
|
||||
cover: { ho, open: lineOpener(hoCtx) },
|
||||
});
|
||||
drawCalc();
|
||||
side?.set([
|
||||
...(base ? [tabOf(base.logic)] : []),
|
||||
...extraKeys.flatMap((k) => (k === baseKey() ? [] : infoOf.has(k) ? [infoOf.get(k)!] : [])),
|
||||
]);
|
||||
}
|
||||
|
||||
/** 재료 고름(줄 모달 · 시험 계산 재료 칸) = SK 문서 `고름`(cid → MT) — 둘째 기초 세부 줄은 보기만 */
|
||||
/** 재료 고름(줄 모달) = SK 문서 `고름`(cid → MT) — 둘째 기초 세부 줄은 보기만 */
|
||||
function pickMat(cid: string | undefined): ((it: { ref: string }) => void) | undefined {
|
||||
if (!cid || cid.startsWith("add:#")) return undefined;
|
||||
return (it) => {
|
||||
@@ -480,8 +456,7 @@ export function mountSangeunDetail(
|
||||
};
|
||||
}
|
||||
|
||||
/** 옵션 칸 · 텍스트 수식 · 계산서 입력 단추 — 입력 고침 → SK `입력값`(727-3 · 열린입력 = 시험 값만) ·
|
||||
* 시험 계산 다시 그림 · 셈 */
|
||||
/** 옵션 칸(수식 탭 · 단가산출 상세) — 입력 고침 → SK `입력값`(727-3 · 열린입력 = 시험 값만) · 셈 */
|
||||
function onInput(name: string, v: string): void {
|
||||
values[name] = v;
|
||||
if (edit && !(doc.열린입력 ?? []).includes(name)) {
|
||||
@@ -491,44 +466,7 @@ export function mountSangeunDetail(
|
||||
doc.입력값 = kept;
|
||||
touch(false);
|
||||
}
|
||||
void recalc(); // 750-2 옵션 바꿈 = 서버 셈 한 번 — 네 보기가 그 답 하나로 다시 그려짐
|
||||
}
|
||||
|
||||
function drawCalc(): void {
|
||||
if (!base) {
|
||||
calcHost.replaceChildren(createText(L("M02_SangeunNoInputs")));
|
||||
return;
|
||||
}
|
||||
const count = base.logic.호표?.length ?? 0;
|
||||
buildCalc(calcHost, {
|
||||
savedKey: base.logic.키,
|
||||
file: base.file,
|
||||
row: base.logic,
|
||||
prices: base.prices,
|
||||
dirty: () => false,
|
||||
values,
|
||||
answer: async () => {
|
||||
if (okKey !== inputKey()) await recalc(); // 칸을 고치고 [계산] — SK 셈 한 번
|
||||
return shownOf(count);
|
||||
},
|
||||
shown: shownOf(count),
|
||||
held: (i, it) => pickMat(bare?.정체[i])?.(it),
|
||||
});
|
||||
}
|
||||
|
||||
/** 시험 계산 칸에 그릴 것 = SK 셈 답(줄 · 기준 · 중간) */
|
||||
function shownOf(count: number): CalcShown | null {
|
||||
if (!ans) return fail ? { answer: { ok: false, reason: fail } } : null;
|
||||
const by = new Map(ans.줄.map((l) => [l.cid, l as unknown as CalcLine]));
|
||||
return {
|
||||
answer: {
|
||||
ok: true,
|
||||
lines: ans.줄 as unknown as CalcLine[], // 못 셈 줄 = 금액 「—」(계에 안 듦)
|
||||
sums: ans.기준 ?? undefined,
|
||||
middle: ans.중간 ?? {},
|
||||
},
|
||||
items: (bare?.정체 ?? []).slice(0, count).map((cid) => by.get(cid)),
|
||||
};
|
||||
void recalc(); // 750-2 옵션 바꿈 = 서버 셈 한 번 — 수식 · 단가산출 상세가 그 답 하나로 다시 그려짐
|
||||
}
|
||||
|
||||
/** `fresh` = 막 넣은 바탕 — 단위가 비거나 「식」이면 기초 기준단위(「100본」 · 바꾸면 서버가 환산 · 728-2) */
|
||||
@@ -563,7 +501,7 @@ export function mountSangeunDetail(
|
||||
if (one) one.바탕판 = l.바탕판; // 둘째 기초도 같음(727-2)
|
||||
}
|
||||
saved = JSON.stringify(doc);
|
||||
sheet.saved();
|
||||
tabs.saved();
|
||||
return clone(doc);
|
||||
},
|
||||
getValues: () => ({ ...values }), // 723-3 — 일위대가 줄 모달 [적용] = 옵션을 그 줄에 남김
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
/* M02 단가산출 줄 식 시트(790-1) — 줄 꼴은 부품 상자(calc_row 시트 줄) · 여기는 자리 · 덩이 머리 · 산출서 글만 */
|
||||
|
||||
.m02-sks {
|
||||
container-type: inline-size;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m02-sks[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.m02-sks__status,
|
||||
.m02-sks__amount,
|
||||
.m02-sks__edit,
|
||||
.m02-sks__cell {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.m02-sks__status {
|
||||
gap: var(--spacing-12);
|
||||
}
|
||||
|
||||
.m02-sks__amount .ui-text,
|
||||
.m02-sks__cell .ui-text {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.m02-sks__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.m02-sks__head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-8);
|
||||
margin-top: var(--spacing-12);
|
||||
padding-bottom: var(--spacing-4);
|
||||
border-bottom: 1px solid var(--color-border-strong);
|
||||
}
|
||||
|
||||
.m02-sks__head .ui-text {
|
||||
margin: 0;
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.m02-sks__mid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m02-sks__mid .ui-calc-formula {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.m02-sks__cell .ui-field {
|
||||
width: 8em;
|
||||
}
|
||||
|
||||
.m02-sks__paper {
|
||||
margin: 0;
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
color: var(--color-text-body);
|
||||
font-size: var(--text-body-sm);
|
||||
font-variant-numeric: tabular-nums;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.m02-sks__paper--sum {
|
||||
font-weight: var(--font-weight-bold);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.m02-sks__paper[data-paper="머리"] {
|
||||
margin-top: var(--spacing-8);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* 790-3 숫자 넓힘 — 식 속 숫자 · 표 값 칸 · 까닭 · 글 줄 */
|
||||
.m02-sks__formula,
|
||||
.m02-sks__find {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
font-size: var(--text-body-sm);
|
||||
font-weight: var(--font-weight-regular);
|
||||
color: var(--color-text-body);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.m02-sks__frag {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.m02-sks__num {
|
||||
width: 6.5em;
|
||||
}
|
||||
|
||||
.m02-sks__num[data-cover] {
|
||||
outline: 2px solid var(--color-warning);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.m02-sks__reason .ui-field {
|
||||
width: min(24em, 100%);
|
||||
}
|
||||
|
||||
.m02-sks__note .ui-field {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.m02-sks__paper--note {
|
||||
color: var(--color-text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* 792 식 칸 — 칸 + 검사 알림 한 줄 */
|
||||
.m02-sks__fx {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m02-sks__fx .ui-field {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.m02-sks__fx .ui-text:empty {
|
||||
display: none;
|
||||
}
|
||||
@@ -1,497 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Sangeun_Sheet.ts
|
||||
* M02 단가산출(SK) 줄 식 시트(PLAN 790-1 · E1 · 요구 650~652 · 요구서_652 1 장 4) — 상세 첫 탭 「시트」.
|
||||
* 보기 고르기 = 수식(첫 탭 · 796-2b `_Sangeun_Equation`) · 단가산출 상세 · 시트 · 옛 보기 셋(인력·재료·경비 · 시험 계산 · 텍스트 수식 — 뒤로만)
|
||||
* 시트 = 한 줄 「이름 | 식 | 값 | 비목」(줄 꼴 = `logic/M02_Sangeun_Sheet_Model`) · 안 보기 전환 「시트 ↔ 산출서」
|
||||
* 고침 = 지금 되는 것만(모델 안 바꿈) — 옵션 고름 · 품 · 단가 · 율 % · 줄 빼기 · 되살림 · [+ 자원 줄] · [+ 율 줄]
|
||||
* → SK 문서(덮개 · 더한줄 · 뺀줄 = `M02_Sangeun_Cover_Model`) → 상세 `touch` = 서버 셈 한 번(최신 응답만)
|
||||
* 상태줄 = M01 원래 · 지금 · 고치기 전(화면 캐시) + 차이 · 자동저장 없음([저장] = 상세 그대로)
|
||||
* 790-3 숫자 넓힘(식 속 상수 · 표값 · 글 줄 · 까닭 · [M01 원래로] · M01 바뀜) = `_Sangeun_SheetEdit`
|
||||
* 792 식 칸(줄 식 고침 · 칠 때마다 검사 · 변수 줄) = `_Sangeun_SheetFormula`
|
||||
* 부품 = `ui_template_calc_row` 시트 줄 · 입력 · 고르기 · 단추 · 배지 · 글 — 날 단추 · 입력 없음
|
||||
* ========================================================================== */
|
||||
|
||||
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 { createSegment } from "@ui/ui_template_segment";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import { createToolbar } from "@ui/ui_template_toolbar";
|
||||
import { formatLineMoney, formatMoney } from "../common_util/common_util_money";
|
||||
import type { LogicRow, TextAnswer } from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
|
||||
import { tl } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Text";
|
||||
import { removedStrip } from "./M02_MasterTemplete_Sangeun_Cover";
|
||||
import { createSangeunEquation } from "./M02_MasterTemplete_Sangeun_Equation";
|
||||
import {
|
||||
addTopNote,
|
||||
formulaEditor,
|
||||
lostBanner,
|
||||
noteRows,
|
||||
reasonCell,
|
||||
removedReasons,
|
||||
restoreAll,
|
||||
spotTail,
|
||||
} from "./M02_MasterTemplete_Sangeun_SheetEdit";
|
||||
import {
|
||||
addVarButton,
|
||||
formulaToggle,
|
||||
rowFormula,
|
||||
varRows,
|
||||
type FormulaCtx,
|
||||
} from "./M02_MasterTemplete_Sangeun_SheetFormula";
|
||||
import type { Layer } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { coverKinds, dropAddKeys, type ConstDoc } from "./logic/M02_Sangeun_Const_Model";
|
||||
import {
|
||||
dropLine,
|
||||
PART_OF,
|
||||
restoreLine,
|
||||
setLineCover,
|
||||
type CoverRow,
|
||||
type HoView,
|
||||
type LineCover,
|
||||
type SkAnswer,
|
||||
type SkDoc,
|
||||
} from "./logic/M02_Sangeun_Cover_Model";
|
||||
import {
|
||||
diffText,
|
||||
liftAddKeys,
|
||||
num,
|
||||
paperLines,
|
||||
sheetItems,
|
||||
spotsOf,
|
||||
statusOf,
|
||||
type SheetItem,
|
||||
type SheetKind,
|
||||
} from "./logic/M02_Sangeun_Sheet_Model";
|
||||
import "./M02_MasterTemplete_Sangeun_Sheet.css";
|
||||
|
||||
const TEXT = {
|
||||
Sheet: ["시트", "Sheet"],
|
||||
Paper: ["산출서", "Statement"],
|
||||
Mode: ["시트 보기", "Sheet view"],
|
||||
Origin: ["M01 원래", "M01 original"],
|
||||
Excel: ["엑셀 원래", "Excel original"], // 793-3b 가져온 식(셈 답 `가져온`) = 원래 = 엑셀 금액
|
||||
Now: ["지금", "Now"],
|
||||
Before: ["고치기 전", "Before edit"],
|
||||
근거: ["근거", "Basis"],
|
||||
옵션: ["옵션", "Options"],
|
||||
상수: ["변수 · 계산", "Variables"],
|
||||
자원: ["자원", "Resources"],
|
||||
율: ["율", "Rates"],
|
||||
소계: ["소계 · 합계", "Subtotals"],
|
||||
Qty: ["품", "Qty"],
|
||||
Price: ["단가", "Price"],
|
||||
Rate: ["율 %", "Rate %"],
|
||||
Drop: ["빼기", "Remove"],
|
||||
AddRes: ["+ 자원 줄", "+ Resource"],
|
||||
AddRate: ["+ 율 줄", "+ Rate line"],
|
||||
Table: ["표", "Table"],
|
||||
Hidden: ["조건 · 표 값", "Condition · table value"],
|
||||
Wait: ["셈 기다리는 중", "Calculating…"],
|
||||
} 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<SheetKind, keyof typeof TEXT> = {
|
||||
근거: "근거",
|
||||
옵션: "옵션",
|
||||
상수: "상수",
|
||||
표: "상수",
|
||||
계산: "상수",
|
||||
자원: "자원",
|
||||
율: "율",
|
||||
소계: "소계",
|
||||
합계: "소계",
|
||||
};
|
||||
|
||||
export interface SheetDraw {
|
||||
doc: SkDoc;
|
||||
ans: SkAnswer | null;
|
||||
bare: SkAnswer | null;
|
||||
base: LogicRow | null;
|
||||
text: TextAnswer | null;
|
||||
rows: CoverRow[];
|
||||
view: HoView | null;
|
||||
values: Record<string, string>;
|
||||
removed: { cid: string; 이름: string }[];
|
||||
fail: string;
|
||||
}
|
||||
|
||||
export interface SheetOptions {
|
||||
edit: boolean;
|
||||
opened: () => boolean;
|
||||
touch: () => void;
|
||||
onInput: (name: string, value: string) => void;
|
||||
addLine: () => void;
|
||||
addRate: () => void;
|
||||
/** 792 식 검사 길 층 */
|
||||
scope: { layer: Layer; owner: string | null };
|
||||
}
|
||||
|
||||
type View = "equation" | "sheet" | "ho" | "est" | "calc" | "formula";
|
||||
|
||||
/** 보기 고르기(시트 + 옛 넷) · 시트 그릇 — `editor` = 옛 보기 그릇(시트일 때 숨김) */
|
||||
export function createSangeunSheet(
|
||||
editor: HTMLElement,
|
||||
opt: SheetOptions,
|
||||
): { picker: HTMLElement; host: HTMLElement; draw: (d: SheetDraw) => void; saved: () => void } {
|
||||
const sheetHost = el("div", { className: "m02-sks", attrs: { "data-sk-sheet": "" } });
|
||||
const eqView = createSangeunEquation(opt); // 796-2b 수식 첫 탭
|
||||
const host = el("div", { children: [eqView.root, sheetHost] });
|
||||
const status = el("div", { className: "m02-sks__status", attrs: { "data-sk-status": "" } });
|
||||
const body = el("div", { className: "m02-sks__body" });
|
||||
let mode: "sheet" | "paper" = "sheet";
|
||||
let last: SheetDraw | null = null;
|
||||
let before: number | null = null; // 고치기 전 = 연 뒤 · 저장 뒤 첫 셈 계(화면 캐시)
|
||||
let ctx: FormulaCtx | null = null; // 790-3 숫자 넓힘 · 792 식 칸 — 그릴 때마다 새로
|
||||
const open = new Set<string>(); // 식 칸 연 자리
|
||||
const drafts = new Map<string, string>(); // 치는 중 식 글
|
||||
let focusNext = ""; // 새 글 줄 칸 — 다음 그림 뒤 초점
|
||||
const show = (v: View): void => {
|
||||
eqView.root.hidden = v !== "equation";
|
||||
sheetHost.hidden = v !== "sheet";
|
||||
editor.hidden = v === "sheet" || v === "equation";
|
||||
if (!editor.hidden) editor.dataset.view = v;
|
||||
};
|
||||
const picker = createSegment({
|
||||
options: [
|
||||
{ value: "equation", label: tl("View_Equation") },
|
||||
{ value: "ho", label: tl("Ho_Title") },
|
||||
{ value: "sheet", label: t("Sheet") },
|
||||
{ value: "est", label: tl("View_Est") },
|
||||
{ value: "calc", label: tl("Calc_Title") },
|
||||
{ value: "formula", label: tl("Formula_Title") },
|
||||
],
|
||||
value: "equation",
|
||||
size: 32,
|
||||
ariaLabel: tl("View_Label"),
|
||||
onChange: (v) => show(v as View),
|
||||
});
|
||||
picker.root.dataset.skViews = "";
|
||||
const inner = createSegment({
|
||||
options: [
|
||||
{ value: "sheet", label: t("Sheet") },
|
||||
{ value: "paper", label: t("Paper") },
|
||||
],
|
||||
value: mode,
|
||||
size: 28,
|
||||
ariaLabel: t("Mode"),
|
||||
onChange: (v) => {
|
||||
mode = v as typeof mode;
|
||||
if (last) draw(last);
|
||||
},
|
||||
});
|
||||
inner.root.dataset.skMode = "";
|
||||
sheetHost.append(
|
||||
createToolbar({ items: [inner.root, "divider", status], label: t("Mode") }),
|
||||
body,
|
||||
);
|
||||
show("equation");
|
||||
|
||||
function drawStatus(d: SheetDraw): void {
|
||||
const s = statusOf(d.ans, d.bare, before);
|
||||
const part = (key: string, label: string, v: number | null, diff = ""): HTMLElement =>
|
||||
el("span", {
|
||||
className: "m02-sks__amount",
|
||||
attrs: { "data-sk-amount": key },
|
||||
children: [
|
||||
createText(`${label} ${v == null ? "—" : formatMoney(v)}`, "muted", 14),
|
||||
...(diff ? [createBadge(diff, diff.startsWith("+") ? "warning" : "info")] : []),
|
||||
],
|
||||
});
|
||||
status.replaceChildren(
|
||||
part("origin", t(d.ans && "가져온" in d.ans ? "Excel" : "Origin"), s.원래),
|
||||
part("now", t("Now"), s.지금, diffText(s.지금, s.원래)),
|
||||
part("before", t("Before"), s.전, diffText(s.지금, s.전)),
|
||||
...(ctx ? restoreAll(ctx) : []),
|
||||
);
|
||||
}
|
||||
|
||||
/** 글 칸 — 바꾸면(Enter · 떠남) `onSet(글)` · 이름표 없으면 칸만 */
|
||||
const textCell = (
|
||||
key: string,
|
||||
label: string,
|
||||
v: string,
|
||||
onSet: (raw: string) => void,
|
||||
): HTMLElement => {
|
||||
const box = createInputField({ value: v, compact: true, size: 24 });
|
||||
box.input.setAttribute("aria-label", label);
|
||||
box.input.dataset.sheetCell = key;
|
||||
box.input.addEventListener("change", () => onSet(box.input.value.trim()));
|
||||
if (!label) return box.root;
|
||||
return el("span", {
|
||||
className: "m02-sks__cell",
|
||||
children: [createText(label, "muted"), box.root],
|
||||
});
|
||||
};
|
||||
|
||||
/** 숫자 칸 — 빈 칸 = null(그 칸 풂) · 못 읽으면 되돌림 */
|
||||
const numCell = (
|
||||
key: string,
|
||||
label: string,
|
||||
v: number | null,
|
||||
onSet: (x: number | null) => void,
|
||||
): HTMLElement => {
|
||||
const cell = textCell(key, label, v == null ? "" : String(Number(v.toFixed(6))), (raw) => {
|
||||
const x = raw ? Number(raw.replaceAll(",", "")) : null;
|
||||
if (x !== null && !Number.isFinite(x))
|
||||
return void (input.value = v == null ? "" : String(Number(v.toFixed(6))));
|
||||
onSet(x);
|
||||
});
|
||||
const input = cell.querySelector("input")!;
|
||||
input.inputMode = "decimal";
|
||||
return cell;
|
||||
};
|
||||
|
||||
/** 자원 · 율 줄 고침 — 덮개 한 칸 바꿈(빈 칸 = 그 칸 풂) → 상세 다시 셈 */
|
||||
function setCell(d: SheetDraw, r: CoverRow, key: keyof LineCover, x: number | null): void {
|
||||
const i = d.view?.cids.indexOf(r.cid) ?? -1;
|
||||
const cur: LineCover = { ...(i >= 0 ? d.view?.covers[i] : undefined) };
|
||||
if (x == null) delete cur[key];
|
||||
else cur[key] = x;
|
||||
setLineCover(d.doc, r, Object.keys(cur).length ? cur : null, opt.opened());
|
||||
opt.touch();
|
||||
}
|
||||
|
||||
function editLine(d: SheetDraw, r: CoverRow): HTMLElement[] {
|
||||
if (!opt.edit) return [];
|
||||
const parts = Object.keys(r.parts);
|
||||
const part = PART_OF[parts[0] ?? ""];
|
||||
const fx = coverKinds(d.doc as ConstDoc, r.cid).includes("식"); // 식 덮개 = 품 칸 안 둠(792)
|
||||
const cells = [
|
||||
...(fx
|
||||
? []
|
||||
: [
|
||||
numCell(`${r.cid}|qty`, r.pct ? t("Rate") : t("Qty"), r.수량, (x) =>
|
||||
setCell(d, r, "qty", x),
|
||||
),
|
||||
]),
|
||||
...(r.priceEdit && part && parts.length === 1
|
||||
? [numCell(`${r.cid}|price`, t("Price"), r.단가, (x) => setCell(d, r, part, x))]
|
||||
: []),
|
||||
];
|
||||
return [el("div", { className: "m02-sks__edit", children: cells })];
|
||||
}
|
||||
|
||||
function tailOf(d: SheetDraw, it: SheetItem, nums: boolean): HTMLElement {
|
||||
const r = it.row;
|
||||
const kids: HTMLElement[] = it.비목
|
||||
? [createBadge(it.비목, it.비목 === "별도" ? "warning" : "neutral")]
|
||||
: [];
|
||||
if (!nums) for (const tag of it.표) kids.push(createBadge(`${t("Table")} ${tag}`, "info"));
|
||||
if (ctx && fx(it)) kids.push(...formulaToggle(ctx, it.자리, ctx.spots?.자리[it.자리]));
|
||||
if (ctx) kids.push(...spotTail(ctx, it.자리, !!r && r.added !== null));
|
||||
if (r && opt.edit) {
|
||||
const drop = createButton({
|
||||
label: t("Drop"),
|
||||
variant: "danger-ghost",
|
||||
size: 22,
|
||||
onClick: () => {
|
||||
dropLine(d.doc, r);
|
||||
if (r.added !== null && !r.sub) apply(d, dropAddKeys(d.doc as ConstDoc, r.added));
|
||||
else opt.touch();
|
||||
},
|
||||
});
|
||||
drop.dataset.sheetDrop = r.cid;
|
||||
kids.push(drop);
|
||||
}
|
||||
return el("span", { children: kids });
|
||||
}
|
||||
|
||||
function optionCell(d: SheetDraw, it: SheetItem): HTMLElement {
|
||||
const spec = it.input!;
|
||||
const value = d.values[spec.이름] ?? "";
|
||||
if (!opt.edit) return createCalcFormula(value || "—");
|
||||
if (spec.고르기?.length) {
|
||||
const sel = createSelectField({
|
||||
options: [...(value ? [] : [""]), ...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;
|
||||
}
|
||||
return textCell(`opt|${spec.이름}`, "", value, (v) => opt.onInput(spec.이름, v));
|
||||
}
|
||||
|
||||
const valueOf = (it: SheetItem): HTMLElement => {
|
||||
const v = it.값;
|
||||
const text =
|
||||
typeof v === "number"
|
||||
? it.kind === "자원" || it.kind === "율"
|
||||
? formatLineMoney(v)
|
||||
: it.kind === "소계" || it.kind === "합계"
|
||||
? formatMoney(v)
|
||||
: num(v)
|
||||
: it.kind === "옵션"
|
||||
? (it.input?.단위 ?? "")
|
||||
: (v ?? (it.kind === "근거" ? "" : "—"));
|
||||
const key = it.kind === "소계" || it.kind === "합계";
|
||||
return el("span", { className: `ui-calc-row__out${key ? " is-key" : ""}`, text: String(text) });
|
||||
};
|
||||
|
||||
/** 식 칸 되는 줄 — 율 · 로직 더한줄 · 둘째 기초 세부 줄 뺌 */
|
||||
const fx = (it: SheetItem): boolean =>
|
||||
!!it.자리 && !(it.row?.sub || (it.row?.added != null && it.row.pct));
|
||||
|
||||
function rowOf(d: SheetDraw, it: SheetItem): HTMLElement[] {
|
||||
const own = ctx && fx(it) ? rowFormula(ctx, it.자리, ctx.spots?.자리[it.자리]) : null;
|
||||
const nums = ctx && it.자리 && !own ? formulaEditor(ctx, it.자리) : null;
|
||||
const shown = it.식 || (it.kind === "계산" ? t("Hidden") : "");
|
||||
const middle =
|
||||
it.kind === "옵션"
|
||||
? optionCell(d, it)
|
||||
: el("div", {
|
||||
className: "m02-sks__mid",
|
||||
children: [
|
||||
...(it.row || !nums ? [createCalcFormula(shown)] : []),
|
||||
...(nums ? [nums] : []),
|
||||
...(own ? [own] : []),
|
||||
...(it.row ? editLine(d, it.row) : []),
|
||||
...(ctx ? reasonCell(ctx, it.자리, it.changed) : []),
|
||||
],
|
||||
});
|
||||
const row = createCalcSheetRow(it.이름, middle, valueOf(it), tailOf(d, it, !!nums));
|
||||
row.dataset.sheetKind = it.kind;
|
||||
if (it.row) row.dataset.sheetCid = it.row.cid;
|
||||
if (it.자리) row.dataset.sheetSpot = it.자리;
|
||||
if (it.changed) row.classList.add("is-changed");
|
||||
const notes = ctx && it.자리 ? noteRows(ctx, (x) => x.자리 === it.자리) : [];
|
||||
const after = ctx && it.자리 ? varRows(ctx, (v) => v.자리 === it.자리) : [];
|
||||
return [row, ...notes, ...after];
|
||||
}
|
||||
|
||||
/** 790-3 — 새 문서로 바꿔 끼움(상세가 쥔 같은 객체) → 다시 셈 */
|
||||
function apply(d: SheetDraw, next: ConstDoc): void {
|
||||
const doc = d.doc as Record<string, unknown>;
|
||||
for (const k of Object.keys(doc)) if (!(k in next)) delete doc[k];
|
||||
Object.assign(doc, next);
|
||||
opt.touch();
|
||||
}
|
||||
|
||||
const head = (key: keyof typeof TEXT, extra: HTMLElement[] = []): HTMLElement =>
|
||||
el("div", {
|
||||
className: "m02-sks__head",
|
||||
attrs: { "data-sheet-section": key },
|
||||
children: [createText(t(key), "muted", 14), ...extra],
|
||||
});
|
||||
|
||||
function drawSheet(d: SheetDraw): HTMLElement[] {
|
||||
const items = sheetItems({
|
||||
base: d.base,
|
||||
inputs: d.base?.입력 ?? [],
|
||||
values: d.values,
|
||||
ans: d.ans,
|
||||
rows: d.rows,
|
||||
text: d.text,
|
||||
covered: (s) => coverKinds(d.doc as ConstDoc, s).length > 0,
|
||||
});
|
||||
const spots = new Set(items.map((it) => it.자리).filter(Boolean));
|
||||
const names = new Set(
|
||||
((d.doc as { 변수줄?: { 이름: string }[] }).변수줄 ?? []).map((v) => v.이름),
|
||||
);
|
||||
const varSpot = (s: string): boolean => s.startsWith("mid:") && names.has(s.slice(4));
|
||||
const out: HTMLElement[] = ctx ? lostBanner(ctx) : [];
|
||||
const add = (label: keyof typeof TEXT, click: () => void): HTMLElement[] =>
|
||||
opt.edit
|
||||
? [createButton({ label: t(label), variant: "ghost", size: 22, onClick: click })]
|
||||
: [];
|
||||
const order: (keyof typeof TEXT)[] = ["근거", "옵션", "상수", "자원", "율", "소계"];
|
||||
for (const sec of order) {
|
||||
const mine = items.filter((it) => SECTION[it.kind] === sec);
|
||||
const tools =
|
||||
sec === "자원"
|
||||
? add("AddRes", opt.addLine)
|
||||
: sec === "율"
|
||||
? add("AddRate", opt.addRate)
|
||||
: sec === "근거" && ctx
|
||||
? addTopNote(ctx)
|
||||
: sec === "상수" && ctx
|
||||
? addVarButton(ctx)
|
||||
: [];
|
||||
if (!mine.length && !tools.length) continue;
|
||||
out.push(head(sec, tools));
|
||||
if (sec === "자원")
|
||||
out.push(
|
||||
...removedStrip(d.removed, opt.edit, (cid) => revive(d, cid)),
|
||||
...(ctx ? removedReasons(ctx, d.removed) : []),
|
||||
);
|
||||
if (sec === "상수" && ctx)
|
||||
out.push(...varRows(ctx, (v) => !v.자리 || (!spots.has(v.자리) && !varSpot(v.자리))));
|
||||
for (const it of mine) out.push(...rowOf(d, it));
|
||||
if (sec === "근거" && ctx) out.push(...noteRows(ctx, (x) => !spots.has(x.자리)));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 뺀 줄 되살림 — 뺀 더한줄(`gone:N`)이면 까닭 · 글줄 열쇠를 그 차례부터 한 칸 밂 */
|
||||
function revive(d: SheetDraw, cid: string): void {
|
||||
const gone = /^gone:(\d+)$/.exec(cid);
|
||||
const at = gone ? d.doc.뺀더한줄?.[Number(gone[1])]?.차례 : undefined;
|
||||
restoreLine(d.doc, cid);
|
||||
if (at != null) apply(d, liftAddKeys(d.doc as ConstDoc, at));
|
||||
else opt.touch();
|
||||
}
|
||||
|
||||
function drawPaper(d: SheetDraw): HTMLElement[] {
|
||||
return paperLines(d.text, d.rows, d.ans, (d.doc as ConstDoc).글줄).map((p) => {
|
||||
const line = el("p", {
|
||||
className: `m02-sks__paper m02-sks__paper--${p.kind === "줄" ? "line" : p.kind === "글" ? "note" : "sum"}`,
|
||||
attrs: { "data-paper": p.kind },
|
||||
text: p.글,
|
||||
});
|
||||
return line;
|
||||
});
|
||||
}
|
||||
|
||||
function draw(d: SheetDraw): void {
|
||||
last = d;
|
||||
eqView.draw(d);
|
||||
if (before === null && d.ans?.기준?.계 != null) before = d.ans.기준.계;
|
||||
ctx = {
|
||||
doc: d.doc as ConstDoc,
|
||||
edit: opt.edit,
|
||||
spots: spotsOf(d.ans),
|
||||
apply: (next) => apply(d, next),
|
||||
focus: (key) => void (focusNext = key),
|
||||
scope: opt.scope,
|
||||
open,
|
||||
drafts,
|
||||
redraw: () => last && draw(last),
|
||||
};
|
||||
drawStatus(d);
|
||||
const active = document.activeElement as HTMLElement | null;
|
||||
const keep =
|
||||
focusNext || (active && sheetHost.contains(active) ? active.dataset.sheetCell : "");
|
||||
focusNext = "";
|
||||
body.dataset.mode = mode;
|
||||
body.replaceChildren(
|
||||
...(d.ans
|
||||
? mode === "sheet"
|
||||
? drawSheet(d)
|
||||
: drawPaper(d)
|
||||
: [createText(d.fail || t("Wait"), d.fail ? "error" : "muted")]),
|
||||
);
|
||||
if (keep) body.querySelector<HTMLElement>(`[data-sheet-cell="${CSS.escape(keep)}"]`)?.focus();
|
||||
}
|
||||
|
||||
return {
|
||||
picker: picker.root,
|
||||
host,
|
||||
draw,
|
||||
saved: () => {
|
||||
eqView.saved(); // 796-2b 사본 할일 뗌
|
||||
before = last?.ans?.기준?.계 ?? null; // 저장 = 고치기 전 새로(상태줄만 다시)
|
||||
if (last) drawStatus(last);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,296 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Sangeun_SheetEdit.ts
|
||||
* 단가산출(SK) 시트 숫자 넓힘 칸(PLAN 790-3 · E2 · _분석_650-H3 1 · 2 장) — 시트(`_Sangeun_Sheet`)가 부름.
|
||||
* 식 칸 숫자 = 상수 덮개(식 모양 그대로 · 옵션 따라감) · 표 꼬리표 값 = 표값 덮개(「표 연결 끊김」)
|
||||
* 글 줄(근거 글 · 셈 없음 · 산출서에 찍힘) · 까닭 칸 · [M01 원래로] · 「M01 바뀜」 · 짝잃음 띠
|
||||
* 문서 고치기 = `logic/M02_Sangeun_Const_Model`(790-2 짝) · 금액은 늘 서버 셈(상세 touch)
|
||||
* 부품 = 입력 · 단추 · 배지 · 띠 알림 · calc_row — 날 단추 · 입력 없음
|
||||
* ========================================================================== */
|
||||
|
||||
import { createBadge } from "@ui/ui_template_badge";
|
||||
import { createBanner } from "@ui/ui_template_banner";
|
||||
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 } from "@ui/ui_template_input";
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import {
|
||||
addText,
|
||||
coverKinds,
|
||||
dropText,
|
||||
editText,
|
||||
restoreM01,
|
||||
setConst,
|
||||
setReason,
|
||||
setTable,
|
||||
type ConstDoc,
|
||||
} from "./logic/M02_Sangeun_Const_Model";
|
||||
import {
|
||||
formulaParts,
|
||||
type NoteLine,
|
||||
type SheetSpots,
|
||||
type SpotInfo,
|
||||
} from "./logic/M02_Sangeun_Sheet_Model";
|
||||
|
||||
const TEXT = {
|
||||
Num: ["식 속 숫자", "Constant"],
|
||||
TableVal: ["표 값", "Table value"],
|
||||
Cut: ["표 연결 끊김", "Table link cut"],
|
||||
Changed: ["M01 바뀜", "M01 changed"],
|
||||
Restore: ["M01 원래로", "Reset to M01"],
|
||||
RestoreAll: ["전체 M01 원래로", "Reset all to M01"],
|
||||
Reason: ["까닭", "Reason"],
|
||||
ReasonHint: ["고친 까닭(짧게)", "Why changed (short)"],
|
||||
Note: ["글", "Note"],
|
||||
NoteHint: ["근거 글", "Basis note"],
|
||||
AddNote: ["+ 글", "+ Note"],
|
||||
AddTop: ["+ 근거 글", "+ Basis note"],
|
||||
DropNote: ["글 지움", "Delete note"],
|
||||
Lost: ["짝 잃은 고침 — M01 이 바뀌어 붙일 자리가 없음", "Orphan edits — M01 changed"],
|
||||
} 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 interface EditCtx {
|
||||
doc: ConstDoc;
|
||||
edit: boolean;
|
||||
spots: SheetSpots | null;
|
||||
/** 새 문서로 바꿔 끼움(같은 객체 유지) → 다시 셈 */
|
||||
apply: (next: ConstDoc) => void;
|
||||
/** 다음 그림 뒤 이 칸에 초점 */
|
||||
focus: (key: string) => void;
|
||||
}
|
||||
|
||||
const strip = (v: number | null | undefined): string =>
|
||||
v == null ? "" : String(Number(Number(v).toFixed(6)));
|
||||
|
||||
/** 작은 칸 — 바꾸면(Enter · 떠남) `onSet(글)` */
|
||||
function smallCell(
|
||||
key: string,
|
||||
label: string,
|
||||
v: string,
|
||||
hint: string,
|
||||
onSet: (raw: string) => void,
|
||||
): HTMLElement {
|
||||
const box = createInputField({ value: v, compact: true, size: 24, placeholder: hint });
|
||||
box.input.setAttribute("aria-label", label);
|
||||
box.input.dataset.sheetCell = key;
|
||||
box.input.addEventListener("change", () => onSet(box.input.value.trim()));
|
||||
return box.root;
|
||||
}
|
||||
|
||||
/** 숫자 칸 — 빈 칸 · 원값과 같음 = null(덮개 풂) · 못 읽으면 되돌림 */
|
||||
function numBox(
|
||||
key: string,
|
||||
label: string,
|
||||
v: number | null,
|
||||
was: number | null,
|
||||
cover: boolean,
|
||||
onSet: (x: number | null) => void,
|
||||
): HTMLElement {
|
||||
const root = smallCell(key, label, strip(v), strip(was), (raw) => {
|
||||
const x = raw ? Number(raw.replaceAll(",", "")) : null;
|
||||
if (x !== null && !Number.isFinite(x)) return void (input.value = strip(v));
|
||||
onSet(x === null || (was != null && x === Number(was)) ? null : x);
|
||||
});
|
||||
const input = root.querySelector("input")!;
|
||||
input.inputMode = "decimal";
|
||||
root.classList.add("m02-sks__num");
|
||||
if (cover) root.dataset.cover = "";
|
||||
return root;
|
||||
}
|
||||
|
||||
/** 식 칸 — 숫자 · 표 값을 칸으로(편집 · 서버 차례와 맞을 때만) · 못 그리면 null */
|
||||
export function formulaEditor(c: EditCtx, spot: string): HTMLElement | null {
|
||||
const info = c.spots?.자리[spot];
|
||||
const parts = c.edit && !info?.식덮개 ? formulaParts(info) : null;
|
||||
if (!info || !parts) return null;
|
||||
const nums = c.doc.상수덮개?.[spot] ?? {};
|
||||
const finds = c.doc.표값덮개?.[spot] ?? {};
|
||||
const kids = parts.map((p): HTMLElement => {
|
||||
if (p.t === "글") return el("span", { className: "m02-sks__frag", text: p.글 });
|
||||
if (p.t === "수") {
|
||||
const one = info.상수![p.n];
|
||||
const mine = nums[String(p.n)];
|
||||
return numBox(
|
||||
`${spot}|n${p.n}`,
|
||||
`${t("Num")} ${p.n + 1}`,
|
||||
mine ?? one.값,
|
||||
one.원값,
|
||||
mine != null,
|
||||
(x) => c.apply(setConst(c.doc, spot, p.n, x)),
|
||||
);
|
||||
}
|
||||
const one = info.표값!.find((x) => x.n === p.n);
|
||||
const was = typeof one?.원값 === "number" ? one.원값 : null;
|
||||
const mine = finds[String(p.n)];
|
||||
const shown = mine ?? (typeof one?.값 === "number" ? one.값 : null);
|
||||
return el("span", {
|
||||
className: "m02-sks__find",
|
||||
children: [
|
||||
numBox(`${spot}|t${p.n}`, `${t("TableVal")} ${p.n + 1}`, shown, was, mine != null, (x) =>
|
||||
c.apply(setTable(c.doc, spot, p.n, x)),
|
||||
),
|
||||
...(p.표.length ? [createBadge(p.표.join(" "), "info")] : []),
|
||||
...(mine != null ? [createBadge(t("Cut"), "warning")] : []),
|
||||
],
|
||||
});
|
||||
});
|
||||
const box = el("div", {
|
||||
className: "m02-sks__formula",
|
||||
attrs: { "data-sheet-formula": spot },
|
||||
children: kids,
|
||||
});
|
||||
return box;
|
||||
}
|
||||
|
||||
/** 꼬리 표지 · 단추 — M01 바뀜 · 표 연결 끊김 · [M01 원래로] · [+ 글] */
|
||||
export function spotTail(c: EditCtx, spot: string, added: boolean): HTMLElement[] {
|
||||
if (!spot) return [];
|
||||
const info: SpotInfo | undefined = c.spots?.자리[spot];
|
||||
const kinds = coverKinds(c.doc, spot);
|
||||
const out: HTMLElement[] = [];
|
||||
if (info?.M01바뀜) out.push(createBadge(t("Changed"), "danger"));
|
||||
if (!c.edit) return out;
|
||||
if (!added && kinds.length) {
|
||||
const back = createButton({
|
||||
label: t("Restore"),
|
||||
variant: "ghost",
|
||||
size: 22,
|
||||
onClick: () => c.apply(restoreM01(c.doc, spot)),
|
||||
});
|
||||
back.dataset.sheetRestore = spot;
|
||||
out.push(back);
|
||||
}
|
||||
const note = createButton({
|
||||
label: t("AddNote"),
|
||||
variant: "ghost",
|
||||
size: 22,
|
||||
onClick: () => addNote(c, spot),
|
||||
});
|
||||
note.dataset.sheetAddNote = spot;
|
||||
out.push(note);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 까닭 칸 — 고친 자리(덮개 · 새 덮개 · 뺀줄 · 더한줄)만 */
|
||||
export function reasonCell(c: EditCtx, key: string, need: boolean): HTMLElement[] {
|
||||
const v = c.doc.까닭?.[key] ?? "";
|
||||
if (!key || (!need && !v)) return [];
|
||||
if (!c.edit) return [createCalcFormula(`${t("Reason")} · ${v}`)];
|
||||
return [
|
||||
el("span", {
|
||||
className: "m02-sks__cell m02-sks__reason",
|
||||
children: [
|
||||
smallCell(`${key}|why`, t("Reason"), v, t("ReasonHint"), (x) =>
|
||||
c.apply(setReason(c.doc, key, x)),
|
||||
),
|
||||
],
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
export const isCovered = (doc: ConstDoc, spot: string): boolean =>
|
||||
coverKinds(doc, spot).some((k) => k === "상수" || k === "표값");
|
||||
|
||||
function addNote(c: EditCtx, spot: string): void {
|
||||
const lines = c.doc.글줄 ?? [];
|
||||
let at = lines.length; // 같은 자리 글 줄 뒤
|
||||
for (let i = lines.length - 1; i >= 0; i--)
|
||||
if (lines[i].자리 === spot) {
|
||||
at = i + 1;
|
||||
break;
|
||||
}
|
||||
if (spot === "" && !lines.some((x) => x.자리 === "")) at = 0;
|
||||
c.focus(`note|${at}`);
|
||||
c.apply(addText(c.doc, spot, "", at));
|
||||
}
|
||||
|
||||
/** 자리 뒤 글 줄들 — 편집 = 글 칸 · 지움 */
|
||||
export function noteRows(c: EditCtx, pick: (x: NoteLine) => boolean): HTMLElement[] {
|
||||
return (c.doc.글줄 ?? []).flatMap((x, i) => {
|
||||
if (!pick(x)) return [];
|
||||
const middle = c.edit
|
||||
? smallCell(`note|${i}`, t("Note"), x.글, t("NoteHint"), (v) =>
|
||||
c.apply(editText(c.doc, i, v)),
|
||||
)
|
||||
: createCalcFormula(x.글);
|
||||
const tail = c.edit
|
||||
? [
|
||||
createButton({
|
||||
label: t("DropNote"),
|
||||
variant: "danger-ghost",
|
||||
size: 22,
|
||||
onClick: () => c.apply(dropText(c.doc, i)),
|
||||
}),
|
||||
]
|
||||
: [];
|
||||
const row = createCalcSheetRow(t("Note"), middle, el("span"), el("span", { children: tail }));
|
||||
row.dataset.sheetKind = "글";
|
||||
row.classList.add("m02-sks__note");
|
||||
return [row];
|
||||
});
|
||||
}
|
||||
|
||||
export const addTopNote = (c: EditCtx): HTMLElement[] =>
|
||||
c.edit
|
||||
? [
|
||||
createButton({
|
||||
label: t("AddTop"),
|
||||
variant: "ghost",
|
||||
size: 22,
|
||||
onClick: () => addNote(c, ""),
|
||||
}),
|
||||
]
|
||||
: [];
|
||||
|
||||
/** 상태줄 [전체 M01 원래로] — 덮개 · 뺀줄 · 상수 · 표값 덮개가 있을 때만 */
|
||||
export function restoreAll(c: EditCtx): HTMLElement[] {
|
||||
const d = c.doc;
|
||||
const any =
|
||||
Object.keys(d.덮개 ?? {}).length +
|
||||
(d.뺀줄 ?? []).length +
|
||||
Object.keys(d.상수덮개 ?? {}).length +
|
||||
Object.keys(d.표값덮개 ?? {}).length +
|
||||
Object.keys((d.식덮개 as object | undefined) ?? {}).length;
|
||||
if (!c.edit || !any) return [];
|
||||
const b = createButton({
|
||||
label: t("RestoreAll"),
|
||||
variant: "ghost",
|
||||
size: 22,
|
||||
onClick: () => c.apply(restoreM01(c.doc)),
|
||||
});
|
||||
b.dataset.sheetRestore = "*";
|
||||
return [b];
|
||||
}
|
||||
|
||||
/** 짝 잃은 새 덮개 띠(790-2 `시트.짝잃음`) */
|
||||
export function lostBanner(c: EditCtx): HTMLElement[] {
|
||||
const gone = c.spots?.짝잃음 ?? [];
|
||||
return gone.length
|
||||
? [
|
||||
createBanner({
|
||||
tone: "warning",
|
||||
text: t("Lost"),
|
||||
details: gone.map((x) => `${x.cid} — ${x.까닭}`).join("\n"),
|
||||
}),
|
||||
]
|
||||
: [];
|
||||
}
|
||||
|
||||
/** 뺀 바탕 줄 까닭 칸(뺀 줄 띠 아래) */
|
||||
export function removedReasons(
|
||||
c: EditCtx,
|
||||
removed: { cid: string; 이름: string }[],
|
||||
): HTMLElement[] {
|
||||
if (!c.edit) return [];
|
||||
return removed
|
||||
.filter((x) => (c.doc.뺀줄 ?? []).includes(x.cid))
|
||||
.map((x) =>
|
||||
el("div", {
|
||||
className: "m02-sks__cell m02-sks__reason",
|
||||
attrs: { "data-sheet-removed": x.cid },
|
||||
children: [createText(x.이름, "muted"), ...reasonCell(c, x.cid, true)],
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -1,234 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Sangeun_SheetFormula.ts
|
||||
* 단가산출(SK) 시트 식 칸(PLAN 792 · E4 · _분석_650-H3 2 장 「식 고침」) — 시트(`_Sangeun_Sheet`)가 부름.
|
||||
* 식 칸 = 이름 꼴 글(`[노임 보통인부] × 무게 / 운반량`) · 칠 때마다 검사 길(`formula-check` · 옛 요청 끊기)
|
||||
* → 틀리면 빨간 칸 + 까닭 한 줄 · 되면 바꿀 때(Enter · 떠남) 엔진 식을 문서에(`setFormula`) → 서버 셈
|
||||
* 변수 줄 = 이름 · 식 · 값 · [+ 변수 줄] · 빼기(`addVar` · `editVar` · `dropVar`)
|
||||
* 빈 칸 · M01 식 그대로 = 식 덮개 풂 · 같은 자리 상수 · 표값 · 품 덮개는 식 덮개와 같이 못 둠 → 걷고 넣음
|
||||
* 문서 고치기 = `logic/M02_Sangeun_Formula_Model`(792-1 짝) · 금액은 늘 서버 셈 · 자동저장 없음
|
||||
* ========================================================================== */
|
||||
|
||||
import { createBadge } from "@ui/ui_template_badge";
|
||||
import { createButton } from "@ui/ui_template_button";
|
||||
import { createCalcSheetRow } from "@ui/ui_template_calc_row";
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { createInputField } from "@ui/ui_template_input";
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createFormulaCell } from "../A00_Common/sheet_formula_cell";
|
||||
import { call, type Layer } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import type { EditCtx } from "./M02_MasterTemplete_Sangeun_SheetEdit";
|
||||
import {
|
||||
addVar,
|
||||
dropVar,
|
||||
editVar,
|
||||
setFormula,
|
||||
type FormulaDoc,
|
||||
type VarLine,
|
||||
} from "./logic/M02_Sangeun_Formula_Model";
|
||||
import { num, type SpotInfo } from "./logic/M02_Sangeun_Sheet_Model";
|
||||
|
||||
const TEXT = {
|
||||
Edit: ["식 고침", "Edit formula"],
|
||||
Close: ["식 닫기", "Close formula"],
|
||||
Var: ["변수", "Variable"],
|
||||
VarName: ["변수 이름", "Variable name"],
|
||||
AddVar: ["+ 변수 줄", "+ Variable"],
|
||||
DropVar: ["변수 빼기", "Remove variable"],
|
||||
} 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 interface CheckAnswer {
|
||||
되나: boolean;
|
||||
까닭: string[];
|
||||
알림: string[];
|
||||
/** 엔진 식(문서에 넣을 것) */
|
||||
식: string;
|
||||
/** 이름 꼴 글 */
|
||||
글: string;
|
||||
}
|
||||
|
||||
export interface FormulaCtx extends EditCtx {
|
||||
scope: { layer: Layer; owner: string | null };
|
||||
/** 식 칸 연 자리(화면 캐시) */
|
||||
open: Set<string>;
|
||||
/** 치는 중 글(다시 그려도 안 잃음) — 넣으면 지움 */
|
||||
drafts: Map<string, string>;
|
||||
redraw: () => void;
|
||||
}
|
||||
|
||||
const q = (c: FormulaCtx): string =>
|
||||
c.scope.owner && c.scope.layer !== "system" ? `?owner=${encodeURIComponent(c.scope.owner)}` : "";
|
||||
|
||||
/** 검사 길(792-1) — 셈 · 저장 없음 */
|
||||
export const checkFormula = (
|
||||
c: FormulaCtx,
|
||||
spot: string,
|
||||
text: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CheckAnswer> =>
|
||||
call<CheckAnswer>(`/layers/${c.scope.layer}/sangeun/formula-check${q(c)}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ 문서: c.doc, 자리: spot, 식: text }),
|
||||
signal,
|
||||
});
|
||||
|
||||
/** 식 덮개와 같이 못 두는 같은 자리 상수 · 표값 · 품 덮개 걷음(배율은 둠) */
|
||||
export function clearNums<T extends FormulaDoc>(doc: T, spot: string): T {
|
||||
const out: FormulaDoc = { ...doc };
|
||||
for (const name of ["상수덮개", "표값덮개"] as const) {
|
||||
const all = { ...(doc[name] ?? {}) };
|
||||
delete all[spot];
|
||||
if (Object.keys(all).length) out[name] = all;
|
||||
else delete out[name];
|
||||
}
|
||||
const one = doc.덮개?.[spot];
|
||||
if (one && "qty" in one) {
|
||||
const rest = { ...one };
|
||||
delete rest.qty;
|
||||
const covers = { ...doc.덮개 };
|
||||
if (Object.keys(rest).length) covers[spot] = rest;
|
||||
else delete covers[spot];
|
||||
out.덮개 = covers;
|
||||
}
|
||||
return out as T;
|
||||
}
|
||||
|
||||
/** 식 칸 — 칠 때마다 검사(앞 요청 끊음) · 바꿀 때 넣음 · `put(엔진 식 | null)` · 몸 = 공용 `sheet_formula_cell` */
|
||||
function formulaBox(
|
||||
c: FormulaCtx,
|
||||
spot: string,
|
||||
key: string,
|
||||
shown: string,
|
||||
origin: string,
|
||||
put: (engine: string | null) => void,
|
||||
): HTMLElement {
|
||||
return createFormulaCell({
|
||||
key,
|
||||
spot,
|
||||
shown,
|
||||
origin,
|
||||
drafts: c.drafts,
|
||||
check: (text, signal) => checkFormula(c, spot, text, signal),
|
||||
put,
|
||||
});
|
||||
}
|
||||
|
||||
/** 줄 식 칸 — 열었거나 식 덮개 있는 자리만 · 못 고치는 자리(로직 · 율 더한줄) = null */
|
||||
export function rowFormula(
|
||||
c: FormulaCtx,
|
||||
spot: string,
|
||||
info: SpotInfo | undefined,
|
||||
): HTMLElement | null {
|
||||
if (!c.edit || !spot || !info || (!c.open.has(spot) && !info.식덮개)) return null;
|
||||
const shown = info.식글 ?? info.식 ?? "";
|
||||
const origin = info.원식글 ?? "";
|
||||
return formulaBox(c, spot, `${spot}|f`, shown, origin, (engine) => {
|
||||
const doc = engine ? clearNums(c.doc as FormulaDoc, spot) : (c.doc as FormulaDoc);
|
||||
c.apply(setFormula(doc, spot, engine));
|
||||
});
|
||||
}
|
||||
|
||||
/** 꼬리 [식 고침] · [식 닫기] — 식 덮개 있으면 늘 열림(닫기 없음) */
|
||||
export function formulaToggle(
|
||||
c: FormulaCtx,
|
||||
spot: string,
|
||||
info: SpotInfo | undefined,
|
||||
): HTMLElement[] {
|
||||
const addOne = /^add:#\d+$/.test(spot); // 더한줄 수량식(율 · 로직 줄은 부르는 쪽이 뺌)
|
||||
if (!c.edit || !spot || !info || info.식덮개 || (info.식 == null && !addOne)) return [];
|
||||
const on = c.open.has(spot);
|
||||
const b = createButton({
|
||||
label: on ? t("Close") : t("Edit"),
|
||||
variant: "ghost",
|
||||
size: 22,
|
||||
onClick: () => {
|
||||
if (on) c.open.delete(spot);
|
||||
else c.open.add(spot);
|
||||
c.drafts.delete(`${spot}|f`);
|
||||
c.focus(on ? "" : `${spot}|f`);
|
||||
c.redraw();
|
||||
},
|
||||
});
|
||||
b.dataset.sheetFxOpen = spot;
|
||||
return [b];
|
||||
}
|
||||
|
||||
const vars = (c: FormulaCtx): VarLine[] => (c.doc as FormulaDoc).변수줄 ?? [];
|
||||
|
||||
/** 자리 뒤 변수 줄들(이어 붙은 변수까지) */
|
||||
export function varRows(c: FormulaCtx, after: (v: VarLine) => boolean): HTMLElement[] {
|
||||
return vars(c).flatMap((v) =>
|
||||
after(v) ? [varRow(c, v), ...varRows(c, (x) => x.자리 === `mid:${v.이름}`)] : [],
|
||||
);
|
||||
}
|
||||
|
||||
function varRow(c: FormulaCtx, v: VarLine): HTMLElement {
|
||||
const spot = `mid:${v.이름}`;
|
||||
const info = c.spots?.자리[spot];
|
||||
const label = c.edit
|
||||
? (() => {
|
||||
const box = createInputField({ value: v.이름, compact: true, size: 24 });
|
||||
box.input.setAttribute("aria-label", t("VarName"));
|
||||
box.input.dataset.sheetCell = `var|${v.이름}`;
|
||||
box.input.addEventListener("change", () => {
|
||||
const to = box.input.value.trim();
|
||||
if (!to || to === v.이름 || vars(c).some((x) => x.이름 === to))
|
||||
return void (box.input.value = v.이름);
|
||||
c.focus(`var|${to}`);
|
||||
c.apply(editVar(c.doc as FormulaDoc, v.이름, { 이름: to }));
|
||||
});
|
||||
return box.root;
|
||||
})()
|
||||
: el("span", { className: "ui-calc-row__label", text: v.이름 });
|
||||
const middle = c.edit
|
||||
? formulaBox(c, spot, `${spot}|f`, info?.식글 ?? v.식, "", (engine) =>
|
||||
c.apply(editVar(c.doc as FormulaDoc, v.이름, { 식: engine ?? "0" })),
|
||||
)
|
||||
: el("span", { className: "ui-calc-formula", text: info?.식글 ?? v.식 });
|
||||
const value = el("span", {
|
||||
className: "ui-calc-row__out",
|
||||
text: typeof info?.지금 === "number" ? num(info.지금) : "—",
|
||||
});
|
||||
const tail = el("span", {
|
||||
children: [
|
||||
createBadge(t("Var"), "accent"),
|
||||
...(c.edit
|
||||
? [
|
||||
createButton({
|
||||
label: t("DropVar"),
|
||||
variant: "danger-ghost",
|
||||
size: 22,
|
||||
onClick: () => c.apply(dropVar(c.doc as FormulaDoc, v.이름)),
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
const row = createCalcSheetRow(label, middle, value, tail);
|
||||
row.dataset.sheetKind = "변수";
|
||||
row.dataset.sheetSpot = spot;
|
||||
row.classList.add("is-changed");
|
||||
return row;
|
||||
}
|
||||
|
||||
/** [+ 변수 줄] — 맨 위(자리 "") · 이름 = 안 겹치는 「변수n」 */
|
||||
export function addVarButton(c: FormulaCtx): HTMLElement[] {
|
||||
if (!c.edit) return [];
|
||||
const b = createButton({
|
||||
label: t("AddVar"),
|
||||
variant: "ghost",
|
||||
size: 22,
|
||||
onClick: () => {
|
||||
const used = new Set([
|
||||
...vars(c).map((v) => v.이름),
|
||||
...(c.spots?.이름표 ?? []).map((x) => x.글),
|
||||
]);
|
||||
let n = 1;
|
||||
while (used.has(`변수${n}`)) n++;
|
||||
c.focus(`var|변수${n}`);
|
||||
c.apply(addVar(c.doc as FormulaDoc, { 이름: `변수${n}`, 식: "0", 자리: "" }, 0));
|
||||
},
|
||||
});
|
||||
b.dataset.sheetAddVar = "";
|
||||
return [b];
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Sangeun_Tabs.ts
|
||||
* M02 단가산출(SK) 상세 보기 고르기(PLAN 799-2 · 요구 655-8) — 수식(첫 탭 · 796-2b `_Sangeun_Equation`) · 단가산출 상세 둘.
|
||||
* 시트 · 인력·재료·경비 · 시험 계산 · 텍스트 수식 보기는 걷음(옛 `_Sangeun_Sheet*` · 790-1) — 서버 문서 꼴(변수줄 · 글줄 · 덮개)은 그대로
|
||||
* 수식 = 이 파일이 쥔 그릇 · 단가산출 상세 = 상세가 쥔 `editor`(수식일 때 숨김)
|
||||
* ========================================================================== */
|
||||
|
||||
import { createSegment } from "@ui/ui_template_segment";
|
||||
import { tl } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Text";
|
||||
import {
|
||||
createSangeunEquation,
|
||||
type SkEquationDraw,
|
||||
type SkEquationOptions,
|
||||
} from "./M02_MasterTemplete_Sangeun_Equation";
|
||||
|
||||
type View = "equation" | "ho";
|
||||
|
||||
/** 보기 고르기 · 수식 그릇 — `editor` = 단가산출 상세 그릇(수식일 때 숨김) */
|
||||
export function createSangeunTabs(
|
||||
editor: HTMLElement,
|
||||
opt: SkEquationOptions,
|
||||
): {
|
||||
picker: HTMLElement;
|
||||
host: HTMLElement;
|
||||
draw: (d: SkEquationDraw) => void;
|
||||
saved: () => void;
|
||||
} {
|
||||
const eqView = createSangeunEquation(opt);
|
||||
const show = (v: View): void => {
|
||||
eqView.root.hidden = v !== "equation";
|
||||
editor.hidden = v === "equation";
|
||||
if (!editor.hidden) editor.dataset.view = v;
|
||||
};
|
||||
const picker = createSegment({
|
||||
options: [
|
||||
{ value: "equation", label: tl("View_Equation") },
|
||||
{ value: "ho", label: tl("Ho_Title") },
|
||||
],
|
||||
value: "equation",
|
||||
size: 32,
|
||||
ariaLabel: tl("View_Label"),
|
||||
onChange: (v) => show(v as View),
|
||||
});
|
||||
picker.root.dataset.skViews = "";
|
||||
show("equation");
|
||||
return {
|
||||
picker: picker.root,
|
||||
host: eqView.root,
|
||||
draw: eqView.draw,
|
||||
saved: eqView.saved, // 796-2b 사본 할일 뗌
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
"""M02 단가산출(SK) 숫자 넓힘(PLAN 790-2 · 요구서_652 · _분석_650-H3 E2) — 새 덮개 갈래 · 시트 답.
|
||||
|
||||
짝 = `logic/M02_Sangeun_Const_Model.ts`(캐시 고치기 · M01 원래로 · 까닭 · 더한줄 열쇠 옮김 ·
|
||||
같은 꼴) · 거울 시험 `resources/tester/test_790_2_sangeun_const.py`.
|
||||
시험 `resources/tester/test_790_2_sangeun_const.py` · 799-2 — 화면 짝(`Const_Model.ts` · 시트 탭)은 걷음, 서버만 남음.
|
||||
|
||||
문서 덧칸(없으면 안 둠 — 옛 SK 그대로 · 지문 그대로):
|
||||
상수덮개 {자리: {n: 값}} 자리 식의 n 번째 숫자 상수(0 부터 · 엔진 파서 차례 · 찾기 머리 · 키 ·
|
||||
@@ -260,7 +259,7 @@ def stamp(row: dict, doc: dict | None = None) -> dict[str, Any]:
|
||||
return {"바탕판": calc.fingerprint(row), "M01판": dict(zip(spots, map(_print, lines)))}
|
||||
|
||||
|
||||
# ── 고치기(짝 = Const_Model.ts) ──────────────────────────────────────
|
||||
# ── 고치기 ──────────────────────────────────────
|
||||
def restore(doc: dict, key: str | None = None) -> dict:
|
||||
"""[M01 원래로] — 전체(None) = 덮개 · 뺀줄 · 상수 · 표값 · 식 덮개 · 그 까닭 지움(더한줄 ·
|
||||
글줄 · 변수줄 · 옵션 남김) · 열쇠 하나 = 그 자리 덮개 · 뺀줄 · 새 덮개 · 까닭(세부
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
"""M02 단가산출(SK) 사용자 식 칸(PLAN 792-1 · 요구서_652 1 장 3 · _분석_650-H3 E4) — 변수 줄 ·
|
||||
식 덮개 · 더한줄 수량식 · 이름 꼴 ↔ 엔진 식 · 검사(「덮개 = 숫자만」 원칙을 바꿈).
|
||||
|
||||
짝 = `logic/M02_Sangeun_Formula_Model.ts`(캐시 고치기 — 서버 짝 함수 없음 · 꼴만 같음) ·
|
||||
시험 `resources/tester/test_792_1_sangeun_formula.py`.
|
||||
시험 `resources/tester/test_792_1_sangeun_formula.py` · 799-2 — 화면 짝(`Formula_Model.ts` · 시트 탭)은 걷음, 서버만 남음.
|
||||
|
||||
문서 덧칸(없으면 안 둠 — 옛 SK 그대로 · 저장 = 엔진 식 · 이름 꼴 글도 받아 서버가 번역):
|
||||
변수줄 [{이름, 식, 표시?, 자리}] 엔진 중간 줄로 끼움 · 자리 = 그 뒤 중간 `mid:이름`
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete/logic/M02_Sangeun_Const_Model.ts
|
||||
* 단가산출(SK) 숫자 넓힘 캐시 고치기(PLAN 790-2 · 요구서_652 · _분석_650-H3 E2) —
|
||||
* 짝 = 서버 `M02_MasterTemplete/M02_Sangeun_Const.py`(`restore` · `drop_add_keys` · `kinds` 같은 꼴)
|
||||
* · 거울 시험 `resources/tester/test_790_2_sangeun_const.py`.
|
||||
* 상수덮개 {자리: {n: 값}} 식 n 번째 숫자(0 부터 · 서버 답 `시트.자리[자리].상수` 의 n)
|
||||
* 표값덮개 {자리: {n: 값}} 식 n 번째 `찾기(…).칸`(서버 답 `표값` 의 n)
|
||||
* 글줄 [{자리, 글}] 셈 없는 글 줄 · 자리 뒤("" = 맨 위)
|
||||
* 까닭 {열쇠: 글} 덮개 · 더한줄 · 뺀줄 · 새 덮개마다
|
||||
* M01판 서버 몫 — 받은 그대로 둠(셈 때 「M01바뀜」 판정)
|
||||
* 자리 = 바탕 cid · 중간 `mid:이름` · 열쇠 = 자리 · `add:#k` · `add:#k/세부 cid`.
|
||||
* 금액은 늘 서버가 셈 — 여기는 문서 칸 고치기만(새 문서를 돌려줌 · 받은 문서 안 바꿈).
|
||||
* 화면 · fetch · sessionStorage 없음(`CLAUDE.md` 4장).
|
||||
* ========================================================================== */
|
||||
|
||||
export type NumCovers = Record<string, Record<string, number>>;
|
||||
|
||||
export interface TextLine {
|
||||
자리: string;
|
||||
글: string;
|
||||
}
|
||||
|
||||
export interface ConstDoc {
|
||||
덮개?: Record<string, Record<string, unknown>>;
|
||||
뺀줄?: string[];
|
||||
더한줄?: Record<string, unknown>[];
|
||||
상수덮개?: NumCovers;
|
||||
표값덮개?: NumCovers;
|
||||
글줄?: TextLine[];
|
||||
까닭?: Record<string, string>;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
export type CoverKind = "품" | "단가" | "상수" | "표값" | "식" | "뺌";
|
||||
|
||||
const ADD = /^add:#(\d+)(\/.*)?$/;
|
||||
const PARTS = ["labor", "material", "expense"];
|
||||
|
||||
/** 빈 묶음 · 목록 칸은 지움(서버 `clean_more` 처럼 없으면 안 둠). */
|
||||
function put<T extends ConstDoc>(doc: T, name: string, value: unknown): T {
|
||||
const out: ConstDoc = { ...doc };
|
||||
const empty =
|
||||
value == null ||
|
||||
(Array.isArray(value)
|
||||
? value.length === 0
|
||||
: typeof value === "object" && Object.keys(value).length === 0);
|
||||
if (empty) delete out[name];
|
||||
else out[name] = value;
|
||||
return out as T;
|
||||
}
|
||||
|
||||
function setNum<T extends ConstDoc>(
|
||||
doc: T,
|
||||
name: "상수덮개" | "표값덮개",
|
||||
spot: string,
|
||||
n: number,
|
||||
v: number | null,
|
||||
): T {
|
||||
const all: NumCovers = { ...(doc[name] ?? {}) };
|
||||
const one = { ...(all[spot] ?? {}) };
|
||||
if (v == null) delete one[String(n)];
|
||||
else one[String(n)] = v;
|
||||
if (Object.keys(one).length) all[spot] = one;
|
||||
else delete all[spot];
|
||||
return put(doc, name, all);
|
||||
}
|
||||
|
||||
/** 식 속 n 번째 숫자 상수 → 값(null = 원래로). */
|
||||
export const setConst = <T extends ConstDoc>(
|
||||
doc: T,
|
||||
spot: string,
|
||||
n: number,
|
||||
v: number | null,
|
||||
): T => setNum(doc, "상수덮개", spot, n, v);
|
||||
|
||||
/** 식 속 n 번째 표 찾기 값 → 값(null = 표 연결 되살림). */
|
||||
export const setTable = <T extends ConstDoc>(
|
||||
doc: T,
|
||||
spot: string,
|
||||
n: number,
|
||||
v: number | null,
|
||||
): T => setNum(doc, "표값덮개", spot, n, v);
|
||||
|
||||
/** 글 줄 넣기 — `at` 차례(없으면 끝). */
|
||||
export function addText<T extends ConstDoc>(doc: T, spot: string, text: string, at?: number): T {
|
||||
const lines = [...(doc.글줄 ?? [])];
|
||||
lines.splice(at ?? lines.length, 0, { 자리: spot, 글: text });
|
||||
return put(doc, "글줄", lines);
|
||||
}
|
||||
|
||||
export function editText<T extends ConstDoc>(doc: T, at: number, text: string): T {
|
||||
const lines = (doc.글줄 ?? []).map((t, i) => (i === at ? { ...t, 글: text } : t));
|
||||
return put(doc, "글줄", lines);
|
||||
}
|
||||
|
||||
export function dropText<T extends ConstDoc>(doc: T, at: number): T {
|
||||
return put(
|
||||
doc,
|
||||
"글줄",
|
||||
(doc.글줄 ?? []).filter((_, i) => i !== at),
|
||||
);
|
||||
}
|
||||
|
||||
/** 까닭 — 빈 글 = 지움. */
|
||||
export function setReason<T extends ConstDoc>(doc: T, key: string, text: string): T {
|
||||
const why = { ...(doc.까닭 ?? {}) };
|
||||
if (text.trim()) why[key] = text.trim();
|
||||
else delete why[key];
|
||||
return put(doc, "까닭", why);
|
||||
}
|
||||
|
||||
const topAdd = (key: string): boolean => {
|
||||
const hit = ADD.exec(key);
|
||||
return !!hit && !hit[2];
|
||||
};
|
||||
|
||||
/** [M01 원래로] — 열쇠 없음 = 전체(덮개 · 뺀줄 · 상수 · 표값 덮개 · 그 까닭 · 더한줄 · 글줄 · 옵션 남김) ·
|
||||
* 열쇠 = 그 자리 덮개 · 뺀줄 · 새 덮개 · 까닭(`add:#k/cid` = 그 더한줄 세부) · 짝 `restore`. */
|
||||
export function restoreM01<T extends ConstDoc>(doc: T, key?: string): T {
|
||||
let out: ConstDoc = { ...doc };
|
||||
delete out.상수덮개;
|
||||
delete out.표값덮개;
|
||||
delete out.식덮개; // 식 칸(792-1)
|
||||
delete out.까닭;
|
||||
const why = { ...(doc.까닭 ?? {}) };
|
||||
if (key == null) {
|
||||
out = { ...out, 덮개: {}, 뺀줄: [] };
|
||||
const kept = Object.fromEntries(Object.entries(why).filter(([k]) => topAdd(k)));
|
||||
return put(out, "까닭", kept) as T;
|
||||
}
|
||||
const hit = ADD.exec(key);
|
||||
if (hit && hit[2]) {
|
||||
const k = Number(hit[1]);
|
||||
const sub = hit[2].slice(1);
|
||||
const adds = (doc.더한줄 ?? []).map((x) => ({ ...x }));
|
||||
if (k < adds.length) {
|
||||
const was = adds[k];
|
||||
const one: Record<string, unknown> = { ...was };
|
||||
delete one.덮개;
|
||||
delete one.뺀줄;
|
||||
const covers = Object.fromEntries(
|
||||
Object.entries((was.덮개 as Record<string, unknown>) ?? {}).filter(([c]) => c !== sub),
|
||||
);
|
||||
const drop = ((was.뺀줄 as string[]) ?? []).filter((c) => c !== sub);
|
||||
if (Object.keys(covers).length) one.덮개 = covers;
|
||||
if (drop.length) one.뺀줄 = drop;
|
||||
adds[k] = one;
|
||||
}
|
||||
out.더한줄 = adds;
|
||||
} else {
|
||||
out.덮개 = Object.fromEntries(Object.entries(doc.덮개 ?? {}).filter(([c]) => c !== key));
|
||||
out.뺀줄 = (doc.뺀줄 ?? []).filter((c) => c !== key);
|
||||
for (const name of ["상수덮개", "표값덮개", "식덮개"] as const) {
|
||||
const all = (doc[name] ?? {}) as Record<string, unknown>;
|
||||
const got = Object.fromEntries(Object.entries(all).filter(([s]) => s !== key));
|
||||
out = put(out, name, got);
|
||||
}
|
||||
}
|
||||
delete why[key];
|
||||
return put(out, "까닭", why) as T;
|
||||
}
|
||||
|
||||
/** 더한줄 k 를 뺀 뒤 — 까닭 · 글줄 열쇠 `add:#j`(j > k) 한 칸 당김 · k 는 지움 · 짝 `drop_add_keys`. */
|
||||
export function dropAddKeys<T extends ConstDoc>(doc: T, k: number): T {
|
||||
const move = (key: string): string | null => {
|
||||
const hit = ADD.exec(key);
|
||||
if (!hit) return key;
|
||||
const j = Number(hit[1]);
|
||||
return j === k ? null : `add:#${j > k ? j - 1 : j}${hit[2] ?? ""}`;
|
||||
};
|
||||
let out: ConstDoc = { ...doc };
|
||||
if (doc.까닭) {
|
||||
const why: Record<string, string> = {};
|
||||
for (const [x, v] of Object.entries(doc.까닭)) {
|
||||
const to = move(x);
|
||||
if (to) why[to] = v;
|
||||
}
|
||||
out = put(out, "까닭", why);
|
||||
}
|
||||
if (doc.글줄) out.글줄 = doc.글줄.map((t) => ({ ...t, 자리: move(t.자리) ?? "" }));
|
||||
return out as T;
|
||||
}
|
||||
|
||||
/** 자리의 덮개 갈래(차례 = 품 · 단가 · 상수 · 표값 · 뺌) · 짝 `kinds`. */
|
||||
export function coverKinds(doc: ConstDoc, spot: string): CoverKind[] {
|
||||
const cover = doc.덮개?.[spot] ?? {};
|
||||
const out: CoverKind[] = [];
|
||||
if ("qty" in cover || "배율" in cover) out.push("품");
|
||||
if (PARTS.some((k) => k in cover)) out.push("단가");
|
||||
if (doc.상수덮개?.[spot]) out.push("상수");
|
||||
if (doc.표값덮개?.[spot]) out.push("표값");
|
||||
if ((doc.식덮개 as Record<string, string> | undefined)?.[spot]) out.push("식");
|
||||
if ((doc.뺀줄 ?? []).includes(spot)) out.push("뺌");
|
||||
return out;
|
||||
}
|
||||
@@ -15,8 +15,6 @@
|
||||
|
||||
import type { CalcLine, TextAnswer } from "../../M01_MasterData/M01_MasterData_UI_Logic_Api";
|
||||
import { bornOfCid } from "./M02_Born_Tag";
|
||||
import type { SheetGroup } from "../../A00_Common/calc_sheet";
|
||||
import { rounddown } from "../../B09_Estimation/B09_Estimation_Calc_Base";
|
||||
import { forgetAdd, goneName, reviveAdd, setQty, resetQty } from "./M02_Sangeun_Undo";
|
||||
|
||||
export type CoverPart = "labor" | "material" | "expense";
|
||||
@@ -418,40 +416,6 @@ export function dropBase(doc: SkDoc, k: number, name = ""): void {
|
||||
export const goneRows = (doc: SkDoc): { cid: string; 이름: string }[] =>
|
||||
(doc.뺀더한줄 ?? []).map((g, n) => ({ cid: `gone:${n}`, 이름: goneName(g.줄) }));
|
||||
|
||||
const NUM = new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 4 });
|
||||
const num = (v: number | null): string => (v == null ? "—" : NUM.format(v));
|
||||
|
||||
/** 표 줄 → 공용 계산서(`calc_sheet`) 비목 묶음 — 줄 글 「단가 * 품 = 금액」 · 기준 밖 재료 = 금액 없음 */
|
||||
export function sheetGroups(rows: CoverRow[]): SheetGroup[] {
|
||||
const groups = new Map<string, SheetGroup>();
|
||||
const take = (k: string): SheetGroup => {
|
||||
let g = groups.get(k);
|
||||
if (!g) groups.set(k, (g = { 비목: k, 소계: 0, 줄: [] }));
|
||||
return g;
|
||||
};
|
||||
for (const r of rows) {
|
||||
const entries = Object.entries(r.parts);
|
||||
for (const [k, v] of entries.length ? entries : [["경비", 0] as [string, number]]) {
|
||||
const share = entries.length > 1 && r.금액 ? (r.단가 ?? 0) * (v / r.금액) : r.단가;
|
||||
const amount = r.apart || (!entries.length && r.금액 == null) ? null : v;
|
||||
const g = take(k);
|
||||
g.소계 = (g.소계 ?? 0) + (amount ?? 0);
|
||||
g.줄.push({
|
||||
이름: r.이름,
|
||||
글: r.pct ? `${num(r.수량)} %` : `${num(share)} * ${num(r.수량)} = ${num(amount)}`,
|
||||
금액: amount,
|
||||
묶음: r.묶음,
|
||||
장비: entries.length > 1 && (r.sub?.cid ?? r.cid).startsWith("logic:"),
|
||||
...(r.off ? { 옵션꺼짐: true } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
const order = Object.keys(PART_OF);
|
||||
return [...groups.values()] // 751-3 소계 = 줄 합 1 원 버림(품셈 1-2-2 · 서버 `master_cut` 와 같은 값)
|
||||
.map((g) => ({ ...g, 소계: rounddown(g.소계 ?? 0, 0) }))
|
||||
.sort((a, b) => order.indexOf(a.비목) - order.indexOf(b.비목));
|
||||
}
|
||||
|
||||
/* ── 공용 상세(Ho) 줄 ── */
|
||||
/** Ho 호표 · 덧줄 꼴(`M01_MasterData_UI_Logic_Api` HoLine · NamedFormula 중 쓰는 칸) */
|
||||
export interface HoItem {
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete/logic/M02_Sangeun_Formula_Model.ts
|
||||
* 단가산출(SK) 사용자 식 칸 캐시 고치기(PLAN 792-1 · 요구서_652 · _분석_650-H3 E4) —
|
||||
* 서버 `M02_MasterTemplete/M02_Sangeun_Formula.py`(꼴 검사 · 번역 · 셈)와 같은 꼴 · 서버 짝 함수 없음
|
||||
* · 시험 `resources/tester/test_792_1_sangeun_formula.py`(여기 결과를 서버 `clean` 이 받음).
|
||||
* 변수줄 [{이름, 식, 표시?, 자리}] 엔진 중간 줄 · 자리 = 그 뒤 중간 `mid:이름`("" = 맨 위)
|
||||
* 식덮개 {자리: 식} 중간 `mid:이름` · 호표 · 덧줄 cid
|
||||
* 더한줄[k].수량식 율 줄 · 로직 줄 빼고
|
||||
* 식 글 = 검사 길(`formula-check`) 답 `식`(엔진) 을 넣음 · 이름 꼴 글을 넣어도 서버가 번역.
|
||||
* 금액은 늘 서버가 셈 — 여기는 문서 칸 고치기만(새 문서를 돌려줌 · 받은 문서 안 바꿈).
|
||||
* 화면 · fetch · sessionStorage 없음(`CLAUDE.md` 4장).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { ConstDoc } from "./M02_Sangeun_Const_Model";
|
||||
|
||||
export interface VarLine {
|
||||
이름: string;
|
||||
식: string;
|
||||
표시?: number;
|
||||
자리: string;
|
||||
}
|
||||
|
||||
export interface FormulaDoc extends ConstDoc {
|
||||
변수줄?: VarLine[];
|
||||
식덮개?: Record<string, string>;
|
||||
}
|
||||
|
||||
const ADD_ONE = /^add:#(\d+)$/;
|
||||
|
||||
/** 빈 묶음 · 목록 칸은 지움(서버처럼 없으면 안 둠). */
|
||||
function putField<T extends FormulaDoc>(doc: T, name: string, value: unknown): T {
|
||||
const out: FormulaDoc = { ...doc };
|
||||
const empty =
|
||||
value == null ||
|
||||
(Array.isArray(value)
|
||||
? value.length === 0
|
||||
: typeof value === "object" && Object.keys(value as object).length === 0);
|
||||
if (empty) delete out[name];
|
||||
else out[name] = value;
|
||||
return out as T;
|
||||
}
|
||||
|
||||
const isVar = (doc: FormulaDoc, spot: string): boolean =>
|
||||
spot.startsWith("mid:") && (doc.변수줄 ?? []).some((v) => v.이름 === spot.slice(4));
|
||||
|
||||
/** 자리 식 — 변수 줄 = 그 식 · `add:#k` = 수량식 · 그 밖 = 식덮개 · null · "" = 지움(M01 식 · 수). */
|
||||
export function setFormula<T extends FormulaDoc>(doc: T, spot: string, text: string | null): T {
|
||||
const t = (text ?? "").trim();
|
||||
if (isVar(doc, spot)) return editVar(doc, spot.slice(4), { 식: t });
|
||||
const hit = ADD_ONE.exec(spot);
|
||||
if (hit) {
|
||||
const k = Number(hit[1]);
|
||||
const adds = (doc.더한줄 ?? []).map((x, i) => {
|
||||
if (i !== k) return x;
|
||||
const one: Record<string, unknown> = { ...x };
|
||||
if (t) one.수량식 = t;
|
||||
else delete one.수량식;
|
||||
return one;
|
||||
});
|
||||
return { ...doc, 더한줄: adds };
|
||||
}
|
||||
const covers = { ...(doc.식덮개 ?? {}) };
|
||||
if (t) covers[spot] = t;
|
||||
else delete covers[spot];
|
||||
return putField(doc, "식덮개", covers);
|
||||
}
|
||||
|
||||
/** 변수 줄 넣기 — `at` 차례(없으면 끝) · 같은 이름이 있으면 그대로. */
|
||||
export function addVar<T extends FormulaDoc>(doc: T, line: VarLine, at?: number): T {
|
||||
const lines = [...(doc.변수줄 ?? [])];
|
||||
if (lines.some((v) => v.이름 === line.이름)) return doc;
|
||||
lines.splice(at ?? lines.length, 0, { ...line });
|
||||
return putField(doc, "변수줄", lines);
|
||||
}
|
||||
|
||||
/** 변수 줄 고침 — 이름 바꾸면 까닭 · 글줄 · 다른 변수 자리 열쇠 `mid:이름` 도 따라감(식 속 이름은
|
||||
* 그대로 — 검사 길이 짚음). */
|
||||
export function editVar<T extends FormulaDoc>(doc: T, name: string, patch: Partial<VarLine>): T {
|
||||
const to = patch.이름 ?? name;
|
||||
const lines = (doc.변수줄 ?? []).map((v) => {
|
||||
if (v.이름 === name) {
|
||||
const one: VarLine = { ...v, ...patch };
|
||||
if (patch.표시 == null && "표시" in patch) delete one.표시;
|
||||
return one;
|
||||
}
|
||||
return v.자리 === `mid:${name}` ? { ...v, 자리: `mid:${to}` } : v;
|
||||
});
|
||||
const out = putField(doc, "변수줄", lines);
|
||||
return to === name ? out : moveKey(out, `mid:${name}`, `mid:${to}`);
|
||||
}
|
||||
|
||||
/** 변수 줄 빼기 — 그 뒤에 붙은 변수는 그 자리를 이어받음 · 까닭 지움 · 글줄은 맨 위로. */
|
||||
export function dropVar<T extends FormulaDoc>(doc: T, name: string): T {
|
||||
const gone = (doc.변수줄 ?? []).find((v) => v.이름 === name);
|
||||
if (!gone) return doc;
|
||||
const lines = (doc.변수줄 ?? [])
|
||||
.filter((v) => v.이름 !== name)
|
||||
.map((v) => (v.자리 === `mid:${name}` ? { ...v, 자리: gone.자리 } : v));
|
||||
return moveKey(putField(doc, "변수줄", lines), `mid:${name}`, null);
|
||||
}
|
||||
|
||||
/** 까닭 · 글줄 열쇠 옮김(null = 까닭 지움 · 글줄 맨 위). */
|
||||
function moveKey<T extends FormulaDoc>(doc: T, from: string, to: string | null): T {
|
||||
let out: T = doc;
|
||||
if (doc.까닭 && from in doc.까닭) {
|
||||
const why = { ...doc.까닭 };
|
||||
const text = why[from];
|
||||
delete why[from];
|
||||
if (to) why[to] = text;
|
||||
out = putField(out, "까닭", why);
|
||||
}
|
||||
if (doc.글줄) {
|
||||
out = { ...out, 글줄: doc.글줄.map((t) => (t.자리 === from ? { ...t, 자리: to ?? "" } : t)) };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -1,328 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete/logic/M02_Sangeun_Sheet_Model.ts
|
||||
* 단가산출(SK) 줄 식 시트 꼴(PLAN 790-1 · 요구 650~652 · 요구서_652 1 장 4) — 셈 답 → 시트 줄 · 산출서 글 · 상태줄 금액.
|
||||
* 줄 갈래 = 근거 글 · 옵션 · 상수 · 표 값 · 계산(바탕 로직 중간) · 자원 · 율(셈 답 줄) · 소계 · 합계
|
||||
* 식 칸 = 읽기 쉬운 이름 꼴 — 자원 · 율 = 서버 텍스트 수식 「이름글」(「= 이름」 뗌 · 「*」 → 「×」) ·
|
||||
* 중간 = 찾기 원문을 서버 별칭 이름으로 바꾼 식 · 엔진 날식(찾기 · 만약 · 로직)이 남으면 식 숨김(표 꼬리표만)
|
||||
* 금액은 늘 서버 셈 — 여기는 줄 모양만 · 화면 · fetch · sessionStorage 없음(`CLAUDE.md` 4장)
|
||||
* ========================================================================== */
|
||||
|
||||
import type {
|
||||
LogicInput,
|
||||
NamedFormula,
|
||||
TextAnswer,
|
||||
TextLine,
|
||||
} from "../../M01_MasterData/M01_MasterData_UI_Logic_Api";
|
||||
import type { CoverRow, SkAnswer } from "./M02_Sangeun_Cover_Model";
|
||||
|
||||
export type SheetKind = "근거" | "옵션" | "상수" | "표" | "계산" | "자원" | "율" | "소계" | "합계";
|
||||
|
||||
export interface SheetItem {
|
||||
kind: SheetKind;
|
||||
이름: string;
|
||||
/** 읽기 쉬운 식 · 빈 글 = 식 없음(숨김 포함) */
|
||||
식: string;
|
||||
값: number | string | null;
|
||||
/** 비목 줄임(노 · 재 · 경) · 별도계상 = 「별도」 */
|
||||
비목: string;
|
||||
/** 표 꼬리표(QC######) */
|
||||
표: string[];
|
||||
/** 자원 · 율 줄 = 고침 열쇠 */
|
||||
row?: CoverRow;
|
||||
/** 옵션 줄 = 입력 꼴 */
|
||||
input?: LogicInput;
|
||||
/** M01 과 다름(덮개 · 더한줄) */
|
||||
changed: boolean;
|
||||
/** 790-2 자리(바탕 cid · 중간 `mid:이름` · `add:#k`) · 없으면 "" */
|
||||
자리: string;
|
||||
}
|
||||
|
||||
export interface SheetBase {
|
||||
출처?: string;
|
||||
원문번호?: string;
|
||||
비고?: string;
|
||||
중간?: NamedFormula[];
|
||||
}
|
||||
|
||||
const SHORT: Record<string, string> = { 노무비: "노", 재료비: "재", 경비: "경" };
|
||||
const TABLE_RE = /[A-Z]{2}\d{6}/g;
|
||||
const RAW_RE = /(찾기|만약|로직)\s*\(/;
|
||||
const NUM_RE = /^-?\d+(\.\d+)?$/;
|
||||
const LOOKUP_RE = /^찾기\([^()]*\)\.[^\s*/+\-()]+$/;
|
||||
|
||||
type Alias = { 이름: string; 원문: string };
|
||||
const aliasesOf = (text: TextAnswer | null): Alias[] =>
|
||||
text?.ok ? (((text as { 별칭?: Alias[] }).별칭 ?? []) as Alias[]) : [];
|
||||
|
||||
/** 엔진 식 → 읽기 쉬운 식(찾기 원문 → 별칭 이름 · 「*」 → 「×」) · 날식이 남으면 "" */
|
||||
export function readable(formula: string, aliases: Alias[]): string {
|
||||
let s = formula.trim();
|
||||
for (const a of [...aliases].sort((x, y) => y.원문.length - x.원문.length))
|
||||
if (a.원문) s = s.split(a.원문).join(a.이름);
|
||||
if (RAW_RE.test(s)) return "";
|
||||
return s.replace(/\s*\*\s*/g, " × ");
|
||||
}
|
||||
|
||||
/** 텍스트 수식 「이름글」 → 식 칸 글(「= 이름」 뗌 · 「*」 → 「×」) */
|
||||
export const nameFormula = (line: TextLine | undefined): string =>
|
||||
(line?.이름글 ?? "").replace(/\s*=\s*[^=]*$/, "").replace(/\s*\*\s*/g, " × ");
|
||||
|
||||
/** 텍스트 수식 줄 cid 표 */
|
||||
export function textLines(text: TextAnswer | null): Map<string, TextLine> {
|
||||
const out = new Map<string, TextLine>();
|
||||
if (text?.ok) for (const g of text.groups) for (const l of g.줄) if (l.cid) out.set(l.cid, l);
|
||||
return out;
|
||||
}
|
||||
|
||||
const tablesOf = (formula: string): string[] => [...new Set(formula.match(TABLE_RE) ?? [])];
|
||||
|
||||
const partTag = (r: CoverRow): string =>
|
||||
r.apart
|
||||
? "별도"
|
||||
: Object.keys(r.parts)
|
||||
.map((k) => SHORT[k] ?? k)
|
||||
.join("·");
|
||||
|
||||
/** 시트 줄 — 근거 · 옵션 · 중간(상수 · 표 · 계산) · 자원 · 율 · 소계 · 합계 */
|
||||
export function sheetItems(o: {
|
||||
base: SheetBase | null;
|
||||
inputs: LogicInput[];
|
||||
values: Record<string, string>;
|
||||
ans: SkAnswer | null;
|
||||
rows: CoverRow[];
|
||||
text: TextAnswer | null;
|
||||
/** 790-3 자리에 새 덮개(상수 · 표값 · 뺌 …)가 있나 — 캐시 문서 기준 */
|
||||
covered?: (spot: string) => boolean;
|
||||
}): SheetItem[] {
|
||||
const out: SheetItem[] = [];
|
||||
const item = (x: Partial<SheetItem> & { kind: SheetKind; 이름: string }): void =>
|
||||
void out.push({ 식: "", 값: null, 비목: "", 표: [], changed: false, 자리: "", ...x });
|
||||
const b = o.base;
|
||||
if (b?.출처) item({ kind: "근거", 이름: b.원문번호 || "", 식: b.출처 });
|
||||
for (const line of (b?.비고 ?? "").split(/\r?\n/).filter((x) => x.trim()))
|
||||
item({ kind: "근거", 이름: "", 식: line.trim() });
|
||||
for (const input of o.inputs)
|
||||
item({ kind: "옵션", 이름: input.이름, 값: o.values[input.이름] ?? "", input });
|
||||
const mid = (o.ans?.중간 ?? {}) as Record<string, unknown>;
|
||||
const aliases = aliasesOf(o.text);
|
||||
const asked = new Set(o.inputs.map((i) => i.이름));
|
||||
for (const f of b?.중간 ?? []) {
|
||||
if (asked.has(f.이름)) continue;
|
||||
const raw = String(f.식 ?? "").trim();
|
||||
const v = mid[f.이름];
|
||||
const 값 = typeof v === "number" || typeof v === "string" ? v : null;
|
||||
const 자리 = `mid:${f.이름}`;
|
||||
const one = { 이름: f.이름, 값, 자리, changed: !!o.covered?.(자리) };
|
||||
if (NUM_RE.test(raw)) item({ ...one, kind: "상수", 값: 값 ?? Number(raw) });
|
||||
else if (LOOKUP_RE.test(raw)) item({ ...one, kind: "표", 표: tablesOf(raw) });
|
||||
else item({ ...one, kind: "계산", 식: readable(raw, aliases), 표: tablesOf(raw) });
|
||||
}
|
||||
const lines = textLines(o.text);
|
||||
for (const pct of [false, true])
|
||||
for (const r of o.rows) {
|
||||
if (r.off || r.pct !== pct) continue;
|
||||
const line = lines.get(r.cid);
|
||||
out.push({
|
||||
kind: pct ? "율" : "자원",
|
||||
이름: r.단위 && !pct ? `${r.이름} (${r.단위})` : r.이름,
|
||||
식: nameFormula(line) || (pct ? `${num(r.수량)} %` : `${num(r.단가)} × ${num(r.수량)}`),
|
||||
값: r.금액,
|
||||
비목: partTag(r),
|
||||
표: tablesOf(line?.이름글 ?? ""),
|
||||
row: r,
|
||||
changed: r.covered.qty || r.covered.price || r.added !== null || !!o.covered?.(r.cid),
|
||||
자리: r.cid,
|
||||
});
|
||||
}
|
||||
const sums = o.ans?.기준 ?? null;
|
||||
for (const k of Object.keys(SHORT))
|
||||
if (sums && k in sums) item({ kind: "소계", 이름: k, 값: sums[k], 비목: SHORT[k] });
|
||||
if (sums) item({ kind: "합계", 이름: "계", 값: sums.계 ?? null });
|
||||
return out;
|
||||
}
|
||||
|
||||
const NUM = new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 4 });
|
||||
export const num = (v: number | null | undefined): string => (v == null ? "—" : NUM.format(v));
|
||||
|
||||
/** 산출서 한 줄 글 — 경쟁사 꼴 「형틀목공 : 275,790 × 5 / 45 = 30,643.3」 · 소계 · 계 */
|
||||
export interface PaperLine {
|
||||
kind: "줄" | "소계" | "합계" | "머리" | "글";
|
||||
글: string;
|
||||
}
|
||||
export function paperLines(
|
||||
text: TextAnswer | null,
|
||||
rows: CoverRow[],
|
||||
ans: SkAnswer | null,
|
||||
notes: NoteLine[] = [],
|
||||
): PaperLine[] {
|
||||
if (!ans) return [];
|
||||
const out: PaperLine[] = [];
|
||||
const by = new Map(rows.map((r) => [r.cid, r]));
|
||||
const sums = ans.기준 ?? {};
|
||||
const groups = text?.ok ? text.groups : [];
|
||||
const shown = new Set(groups.flatMap((g) => g.줄.flatMap((l) => (l.옵션꺼짐 ? [] : [l.cid]))));
|
||||
const note = (spot: string): PaperLine[] =>
|
||||
notes.filter((x) => x.글 && x.자리 === spot).map((x) => ({ kind: "글", 글: x.글 }));
|
||||
out.push(
|
||||
...notes
|
||||
.filter((x) => x.글 && !shown.has(x.자리))
|
||||
.map((x) => ({ kind: "글" as const, 글: x.글 })),
|
||||
);
|
||||
for (const g of groups) {
|
||||
out.push({ kind: "머리", 글: g.비목 });
|
||||
for (const l of g.줄) {
|
||||
if (l.옵션꺼짐) continue;
|
||||
const r = l.cid ? by.get(l.cid) : undefined;
|
||||
const body = l.글
|
||||
? l.글.replace(/\s*\*\s*/g, " × ")
|
||||
: r
|
||||
? `${r.pct ? `${num(r.수량)} %` : `${num(r.단가)} × ${num(r.수량)}`} = ${num(r.금액)}`
|
||||
: num(l.금액);
|
||||
out.push({ kind: "줄", 글: `${l.이름} : ${body}` }, ...(l.cid ? note(l.cid) : []));
|
||||
}
|
||||
const sub = sums[g.비목] ?? g.소계;
|
||||
out.push({ kind: "소계", 글: `${g.비목} 소계 = ${num(sub)}${g.끝수 ? ` (${g.끝수})` : ""}` });
|
||||
}
|
||||
out.push({ kind: "합계", 글: `계 = ${num(sums.계 ?? null)}` });
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 상태줄 금액 셋 — M01 원래(790-2 셈 답 `원래.계` · 없으면 바탕 셈 계) · 지금 · 고치기 전(화면 캐시) */
|
||||
export interface SheetStatus {
|
||||
원래: number | null;
|
||||
지금: number | null;
|
||||
전: number | null;
|
||||
}
|
||||
export function statusOf(
|
||||
ans: SkAnswer | null,
|
||||
bare: SkAnswer | null,
|
||||
before: number | null,
|
||||
): SheetStatus {
|
||||
const origin = (ans as (SkAnswer & { 원래?: { 계?: number } | null }) | null)?.원래?.계;
|
||||
return {
|
||||
원래: origin ?? bare?.기준?.계 ?? ans?.기준?.계 ?? null,
|
||||
지금: ans?.기준?.계 ?? null,
|
||||
전: before,
|
||||
};
|
||||
}
|
||||
|
||||
/** 차이 글 「+383」 · 「−1,200」 · 같으면 "" */
|
||||
export const diffText = (now: number | null, was: number | null): string =>
|
||||
now == null || was == null || now === was
|
||||
? ""
|
||||
: `${now > was ? "+" : "−"}${NUM.format(Math.abs(now - was))}`;
|
||||
|
||||
// ── 790-3 숫자 넓힘 화면 이음(790-2 셈 답 `시트` · 문서 `글줄`) ──────────────
|
||||
|
||||
/** 글 줄(셈 없음) — 자리 뒤 · "" = 맨 위 */
|
||||
export interface NoteLine {
|
||||
자리: string;
|
||||
글: string;
|
||||
}
|
||||
|
||||
/** 셈 답 `시트.자리[자리]`(790-2) */
|
||||
export interface SpotInfo {
|
||||
갈래: string;
|
||||
이름: string;
|
||||
식?: string;
|
||||
상수?: { n: number; 값: number | null; 원값: number | null }[];
|
||||
표값?: { n: number; 글: string; 값: unknown; 원값: unknown }[];
|
||||
원래?: number | null;
|
||||
지금?: unknown;
|
||||
덮개?: string[];
|
||||
까닭?: string | null;
|
||||
M01바뀜?: boolean;
|
||||
/** 792-1 — 지금 식 · M01 식(이름 꼴) · 식 덮개 있음 */
|
||||
식글?: string;
|
||||
원식글?: string;
|
||||
식덮개?: boolean;
|
||||
}
|
||||
|
||||
export interface SheetSpots {
|
||||
자리: Record<string, SpotInfo>;
|
||||
글줄: NoteLine[];
|
||||
짝잃음: { cid: string; 까닭: string }[];
|
||||
/** 792-1 식 칸에 쓸 수 있는 이름 */
|
||||
이름표?: { 글: string; 갈래: string }[];
|
||||
}
|
||||
|
||||
export const spotsOf = (ans: SkAnswer | null): SheetSpots | null =>
|
||||
((ans as { 시트?: SheetSpots } | null)?.시트 ?? null) || null;
|
||||
|
||||
export type FormulaPart =
|
||||
{ t: "글"; 글: string } | { t: "수"; n: number } | { t: "표"; n: number; 표: string[] };
|
||||
|
||||
const IDENT = /^[\p{L}_][\p{L}\p{N}_]*/u;
|
||||
const DIGITS = /^\d+(\.\d+)?/;
|
||||
|
||||
/** 식 글 → 숫자 자리(왼쪽부터 · 따옴표 글 · 이름 안 숫자 뺌) — 서버 `scan` 숫자 차례와 같음 */
|
||||
export function numSpots(s: string): [number, number][] {
|
||||
const out: [number, number][] = [];
|
||||
let i = 0;
|
||||
while (i < s.length) {
|
||||
const c = s[i];
|
||||
if (c === "'" || c === '"') {
|
||||
const j = s.indexOf(c, i + 1);
|
||||
i = j < 0 ? s.length : j + 1;
|
||||
continue;
|
||||
}
|
||||
const rest = s.slice(i);
|
||||
const id = IDENT.exec(rest);
|
||||
const d = id ? null : DIGITS.exec(rest);
|
||||
if (d) out.push([i, i + d[0].length]);
|
||||
i += id ? id[0].length : d ? d[0].length : 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 엔진 식을 조각으로 — 글 · 숫자(n) · 표 찾기(n) · 서버 차례와 안 맞으면 null(숫자 칸 안 그림) */
|
||||
export function formulaParts(info: SpotInfo | undefined): FormulaPart[] | null {
|
||||
const s = info?.식 ?? "";
|
||||
const nums = info?.상수 ?? [];
|
||||
const finds = info?.표값 ?? [];
|
||||
if (!s || (!nums.length && !finds.length)) return null;
|
||||
const at = numSpots(s);
|
||||
if (at.length !== nums.length) return null;
|
||||
if (at.some(([a, b], i) => Number(s.slice(a, b)) !== Number(nums[i].원값))) return null;
|
||||
const cuts: { a: number; b: number; p: FormulaPart }[] = [];
|
||||
let from = 0;
|
||||
for (const f of finds) {
|
||||
const a = s.indexOf(f.글, from);
|
||||
if (a < 0) return null;
|
||||
from = a + f.글.length;
|
||||
cuts.push({ a, b: from, p: { t: "표", n: f.n, 표: tablesOf(f.글) } });
|
||||
}
|
||||
at.forEach(([a, b], n) => {
|
||||
if (!cuts.some((c) => c.a <= a && b <= c.b)) cuts.push({ a, b, p: { t: "수", n } });
|
||||
});
|
||||
cuts.sort((x, y) => x.a - y.a);
|
||||
const out: FormulaPart[] = [];
|
||||
const text = (x: string): void => {
|
||||
const 글 = x.replace(/\s*\*\s*/g, " × ");
|
||||
if (글) out.push({ t: "글", 글 });
|
||||
};
|
||||
let pos = 0;
|
||||
for (const c of cuts) {
|
||||
text(s.slice(pos, c.a));
|
||||
out.push(c.p);
|
||||
pos = c.b;
|
||||
}
|
||||
text(s.slice(pos));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 뺀 더한줄 되살림(차례 k 에 다시 끼움) 뒤 — 까닭 · 글줄 열쇠 `add:#j`(j ≥ k) 한 칸 밂 · `dropAddKeys` 거꾸로 */
|
||||
export function liftAddKeys<T extends { 까닭?: Record<string, string>; 글줄?: NoteLine[] }>(
|
||||
doc: T,
|
||||
k: number,
|
||||
): T {
|
||||
const move = (key: string): string =>
|
||||
key.replace(/^add:#(\d+)/, (all, j: string) =>
|
||||
Number(j) >= k ? `add:#${Number(j) + 1}` : all,
|
||||
);
|
||||
const out = { ...doc };
|
||||
if (doc.까닭)
|
||||
out.까닭 = Object.fromEntries(Object.entries(doc.까닭).map(([x, v]) => [move(x), v]));
|
||||
if (doc.글줄) out.글줄 = doc.글줄.map((x) => ({ ...x, 자리: move(x.자리) }));
|
||||
return out;
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
/* PLAN 748-2 헬퍼 — 텍스트 수식 조각 나누기(`M01_MasterData_UI_LogicLab_FormulaTokens.ts` 순수 셈).
|
||||
* 짝 = `test_748_2_formula_tokens.py`. 결과 = 표준출력 JSON.
|
||||
*/
|
||||
const path = require("path");
|
||||
const { makeLoader } = require("./helper_ts_stub_loader.cjs");
|
||||
|
||||
const { load } = makeLoader({ root: path.join(__dirname, "..", "..") });
|
||||
const m = load("M01_MasterData", "M01_MasterData_UI_LogicLab_FormulaTokens.ts");
|
||||
const hot = (list) => list.filter((t) => t.hot).map((t) => t.text);
|
||||
const name = "굴착기노무비단가 * 뒤채움및다짐소형장비.굴착기 * 8 / 시공량 = 굴착기노무비";
|
||||
const value = "35,412 * 1 * 8 / 110 = 2,575";
|
||||
const out = {
|
||||
nameHot: hot(m.nameTokens(name)),
|
||||
nameAll: m.nameTokens(name).map((t) => t.text),
|
||||
fnCall: hot(m.nameTokens("올림(단가) + 1.5 - 보정")),
|
||||
valueHot: hot(m.valueTokens(value)),
|
||||
valueAll: m.valueTokens("13,873 = 13,873 · 21 %").map((t) => t.text),
|
||||
empty: [m.nameTokens("").length, m.valueTokens("").length],
|
||||
};
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -1,6 +1,6 @@
|
||||
"""714-2 공용 단가산출 계산서 지킴 시험 — 글 · 순수 함수(화면 · 서버 없음).
|
||||
|
||||
- 704-2 M02 계산서 보기 · M01 「인력·재료·경비」 보기가 한 벌(`A00_Common/calc_sheet.ts`)을 씀
|
||||
- 704-2 M02 계산서 보기 · B09 단가산출근거가 한 벌(`A00_Common/calc_sheet.ts`)을 씀(799-2 — M01 「인력·재료·경비」 보기는 걷음)
|
||||
- 계산서는 부품 상자만(날 단추 · 입력 · 표 없음) · 화면에서 다시 셈 안 함(값 = 서버)
|
||||
"""
|
||||
|
||||
@@ -21,10 +21,9 @@ def _text(p: Path) -> str:
|
||||
|
||||
def test_한_벌을_씀():
|
||||
m02 = _text(ROOT / "M02_MasterTemplete" / "M02_MasterTemplete_Labor_Sheet.ts")
|
||||
est = _text(ROOT / "M01_MasterData" / "M01_MasterData_UI_LogicLab_Est.ts")
|
||||
detail = _text(ROOT / "M01_MasterData" / "M01_MasterData_UI_LogicLab_Detail.ts")
|
||||
b09 = _text(ROOT / "B09_Estimation" / "B09_Estimation_UI_Tabs_Sangeun_Sheet.ts")
|
||||
assert "calcSheetRows(" in m02 and "createCalcFormulaRow" not in m02
|
||||
assert "calcSheetRows(" in est and "buildEst(" in detail
|
||||
assert "calcSheetRows(" in b09
|
||||
|
||||
|
||||
def test_부품_상자만():
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
"""714-3 텍스트 수식 입력 단추 지킴 시험 — 줄이 쓰는 입력 고르기(순수 함수 · node) + 이음 글.
|
||||
|
||||
`M01_MasterData_UI_LogicLab_Formula.ts` 「순수 셈」 구간만 떼어 돌림(화면 · 서버 없음).
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
M01 = Path(__file__).resolve().parents[2] / "M01_MasterData"
|
||||
FX = M01 / "M01_MasterData_UI_LogicLab_Formula.ts"
|
||||
|
||||
pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
||||
|
||||
|
||||
def _run(body: str) -> object:
|
||||
text = FX.read_text(encoding="utf-8")
|
||||
pure = text[text.index("/* ── 순수 셈: 줄이 쓰는 입력 ── */") : text.index("/* ── 순수 셈 끝 ── */")]
|
||||
out = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript", "-e", pure + "\n" + body],
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert out.returncode == 0, out.stderr
|
||||
return json.loads(out.stdout)
|
||||
|
||||
|
||||
def test_표_행_열쇠와_이름_조각():
|
||||
got = _run(
|
||||
"""
|
||||
const line = {
|
||||
조각: [
|
||||
{ kind: "표", 글: "25 · 깬잡석", 참조: { 키: "QF1", 행: { 뒷길이: 25, 돌: "깬잡석", 줄번호: 3 } } },
|
||||
{ kind: "기호", 글: "*" },
|
||||
{ kind: "값", 글: "두께", 값: "20" },
|
||||
{ kind: "값", 글: "0.009" },
|
||||
{ kind: "값", 글: "뒷길이", 값: "99" },
|
||||
],
|
||||
요소조각: [{ kind: "표", 글: "x", 참조: { 키: "QF2", 행: 4 } }],
|
||||
};
|
||||
console.log(JSON.stringify(usedInputs(line, ["뒷길이", "돌", "두께", "쌓기"])));
|
||||
"""
|
||||
)
|
||||
# 처음 나온 값 · 입력 아닌 열쇠(줄번호) · 이름 없는 수(0.009) · 행 번호(4) 는 뺌
|
||||
assert got == [["뒷길이", "25"], ["돌", "깬잡석"], ["두께", "20"]]
|
||||
|
||||
|
||||
def test_빈_줄():
|
||||
assert _run('console.log(JSON.stringify(usedInputs({}, ["a"])))') == []
|
||||
|
||||
|
||||
def test_이음_시험_입력만_고침():
|
||||
lab = (M01 / "M01_MasterData_UI_LogicLab.ts").read_text(encoding="utf-8")
|
||||
detail = (M01 / "M01_MasterData_UI_LogicLab_Detail.ts").read_text(encoding="utf-8")
|
||||
# 텍스트 수식 고침 = 시험 계산 입력 + 다시 셈(흐름 예시값과 같은 길) · 캐시(drafts)에 안 들어감
|
||||
assert "onInput: (name, v) => syncLeft(current, { [name]: v }, true)" in lab
|
||||
assert "onInput: ctx.onInput" in detail
|
||||
fx = FX.read_text(encoding="utf-8")
|
||||
assert "touch" not in fx and "drafts" not in fx
|
||||
@@ -104,8 +104,6 @@ dropLine(doc, by("add:#0"));
|
||||
out.dropAdded = doc.더한줄.length;
|
||||
const covered = { 덮개: { "labor:A#0": { qty: 0.3, 원값: { qty: 0.09, 단가: 1000 } } } };
|
||||
out.revised = rowsOf(bare, bare, covered).find((r) => r.cid === "labor:A#0").revised;
|
||||
out.sheet = sheetGroups(rowsOf(bare, bare, {}))
|
||||
.map((g) => [g.비목, g.소계, g.줄.map((x) => x.금액)]);
|
||||
// 공용 상세 줄 — 바탕 줄 그대로 · 뺀 줄 없음 · 더한줄 · 별도계상 빠진 줄 = 빈 칸 · 덧줄
|
||||
const base = { 호표: [{ 종류: "인력", 요소: "A", 이름: "석공", 수량: "0.1*x" },
|
||||
{ 종류: "로직", 요소: "로직(G)", 수량: "2" }, { 종류: "재료", 요소: "M", 수량: "0.5" }],
|
||||
@@ -146,12 +144,6 @@ console.log(JSON.stringify(out));
|
||||
assert got["drop"] == [["labor:A#0"], None]
|
||||
assert got["restore"] == [] and got["dropAdded"] == 0
|
||||
assert got["revised"] is True
|
||||
# 계산서 = 표 줄 그대로 · 별도계상 재료 금액 없음(계에 안 듦)
|
||||
assert got["sheet"] == [
|
||||
["노무비", 160, [100, 60]],
|
||||
["재료비", 30, [30, None]],
|
||||
["경비", 10, [10]],
|
||||
]
|
||||
ho, extra, cids, lines, covers = got["view"]
|
||||
assert ho == [
|
||||
["인력", "A", "0.1*x", None], # 바탕 줄 그대로(식)
|
||||
@@ -173,11 +165,9 @@ def test_상세_부품_길_부품상자():
|
||||
cover = _text(M02 / "M02_MasterTemplete_Sangeun_Cover.ts")
|
||||
assert "mountSangeunDetail(host, doc" in view and "createEmpty" not in view
|
||||
assert 'read: "/m02/logic-lib"' in detail and "setLogicApiPaths(LIB)" in detail
|
||||
assert (
|
||||
"buildLabDetail(editor" in detail
|
||||
and "cover: { ho, est, open: lineOpener(hoCtx) }" in detail
|
||||
)
|
||||
assert "buildCalc(calcHost" in detail and "openSangeunLinePicker(scope" in detail
|
||||
assert "buildLabDetail(editor" in detail and "cover: { ho, open: lineOpener(hoCtx) }" in detail
|
||||
assert "buildCalc" not in detail # 799-2 시험 계산 보기 걷음
|
||||
assert "openSangeunLinePicker(scope" in detail
|
||||
assert "if (mine !== seq || !alive) return" in detail # 최신 응답만
|
||||
# 724-1 — 단가산출 상세 = 공용 상세(Ho) · 고침 고리 = SK 덮개 · 옛 고침 표 걷음
|
||||
assert "...buildHo(hoCtx)" in detail and 'scope: "m02",' in detail
|
||||
@@ -188,7 +178,7 @@ def test_상세_부품_길_부품상자():
|
||||
assert "<button" not in s and "<input" not in s and "<table" not in s
|
||||
assert "document.createElement" not in s and "sessionStorage." not in s
|
||||
lab = _text(ROOT / "M01_MasterData" / "M01_MasterData_UI_LogicLab_Detail.ts")
|
||||
assert "ctx.cover?.est ??" in lab and "ctx.cover.ho" in lab
|
||||
assert "ctx.cover.ho" in lab and "cover?.est" not in lab
|
||||
side = _text(ROOT / "M01_MasterData" / "M01_MasterData_UI_Side.ts")
|
||||
modal = _text(ROOT / "M01_MasterData" / "M01_MasterData_UI_LogicLab_Modal.ts")
|
||||
assert "export const hasMasterJump" in side and "go.hidden = !hasMasterJump()" in modal
|
||||
|
||||
@@ -27,8 +27,8 @@ def _text(path: Path) -> str:
|
||||
def test_721_1_단추_이름과_자리():
|
||||
detail = _text(M02 / "M02_MasterTemplete_Sangeun_Detail.ts")
|
||||
assert (
|
||||
"children: [sheet.picker, addButton, rateButton]" in detail
|
||||
) # 750-2 안내 걷음 · 778-4 율 줄 추가 · 790-1 보기 고르기 = 시트 탭 부품
|
||||
"children: [tabs.picker, addButton, rateButton]" in detail
|
||||
) # 750-2 안내 걷음 · 778-4 율 줄 추가 · 799-2 보기 고르기 = 수식 · 단가산출 상세 (`_Sangeun_Tabs`)
|
||||
assert "M02_SangeunPickBase" not in detail # 727-2 단추 걷음
|
||||
locale = _text(ROOT / "ui_template" / "ui_template_locale_m2.ts")
|
||||
assert "M02_SangeunPickBase" not in locale
|
||||
|
||||
@@ -63,9 +63,9 @@ def test_상세가_공용_부품을_씀():
|
||||
def test_시험_고침은_캐시_밖():
|
||||
lab = _text(M01 / "M01_MasterData_UI_LogicLab.ts")
|
||||
# 시험 계산에만 덮은 사본 · 고침이 있으면 줄을 보냄 · drafts(캐시)에는 원본 줄
|
||||
assert ": trial.apply(row);" in lab
|
||||
assert "=> trial.apply(row);" in lab
|
||||
assert "dirty: () => current.id in drafts || trial.has(current.row)," in lab
|
||||
assert "onTrial: drawCalc," in lab
|
||||
assert "onTrial: drawCalc," in lab and "rerun: () => void drive.run()," in lab # 799-2 셈 구동
|
||||
cover = _text(COVER)
|
||||
assert "drafts" not in cover and "touch" not in cover and "/save" not in cover
|
||||
modal = _text(M01 / "M01_MasterData_UI_LogicLab_Modal.ts")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""734-1 계산식 열 단추 지킴 시험 — 낮은 단(20) · 갈래 색(옵션값 · 요소 연결) · 풍선 글(화면 · 서버 없음)."""
|
||||
"""734-1 계산식 열 단추 지킴 시험 — 낮은 단(20) · 갈래 색 · 풍선 글(화면 · 서버 없음)."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
@@ -15,11 +15,6 @@ def test_알약은_낮은_단과_요소연결_갈래():
|
||||
assert 'size: 20, tone: "link"' in s and 'tl("Pill_Tone")' in s
|
||||
|
||||
|
||||
def test_입력칩은_낮은_단과_옵션_갈래():
|
||||
s = _t(M01 / "M01_MasterData_UI_LogicLab_Formula.ts")
|
||||
assert "size: 20," in s and 'tone: "option"' in s and 'tl("Var_Tone")' in s
|
||||
|
||||
|
||||
def test_갈래_색은_공통_값만():
|
||||
css = _t(ROOT / "ui_template" / "ui_template_button.css")
|
||||
head = css[css.index("갈래 색(tone)") :]
|
||||
@@ -30,4 +25,4 @@ def test_갈래_색은_공통_값만():
|
||||
|
||||
def test_풍선_글_두_갈래():
|
||||
s = _t(M01 / "M01_MasterData_UI_LogicLab_Text.ts")
|
||||
assert '"요소 연결"' in s and '["옵션"' in s
|
||||
assert '"요소 연결"' in s # 799-2 — 입력 칩(옵션 풍선)은 텍스트 수식과 함께 걷음
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""737 단가산출 상세 — 옵션 칸 격자 · 합계 한 줄 바닥 · 두 보기 같은 제목 · 비목 상자 지킴 시험(글 대조 · 서버 · 화면 없음)."""
|
||||
"""737 단가산출 상세 — 옵션 칸 격자 · 합계 한 줄 바닥 · 비목 상자 지킴 시험(글 대조 · 서버 · 화면 없음) · 799-2 텍스트 수식 보기는 걷음."""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
@@ -8,8 +8,6 @@ M01 = ROOT / "M01_MasterData"
|
||||
SHEET_CSS = (ROOT / "A00_Common" / "calc_sheet.css").read_text(encoding="utf-8")
|
||||
STYLE = (M01 / "M01_MasterData_UI_LogicLab_Style.css").read_text(encoding="utf-8")
|
||||
HO = (M01 / "M01_MasterData_UI_LogicLab_Ho.ts").read_text(encoding="utf-8")
|
||||
DETAIL = (M01 / "M01_MasterData_UI_LogicLab_Detail.ts").read_text(encoding="utf-8")
|
||||
FX = (M01 / "M01_MasterData_UI_LogicLab_Formula.ts").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_옵션_격자_폭_따라_6_4_3_2_1():
|
||||
@@ -29,21 +27,10 @@ def test_합계_한_줄_바닥_고정():
|
||||
assert "overflow: clip" in STYLE # 메인 칸이 굴림 칸이면 바닥에 안 붙음
|
||||
foot_ts = (M01 / "M01_MasterData_UI_LogicLab_Foot.ts").read_text(encoding="utf-8")
|
||||
assert 'className: "m01lab__foot"' in foot_ts # 748-3 두 보기가 같은 바닥 줄 부품
|
||||
assert "buildFoot(sums, calculated, total)" in HO and "buildFoot(sums, calculated)" in FX
|
||||
assert "buildFoot(sums, calculated, total)" in HO
|
||||
assert 'data-cols="3"' in STYLE and ".m01lab__ho-box + .m01lab__ho-box" in STYLE
|
||||
|
||||
|
||||
def test_공통_옵션은_모든_박스에_같이():
|
||||
assert "optionRows([...usesOf(b), ...common], ctx)" in HO
|
||||
assert "commonCard(" in HO and "boxes.length ||" in HO # 박스 없을 때만 따로 카드
|
||||
|
||||
|
||||
def test_두_보기_같은_제목_지금_입력_꼬리표_없음():
|
||||
assert 'section(tl("Formula_Title")' not in DETAIL
|
||||
assert 'section(tl("Ho_Title"), [formula])' in DETAIL
|
||||
assert "nowInputs(ctx.values), ...blocks" not in DETAIL
|
||||
|
||||
|
||||
def test_텍스트_수식은_비목_상자_옵션은_상자_위():
|
||||
assert "createCard({" in FX and "fxBox" in FX and "optionGrid(" in FX
|
||||
assert "...vars(line)" not in FX # 줄마다 붙던 옵션 꼬리표 걷음
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
"""748-2 지킴 — 텍스트 수식 한 줄을 변수 이름 · 값 하나하나로 나눔(누르면 줄 모달)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def test_변수_이름_값_조각():
|
||||
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", str(HERE / "helper_748_2_formula_tokens.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
out = json.loads(proc.stdout)
|
||||
# 이름식 — 변수 이름(점 든 표 찾기 이름 포함)만 · 수 · 기호는 아님
|
||||
assert out["nameHot"] == [
|
||||
"굴착기노무비단가",
|
||||
"뒤채움및다짐소형장비.굴착기",
|
||||
"시공량",
|
||||
"굴착기노무비",
|
||||
]
|
||||
assert "8" in out["nameAll"] and "=" in out["nameAll"]
|
||||
# 함수 이름(「이름(」)은 변수가 아님 · 수 · 홀로 선 빼기는 아님
|
||||
assert out["fnCall"] == ["단가", "보정"]
|
||||
# 값식 — 수 하나하나(콤마 · 소수점 포함) · 기호는 그대로
|
||||
assert out["valueHot"] == ["35,412", "1", "8", "110", "2,575"]
|
||||
assert out["valueAll"][:3] == ["13,873", "=", "13,873"]
|
||||
assert out["empty"] == [0, 0]
|
||||
@@ -1,4 +1,4 @@
|
||||
"""750-2 화면 — 네 보기가 SK 셈 답 하나로(바탕 M01 로직 따로 셈 길 걷음) · 재료 고름 = 문서 `고름` ·
|
||||
"""750-2 화면 — 수식 · 단가산출 상세가 SK 셈 답 하나로(바탕 M01 로직 따로 셈 길 걷음 · 799-2 시험 계산 보기는 걷음) · 재료 고름 = 문서 `고름` ·
|
||||
읽는 식 줄 = cid 로 이음 · 옵션 바꿈 = SK 셈 한 번(원문 대조는 서버 test_750_1)."""
|
||||
|
||||
from pathlib import Path
|
||||
@@ -12,7 +12,7 @@ def _ts(rel: str) -> str:
|
||||
|
||||
def test_detail_uses_only_sk_answer():
|
||||
ts = _ts("M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts")
|
||||
assert "text = got.글" in ts and "answer: async" in ts and "shown: shownOf" in ts
|
||||
assert "text = got.글" in ts and "answer: async" not in ts and "buildCalc" not in ts
|
||||
assert "onText" not in ts and "onLines" not in ts # 바탕 `/calc` · `/text` 부름 걷음
|
||||
assert "dropText" not in ts and "baseCovers" not in ts and "applyCovers" not in ts
|
||||
assert "calcSangeun(scope, plain, values, false)" in ts # 바탕 원값 셈 = 보기 덧칸 없이
|
||||
@@ -26,10 +26,11 @@ def test_text_lines_joined_by_cid():
|
||||
assert "cid?: string" in api and "나눔?: number" in api
|
||||
|
||||
|
||||
def test_calc_view_answer_hook_skips_logic_lib():
|
||||
def test_calc_drive_is_screenless_and_m02_has_no_calc_hook():
|
||||
ts = _ts("M01_MasterData/M01_MasterData_UI_Logic_Calc.ts")
|
||||
assert "ctx.onText && !ctx.answer" in ts # 미리보기 안 부름
|
||||
assert "if (ctx.answer)" in ts and "runCalc" in ts # M01 옛 길 그대로
|
||||
assert "ctx.answer" not in ts and "CalcShown" not in ts # 799-2 M02 시험 계산 길 걷음
|
||||
drive = _ts("M01_MasterData/M01_MasterData_UI_Logic_Drive.ts")
|
||||
assert "runCalc(body)" in drive and "document." not in drive # 셈 구동 = 화면 없이
|
||||
|
||||
|
||||
def test_752_1_source_panel_only_on_sangeun_screen():
|
||||
@@ -71,9 +72,7 @@ const line = (cid, 이름, 수량, 금액, 꺼짐) => ({ cid, 이름, 단위: "
|
||||
const ans = { 기준: null, 짝잃음: [], 바탕판: "x", 정체: ["material:A#0", "material:B#0"],
|
||||
줄: [line("material:A#0", "폼타이 12.7", 0, 0, true), line("material:B#0", "합판", 1, 100, false)] };
|
||||
const rows = rowsOf(ans, null, {});
|
||||
const groups = sheetGroups(rows);
|
||||
console.log(JSON.stringify({ off: rows.map((r) => r.off),
|
||||
sheet: groups.map((g) => [g.비목, g.소계, g.줄.map((x) => [x.이름, x.옵션꺼짐 ?? false, x.금액])]) }));
|
||||
console.log(JSON.stringify({ off: rows.map((r) => r.off) }));
|
||||
""",
|
||||
]
|
||||
)
|
||||
@@ -85,14 +84,10 @@ console.log(JSON.stringify({ off: rows.map((r) => r.off),
|
||||
assert out.returncode == 0, out.stderr
|
||||
got = json.loads(out.stdout)
|
||||
assert got["off"] == [True, False]
|
||||
assert got["sheet"] == [["재료비", 100, [["폼타이 12.7", True, 0], ["합판", False, 100]]]]
|
||||
sheet = (ROOT / "A00_Common" / "calc_sheet.ts").read_text("utf-8")
|
||||
assert "b.줄.filter(shown)" in sheet and "g.줄.filter(shown)" in sheet
|
||||
ho = (ROOT / "M01_MasterData" / "M01_MasterData_UI_LogicLab_Ho.ts").read_text("utf-8")
|
||||
assert "옵션꺼짐" in ho
|
||||
assert "옵션꺼짐" in (
|
||||
ROOT / "M01_MasterData" / "M01_MasterData_UI_LogicLab_Formula.ts"
|
||||
).read_text("utf-8")
|
||||
assert "옵션꺼짐" in (ROOT / "M01_MasterData" / "M01_MasterData_UI_Logic_Calc.ts").read_text(
|
||||
"utf-8"
|
||||
)
|
||||
|
||||
@@ -1,312 +0,0 @@
|
||||
"""790-1 M02 단가산출 줄 식 시트(E1) — 상세 첫 탭 「시트」 · 줄 갈래 · 산출서 보기 · 부품 상자만.
|
||||
|
||||
- 정적: 시트 화면 = 부품 상자(calc_row 시트 줄 · 입력 · 고르기 · 단추 · 배지 · 글)만 · 날 button · input 0 ·
|
||||
상세 탭 등록(시트 첫 칸 · 옛 보기 넷 그대로) · 고침 = SK 덮개 · 뺀줄 · 되살림 길(모델 안 바꿈)
|
||||
- 순수 셈(node strip-types): 줄 갈래(근거 · 옵션 · 상수 · 표 · 계산 · 자원 · 율 · 소계 · 합계) · 엔진 날식 숨김 ·
|
||||
읽기 쉬운 식 · 산출서 한 줄 글 · 상태줄 금액 셋(790-2 `원래` 있으면 그 값)
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
M02 = ROOT / "M02_MasterTemplete"
|
||||
SHEET = M02 / "M02_MasterTemplete_Sangeun_Sheet.ts"
|
||||
MODEL = M02 / "logic" / "M02_Sangeun_Sheet_Model.ts"
|
||||
DETAIL = M02 / "M02_MasterTemplete_Sangeun_Detail.ts"
|
||||
ROW = ROOT / "ui_template" / "ui_template_calc_row.ts"
|
||||
IMPORTS = re.compile(r"^import[^;]*;\r?\n", re.MULTILINE)
|
||||
|
||||
|
||||
def _text(p: Path) -> str:
|
||||
return p.read_text("utf-8")
|
||||
|
||||
|
||||
def test_부품_상자만_날_요소_없음():
|
||||
for p in (SHEET, MODEL, SHEET.with_suffix(".css")):
|
||||
s = _text(p)
|
||||
assert "<button" not in s and "<input" not in s and "<table" not in s
|
||||
assert "document.createElement" not in s and "sessionStorage." not in s
|
||||
assert len(s.splitlines()) < 700
|
||||
sheet = _text(SHEET)
|
||||
for part in ("createCalcSheetRow", "createInputField", "createSelectField", "createButton"):
|
||||
assert f"{part}" in sheet
|
||||
assert "fetch(" not in _text(MODEL) and "document." not in _text(MODEL)
|
||||
css = _text(SHEET.with_suffix(".css"))
|
||||
assert not re.search(r"#[0-9a-fA-F]{3,6}\b|rgb\(", css) # 색 = 공통 값만
|
||||
row = _text(ROW)
|
||||
assert "export function createCalcSheetRow(" in row and "ui-calc-row--sheet" in row
|
||||
|
||||
|
||||
def test_상세_탭_등록_시트_첫_칸():
|
||||
detail = _text(DETAIL)
|
||||
assert "createSangeunSheet(editor," in detail and "createViewPicker" not in detail
|
||||
assert "children: [sheet.picker, addButton, rateButton]" in detail
|
||||
assert re.search(r"sheet\.host,\s+editor,", detail) # 시트 그릇 = 옛 보기 그릇 앞
|
||||
assert (
|
||||
"sheet.draw({ doc, ans, bare, base: row, text, rows, view, values, removed, fail });"
|
||||
in detail
|
||||
)
|
||||
assert "sheet.saved();" in detail
|
||||
assert len(detail.splitlines()) <= 575
|
||||
sheet = _text(SHEET)
|
||||
opts = re.search(r"options: \[\s*\{ value: \"sheet\"", sheet)
|
||||
assert opts, "시트가 보기 고르기 첫 칸"
|
||||
for v in ('"ho"', '"est"', '"calc"', '"formula"', '"paper"'):
|
||||
assert f"value: {v}" in sheet # 옛 보기 넷 그대로 · 산출서 전환
|
||||
# 고침 길 = 지금 SK 모델(덮개 · 뺀줄 · 되살림) · 다시 셈 = 상세 touch(서버 셈 · 최신 응답만)
|
||||
for call in (
|
||||
"setLineCover(d.doc, r,",
|
||||
"dropLine(d.doc, r)",
|
||||
"restoreLine(d.doc, cid)",
|
||||
"opt.touch()",
|
||||
):
|
||||
assert call in sheet
|
||||
assert (
|
||||
"addLine: () => addButton.click()" in detail
|
||||
and "addRate: () => rateButton.click()" in detail
|
||||
)
|
||||
|
||||
|
||||
FIX = {
|
||||
"base": {
|
||||
"출처": "건설표준품셈 공통 6-3-1",
|
||||
"원문번호": "6-3-1",
|
||||
"비고": "※ 높이 3.5 m 이하",
|
||||
"입력": [{"이름": "사용횟수", "고르기": ["3회", "6회"]}],
|
||||
"중간": [
|
||||
{"이름": "상수", "식": "17"},
|
||||
{"이름": "시공량", "식": "찾기(QC000431, 유형=유형).시공량"},
|
||||
{"이름": "형틀목공", "식": "찾기(QC000431, 유형=유형).형틀목공 / 시공량"},
|
||||
{"이름": "합판율", "식": "만약(사용횟수 == '1회', 1, 찾기(QC000428, 사용횟수=사용횟수).합판 / 100)"},
|
||||
],
|
||||
},
|
||||
"values": {"사용횟수": "6회"},
|
||||
"ans": {
|
||||
"기준": {"노무비": 38290, "재료비": 12747, "경비": 382, "계": 51419},
|
||||
"중간": {"상수": 17, "시공량": 45, "형틀목공": 0.1111, "합판율": 0.327},
|
||||
"원래": None,
|
||||
},
|
||||
"rows": [
|
||||
{"cid": "labor:LB000007#0", "이름": "형틀목공", "단위": "인", "수량": 0.1111, "단가": 275790,
|
||||
"금액": 30643.3, "parts": {"노무비": 30643.3}, "pct": False, "added": None, "apart": False,
|
||||
"covered": {"qty": False, "price": False}, "off": False},
|
||||
{"cid": "material:폼타이#0", "이름": "폼타이", "단위": "본", "수량": 0, "단가": 0, "금액": 0,
|
||||
"parts": {"재료비": 0}, "pct": False, "added": None, "apart": False,
|
||||
"covered": {"qty": False, "price": False}, "off": True},
|
||||
{"cid": "add:#0", "이름": "보통인부", "단위": "인", "수량": 0.1, "단가": 172068, "금액": 17206.8,
|
||||
"parts": {"노무비": 17206.8}, "pct": False, "added": 0, "apart": False,
|
||||
"covered": {"qty": False, "price": False}, "off": False},
|
||||
{"cid": "pct:공구손료#0", "이름": "공구손료", "단위": "%", "수량": 2, "단가": None, "금액": 765.8,
|
||||
"parts": {"경비": 765.8}, "pct": True, "added": None, "apart": False,
|
||||
"covered": {"qty": True, "price": False}, "off": False},
|
||||
],
|
||||
"text": {
|
||||
"ok": True,
|
||||
"계": 51419,
|
||||
"별칭": [{"이름": "합판거푸집일당", "원문": "찾기(QC000431, 유형=유형)"}],
|
||||
"groups": [
|
||||
{"비목": "노무비", "소계": 38290, "끝수": "", "줄": [
|
||||
{"이름": "형틀목공", "cid": "labor:LB000007#0", "글": "275,790 * 0.1111 = 30,643.3",
|
||||
"이름글": "형틀목공노임 * 형틀목공 = 형틀목공비", "금액": 30643.3},
|
||||
{"이름": "폼타이", "cid": "material:폼타이#0", "글": "0 * 0 = 0", "금액": 0, "옵션꺼짐": True},
|
||||
{"이름": "보통인부", "cid": "add:#0", "글": "", "금액": 17206.8},
|
||||
]},
|
||||
{"비목": "경비", "소계": 765, "끝수": "", "줄": [
|
||||
{"이름": "공구손료", "cid": "pct:공구손료#0", "글": "(30,643.3) * 2 % = 765.8",
|
||||
"이름글": "(형틀목공 * 형틀목공노임) * 2 % = 공구손료", "금액": 765.8},
|
||||
]},
|
||||
],
|
||||
},
|
||||
} # fmt: skip
|
||||
|
||||
|
||||
def _run():
|
||||
src = IMPORTS.sub("", _text(MODEL))
|
||||
body = (
|
||||
f"\nconst F = {json.dumps(FIX, ensure_ascii=False)};"
|
||||
"\nconst items = sheetItems({ base: F.base, inputs: F.base.입력, values: F.values, ans: F.ans,"
|
||||
" rows: F.rows, text: F.text });"
|
||||
"\nconst out = {"
|
||||
"\n items: items.map((x) => ({ kind: x.kind, 이름: x.이름, 식: x.식, 값: x.값, 비목: x.비목,"
|
||||
" 표: x.표, changed: x.changed })),"
|
||||
"\n paper: paperLines(F.text, F.rows, F.ans),"
|
||||
"\n status: statusOf(F.ans, { 기준: { 계: 51000 } }, 50000),"
|
||||
"\n origin: statusOf({ ...F.ans, 원래: { 계: 49000 } }, { 기준: { 계: 51000 } }, null),"
|
||||
"\n diff: [diffText(51802, 51419), diffText(51000, 51419), diffText(1, 1)],"
|
||||
"\n};"
|
||||
"\nconsole.log(JSON.stringify(out));"
|
||||
)
|
||||
result = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript", "-"],
|
||||
input=src + body, capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_줄_갈래_읽기_쉬운_식_산출서_상태줄():
|
||||
got = _run()
|
||||
kinds = [x["kind"] for x in got["items"]]
|
||||
assert kinds == ["근거", "근거", "옵션", "상수", "표", "계산", "계산", "자원", "자원", "율",
|
||||
"소계", "소계", "소계", "합계"] # fmt: skip
|
||||
by = {(x["kind"], x["이름"]): x for x in got["items"]}
|
||||
assert by[("근거", "6-3-1")]["식"] == "건설표준품셈 공통 6-3-1"
|
||||
assert by[("표", "시공량")]["값"] == 45 and by[("표", "시공량")]["표"] == ["QC000431"]
|
||||
assert by[("계산", "형틀목공")]["식"] == "합판거푸집일당.형틀목공 / 시공량"
|
||||
assert by[("계산", "합판율")]["식"] == "" and by[("계산", "합판율")]["표"] == [
|
||||
"QC000428"
|
||||
] # 날식 숨김
|
||||
res = by[("자원", "형틀목공 (인)")]
|
||||
assert res["식"] == "형틀목공노임 × 형틀목공" and res["비목"] == "노" and not res["changed"]
|
||||
assert ("자원", "폼타이 (본)") not in by # 옵션으로 빠진 줄 안 그림
|
||||
assert by[("자원", "보통인부 (인)")]["changed"] # 더한줄 = M01 과 다름
|
||||
assert by[("자원", "보통인부 (인)")]["식"] == "172,068 × 0.1" # 이름글 없음 = 단가 × 품
|
||||
rate = by[("율", "공구손료")]
|
||||
assert (
|
||||
rate["식"] == "(형틀목공 × 형틀목공노임) × 2 %" and rate["changed"] and rate["비목"] == "경"
|
||||
)
|
||||
assert by[("합계", "계")]["값"] == 51419
|
||||
paper = [p["글"] for p in got["paper"]]
|
||||
assert "형틀목공 : 275,790 × 0.1111 = 30,643.3" in paper
|
||||
assert "보통인부 : 172,068 × 0.1 = 17,206.8" in paper # 글 없음 = 표 줄로 지음
|
||||
assert not any(p.startswith("폼타이") for p in paper)
|
||||
assert "노무비 소계 = 38,290" in paper and paper[-1] == "계 = 51,419"
|
||||
assert got["status"] == {"원래": 51000, "지금": 51419, "전": 50000} # 원래 = 바탕 셈 계
|
||||
assert got["origin"]["원래"] == 49000 # 790-2 `원래` 있으면 그 값
|
||||
assert got["diff"] == ["+383", "−419", ""]
|
||||
|
||||
|
||||
# ── 790-3 E2 화면 이음(식 속 상수 · 표값 · 글 줄 · 까닭 · M01 원래로 · M01 바뀜) ──────────
|
||||
EDIT = M02 / "M02_MasterTemplete_Sangeun_SheetEdit.ts"
|
||||
|
||||
|
||||
def test_790_3_숫자_넓힘_칸_정적():
|
||||
s = _text(EDIT)
|
||||
assert "<button" not in s and "<input" not in s and "document.createElement" not in s
|
||||
assert "fetch(" not in s and "sessionStorage." not in s and len(s.splitlines()) < 700
|
||||
for call in (
|
||||
"setConst(c.doc, spot, p.n, x)", # 식 속 숫자 = 상수 덮개
|
||||
"setTable(c.doc, spot, p.n, x)", # 표 꼬리표 값 = 표값 덮개
|
||||
'createBadge(t("Cut"), "warning")', # 표 연결 끊김
|
||||
"addText(c.doc, spot,", # 글 줄 더함 · 고침 · 지움
|
||||
"editText(c.doc, i, v)",
|
||||
"dropText(c.doc, i)",
|
||||
"setReason(c.doc, key, x)", # 까닭
|
||||
"restoreM01(c.doc, spot)", # 줄마다 M01 원래로
|
||||
"restoreM01(c.doc)", # 전체
|
||||
'createBadge(t("Changed"), "danger")', # M01 바뀜
|
||||
"c.spots?.짝잃음",
|
||||
):
|
||||
assert call in s, call
|
||||
sheet = _text(SHEET)
|
||||
for call in (
|
||||
"formulaEditor(ctx, it.자리)",
|
||||
"reasonCell(ctx, it.자리, it.changed)",
|
||||
"spotTail(ctx, it.자리,",
|
||||
"restoreAll(ctx)",
|
||||
"lostBanner(ctx)",
|
||||
"removedReasons(ctx, d.removed)",
|
||||
"dropAddKeys(d.doc as ConstDoc, r.added)", # 뺀 더한줄 = 까닭 · 글줄 열쇠 당김
|
||||
"liftAddKeys(d.doc as ConstDoc, at)", # 되살림 = 거꾸로 밂
|
||||
"paperLines(d.text, d.rows, d.ans, (d.doc as ConstDoc).글줄)", # 글 줄 산출서에
|
||||
"spots: spotsOf(d.ans)",
|
||||
):
|
||||
assert call in sheet, call
|
||||
assert "Object.assign(doc, next);" in sheet # 상세가 쥔 같은 문서 객체 · 자동저장 없음
|
||||
assert len(sheet.splitlines()) < 700
|
||||
|
||||
|
||||
SPOT = {
|
||||
"식": "찾기(QC000431, 유형=유형).시공량 * (1 - (높이감률 + 만약(지붕슬래브 == '아님', 0, 17)) / 100)",
|
||||
"상수": [{"n": 0, "값": 1, "원값": 1}, {"n": 1, "값": 0, "원값": 0},
|
||||
{"n": 2, "값": 17, "원값": 17}, {"n": 3, "값": 100, "원값": 100}],
|
||||
"표값": [{"n": 0, "글": "찾기(QC000431, 유형=유형).시공량", "값": 45, "원값": 45}],
|
||||
} # fmt: skip
|
||||
|
||||
|
||||
def _run_e2():
|
||||
src = IMPORTS.sub("", _text(MODEL))
|
||||
bad = {**SPOT, "상수": SPOT["상수"][:3]}
|
||||
body = (
|
||||
f"\nconst S = {json.dumps(SPOT, ensure_ascii=False)};"
|
||||
f"\nconst B = {json.dumps(bad, ensure_ascii=False)};"
|
||||
f"\nconst F = {json.dumps(FIX, ensure_ascii=False)};"
|
||||
"\nconst show = (p) => p && p.map((x) => x.t === '글' ? x.글 : x.t === '수' ? `[#${x.n}]` : `[표${x.n} ${x.표}]`).join('');"
|
||||
"\nconst notes = [{ 자리: '', 글: '맨 위 글' }, { 자리: 'labor:LB000007#0', 글: '형틀 뒤 글' },"
|
||||
" { 자리: 'material:폼타이#0', 글: '꺼진 줄 뒤' }, { 자리: 'labor:LB000007#0', 글: '' }];"
|
||||
"\nconsole.log(JSON.stringify({"
|
||||
"\n parts: show(formulaParts(S)),"
|
||||
"\n bad: formulaParts(B),"
|
||||
"\n none: formulaParts({ 식: '형틀목공', 상수: [], 표값: [] }),"
|
||||
"\n spots: numSpots(\"만약(a == '3회', LB000007 * 2.5, 줄.'합판 12㎜' / 10)\"),"
|
||||
"\n paper: paperLines(F.text, F.rows, F.ans, notes).map((p) => p.kind + ':' + p.글),"
|
||||
"\n lift: liftAddKeys({ 까닭: { 'add:#0': 'a', 'add:#1/x#0': 'b', 'mid:q': 'c' },"
|
||||
" 글줄: [{ 자리: 'add:#2', 글: 'g' }] }, 1),"
|
||||
"\n spotsNone: spotsOf(null),"
|
||||
"\n}));"
|
||||
) # fmt: skip
|
||||
result = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript", "-"],
|
||||
input=src + body, capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_790_3_식_조각_글줄_산출서_열쇠_밂():
|
||||
got = _run_e2()
|
||||
assert got["parts"] == (
|
||||
"[표0 QC000431] × ([#0] - (높이감률 + 만약(지붕슬래브 == '아님', [#1], [#2])) / [#3])"
|
||||
) # 서버 scan 차례 그대로 · 표 찾기 = 한 칸
|
||||
assert got["bad"] is None and got["none"] is None # 차례가 안 맞으면 숫자 칸 안 그림
|
||||
assert len(got["spots"]) == 2 # 2.5 · 10 만 — 따옴표 글(3회 · 12㎜) · 이름(LB000007) 안 숫자 뺌
|
||||
paper = got["paper"]
|
||||
assert paper[0] == "글:맨 위 글" and paper[1] == "글:꺼진 줄 뒤" # 맨 위 · 안 그린 줄 = 위로
|
||||
i = paper.index("줄:형틀목공 : 275,790 × 0.1111 = 30,643.3")
|
||||
assert paper[i + 1] == "글:형틀 뒤 글" and "글:" not in paper[i + 2] # 빈 글 안 찍음
|
||||
assert got["lift"]["까닭"] == {"add:#0": "a", "add:#2/x#0": "b", "mid:q": "c"}
|
||||
assert got["lift"]["글줄"] == [{"자리": "add:#3", "글": "g"}]
|
||||
assert got["spotsNone"] is None
|
||||
|
||||
|
||||
# ── 792 식 칸 화면(줄 식 고침 · 칠 때마다 검사 · 변수 줄) ────────────────────────────
|
||||
FX = M02 / "M02_MasterTemplete_Sangeun_SheetFormula.ts"
|
||||
CELL = ROOT / "A00_Common" / "sheet_formula_cell.ts" # 794-1 식 칸 몸 = 공용(M01 시트와 한 벌)
|
||||
|
||||
|
||||
def test_792_식_칸_정적():
|
||||
s = _text(FX)
|
||||
assert "<button" not in s and "<input" not in s and "document.createElement" not in s
|
||||
assert "sessionStorage." not in s and len(s.splitlines()) < 700
|
||||
both = s + _text(CELL)
|
||||
for call in (
|
||||
"/sangeun/formula-check", # 검사 길(셈 · 저장 없음)
|
||||
"ctl?.abort();", # 칠 때마다 · 옛 요청 끊기
|
||||
"signal,",
|
||||
'box.input.addEventListener("input"',
|
||||
'box.setError(got.되나 ? undefined : got.까닭.join(" · "))', # 빨간 칸 + 까닭
|
||||
"if (!got?.되나 || !got.식) return", # 틀린 식은 문서에 안 넣음
|
||||
"setFormula(doc, spot, engine)", # 엔진 식을 문서에
|
||||
"clearNums(c.doc as FormulaDoc, spot)", # 같은 자리 상수 · 표값 · 품 덮개 걷음
|
||||
"addVar(c.doc as FormulaDoc,",
|
||||
"editVar(c.doc as FormulaDoc, v.이름, { 이름: to })",
|
||||
"dropVar(c.doc as FormulaDoc, v.이름)",
|
||||
):
|
||||
assert call in both, call
|
||||
sheet = _text(SHEET)
|
||||
for call in (
|
||||
"rowFormula(ctx, it.자리,",
|
||||
"formulaToggle(ctx, it.자리,",
|
||||
"varRows(ctx, (v) => v.자리 === it.자리)",
|
||||
"addVarButton(ctx)",
|
||||
"scope: opt.scope,",
|
||||
'coverKinds(d.doc as ConstDoc, r.cid).includes("식")', # 식 덮개 = 품 칸 안 둠
|
||||
):
|
||||
assert call in sheet, call
|
||||
assert "addRate: () => rateButton.click(), scope }" in _text(DETAIL) # 탭 등록 줄에 층만
|
||||
assert "fetch(" not in s # 길 = Api_Fetch.call(로그인 쿠키)
|
||||
@@ -5,14 +5,13 @@
|
||||
인력운반 120 → 100 = 38,696 · 표값 운반량 30 = 35,764) · 옵션 바꿔도 상수 덮개 식 따라감
|
||||
- 식 속 숫자 차례 = 엔진 파서(찾기 머리 · 키 · EQ: · 따옴표 글 안 셈)
|
||||
- 옛 SK(새 칸 없음) = 꼴 · 금액 그대로 · 저장 = M01판 · 셈 답 `원래` · `시트`
|
||||
- [M01 원래로] · 더한줄 열쇠 옮김 · 덮개 갈래 = 파이썬 `M02_Sangeun_Const` ↔ TS `Const_Model` 거울
|
||||
- 799-2 — TS 거울(`Const_Model.ts`)은 시트 탭과 함께 걷음 · 서버 `M02_Sangeun_Const` 만 지킴
|
||||
- 펼침 = 상수 덮개 SK 안 펼침(참조 그대로 · 값 그대로) · [초기화] = 새 덮개도 걷음
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -233,86 +232,3 @@ def test_펼침_막음_초기화_걷음(world):
|
||||
flat.flat_lines("sangeun", {**MINE, "키": a})
|
||||
doc, n = flat.reset("sangeun", {**_read(mine, a), "까닭": {"mid:일운반회수": "가정"}})
|
||||
assert n == 1 and not set(doc) & {"상수덮개", "까닭"}
|
||||
|
||||
|
||||
# ── 파이썬 ↔ TS 거울 ─────────────────────────────────────────────────
|
||||
MIRROR_DOC = {
|
||||
"덮개": {"labor:LB000002#0": {"qty": 1}, "labor:LB000007#0": {"labor": 5}},
|
||||
"뺀줄": ["pct:소모자재#0"],
|
||||
"더한줄": [
|
||||
{
|
||||
"종류": "로직",
|
||||
"로직": "GC000001",
|
||||
"덮개": {"labor:X#0": {"qty": 1}},
|
||||
"뺀줄": ["material:Y#0"],
|
||||
},
|
||||
{"종류": "인력"},
|
||||
],
|
||||
"상수덮개": {"mid:시공량": {"2": 20}, "labor:LB000002#0": {"0": 3}},
|
||||
"표값덮개": {"mid:운반량": {"0": 30}},
|
||||
"글줄": [{"자리": "add:#1", "글": "a"}, {"자리": "add:#0", "글": "b"}],
|
||||
"까닭": {
|
||||
"mid:시공량": "c",
|
||||
"add:#0": "d",
|
||||
"add:#1": "e",
|
||||
"add:#0/labor:X#0": "f",
|
||||
"pct:소모자재#0": "g",
|
||||
},
|
||||
}
|
||||
KEYS = [
|
||||
None,
|
||||
"mid:시공량",
|
||||
"labor:LB000002#0",
|
||||
"pct:소모자재#0",
|
||||
"add:#0/labor:X#0",
|
||||
"add:#0/material:Y#0",
|
||||
"없음",
|
||||
]
|
||||
|
||||
|
||||
def _ts(body: str):
|
||||
src = (ROOT / "M02_MasterTemplete/logic/M02_Sangeun_Const_Model.ts").read_text("utf-8")
|
||||
got = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript", "-e", src + body],
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert got.returncode == 0, got.stderr
|
||||
return json.loads(got.stdout)
|
||||
|
||||
|
||||
def test_거울_M01원래로_열쇠옮김_갈래():
|
||||
d = json.dumps(MIRROR_DOC, ensure_ascii=False)
|
||||
keys = json.dumps(KEYS)
|
||||
spots = json.dumps(
|
||||
["labor:LB000002#0", "labor:LB000007#0", "pct:소모자재#0", "mid:운반량", "x"]
|
||||
)
|
||||
ts = _ts(
|
||||
f"\nconst d = {d};"
|
||||
f"\nconst out = {{r: {keys}.map((k) => restoreM01(d, k ?? undefined)),"
|
||||
" a: [0, 1, 5].map((k) => dropAddKeys(d, k)),"
|
||||
f" k: {spots}.map((s) => coverKinds(d, s))}};"
|
||||
"\nconsole.log(JSON.stringify(out));"
|
||||
)
|
||||
assert ts["r"] == [const.restore(MIRROR_DOC, k) for k in KEYS]
|
||||
assert ts["a"] == [const.drop_add_keys(MIRROR_DOC, k) for k in (0, 1, 5)]
|
||||
assert ts["k"] == [const.kinds(MIRROR_DOC, s) for s in json.loads(spots)]
|
||||
assert ts["k"][0] == ["품", "상수"] and ts["k"][2] == ["뺌"]
|
||||
|
||||
|
||||
def test_TS_고치기_꼴_서버가_받음():
|
||||
"""setConst · setTable · addText · editText · dropText · setReason 결과 = 서버 꼴 그대로 통과."""
|
||||
ts = _ts(
|
||||
"\nlet d = {키: 'SK-000001', 이름: '견본', 단위: '식', 바탕: {로직: 'GC000211'}};"
|
||||
"\nd = setConst(d, 'mid:시공량', 2, 20); d = setConst(d, 'mid:시공량', 0, 1);"
|
||||
"\nd = setConst(d, 'mid:시공량', 0, null); d = setTable(d, 'mid:시공량', 0, 45);"
|
||||
"\nd = setTable(d, 'mid:시공량', 0, null);"
|
||||
"\nd = addText(d, '', '근거'); d = addText(d, 'mid:시공량', '※ 조건', 0);"
|
||||
"\nd = editText(d, 1, '근거 고침'); d = dropText(d, 0);"
|
||||
"\nd = setReason(d, 'mid:시공량', ' 까닭 '); d = setReason(d, 'add:#0', '');"
|
||||
"\nconsole.log(JSON.stringify(d));"
|
||||
)
|
||||
assert ts["상수덮개"] == {"mid:시공량": {"2": 20}} and "표값덮개" not in ts
|
||||
assert ts["글줄"] == [{"자리": "", "글": "근거 고침"}] and ts["까닭"] == {"mid:시공량": "까닭"}
|
||||
got = sd.clean({**ts, "입력값": F211}, None, _whole())
|
||||
assert {k: got[k] for k in const.NEW if k in got} == {k: ts[k] for k in const.NEW if k in ts}
|
||||
|
||||
@@ -111,8 +111,9 @@ def test_단산_줄_길_잇기():
|
||||
assert "skPick(sk" in imp and "o.onLink" in imp # [이 줄에 잇기] = 줄 고른 것 SK
|
||||
for how in ('run("price"', 'run("link"', 'run("to-base"'):
|
||||
assert how in imp, how
|
||||
sheet = _src("Sangeun_Sheet") # 상태줄 원래 = 가져온 식이면 「엑셀 원래」
|
||||
assert 'Excel: ["엑셀 원래"' in sheet and '"가져온" in d.ans ? "Excel" : "Origin"' in sheet
|
||||
# 수식 탭 위 줄 = 가져온 식이면 「엑셀 원래」(799-2 — 시트 탭 걷음)
|
||||
sheet = _src("Sangeun_Equation")
|
||||
assert 'Excel: ["엑셀 원래"' in sheet and '"가져온" in ans' in sheet
|
||||
locale = (ROOT / "ui_template/ui_template_locale_m2_judge.ts").read_text(encoding="utf-8")
|
||||
assert 'M02_CheckImported: ["가져온 식으로 열기"' in locale
|
||||
|
||||
@@ -148,7 +149,7 @@ def test_부품상자만_700줄_공용_안_고침():
|
||||
assert not re.search(r"""\bel\(\s*["'](button|input|select)["']""", text), name
|
||||
assert not re.search(r"""createElement\(\s*["'](button|input|select)["']""", text), name
|
||||
assert "#" not in "".join(re.findall(r"color:\s*[^;]+", text)), name
|
||||
for name in ("Labor_Rows", "Labor_SangeunModal", "Sangeun_Detail", "Sangeun_Sheet"):
|
||||
for name in ("Labor_Rows", "Labor_SangeunModal", "Sangeun_Detail", "Sangeun_Tabs"):
|
||||
assert "Hopyo_Imported" not in _src(name), name # 공용 쪽은 새 모달을 모름
|
||||
|
||||
|
||||
|
||||
@@ -1,22 +1,16 @@
|
||||
# ruff: noqa: E501
|
||||
"""794-1 M01 관리자 줄 식 시트 — 로직 상세 첫 보기 「시트」 · 식 칸 = M02 단가산출 시트와 한 벌.
|
||||
"""794-1 M01 관리자 줄 식 시트 — 서버 길(`/logic/sheet` · `/logic/sheet/check`) 지킴.
|
||||
|
||||
- 정적: 시트 화면 = 부품 상자만(날 button · input 0) · 식 칸 몸 = 공용 `sheet_formula_cell`(M02 도 같은 것) ·
|
||||
고침 = 관리자만 · 줄 사본 → 캐시(touch) → 기존 [저장] 길(시트는 `/save` 안 부름) · 보기 첫 칸 = 시트 ·
|
||||
자체 로직(GX) 편집기 식 칸 = 읽기만(날 입력칸 갈음)
|
||||
- 799-2 — 시트 화면 · 모델(`_LogicLab_Sheet.ts` · `logic/M01_Logic_Sheet_Model.ts`)은 걷음 · 이 서버 길은 수식 탭 검사와 같은 엔진이라 남김
|
||||
- 서버(사본 폴더): 이름 꼴 식 글 · 칠 때마다 검사(없는 이름 · 아래 줄 · 괄호 · 빈 식 · 길이 · 없는 자리) ·
|
||||
검사 = 쓰기 없음 · 공구손료 식 1 → 2 % 저장(개정 확인 409 → 200) 뒤 셈 51,802 · 틀린 식 저장 422 · 시스템 관리자가 아니면 403
|
||||
- 모델(node strip-types): 줄 갈래 · 줄 금액 = 호표 → 덧줄 차례 · 고친 줄 · 끝수 · 단계 · 식 칸 고침
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -28,19 +22,12 @@ from M01_MasterData import M01_MasterData_Router as m01_router
|
||||
from M01_MasterData import M01_MasterData_Sheet as line_sheet
|
||||
from M01_MasterData import M01_MasterData_Store as store
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
M01 = ROOT / "M01_MasterData"
|
||||
SHEET = M01 / "M01_MasterData_UI_LogicLab_Sheet.ts"
|
||||
MODEL = M01 / "logic" / "M01_Logic_Sheet_Model.ts"
|
||||
LAB = M01 / "M01_MasterData_UI_LogicLab.ts"
|
||||
CELL = ROOT / "A00_Common" / "sheet_formula_cell.ts"
|
||||
REAL = store.FOLDER
|
||||
KEY = "GC000211" # 합판거푸집 설치 및 해체
|
||||
INPUTS = {"자재지역": "포항", "유형": "보통", "사용횟수": "6회", "높이감률": 0, "지붕슬래브": "아님",
|
||||
"폼타이": "아님", "폼타이규격": "12.7㎜", "측압": "3t/㎡", "구멍땜": 0} # fmt: skip
|
||||
TOOL_1 = "(형틀목공 × [노임 형틀목공] + 보통인부 × [노임 보통인부]) × 1 / 100"
|
||||
TOOL_2 = TOOL_1.replace("× 1 / 100", "× 2 / 100")
|
||||
IMPORTS = re.compile(r"^import[^;]*;\r?\n", re.MULTILINE)
|
||||
|
||||
|
||||
def _text(p: Path) -> str:
|
||||
@@ -51,44 +38,6 @@ def _hashes(folder: Path) -> dict[str, str]:
|
||||
return {p.name: hashlib.md5(p.read_bytes()).hexdigest() for p in sorted(folder.glob("*.json"))}
|
||||
|
||||
|
||||
# ── 정적 ─────────────────────────────────────────────────────────────
|
||||
def test_정적_부품_상자만_관리자만_저장은_기존_길() -> None:
|
||||
for p in (SHEET, MODEL, CELL):
|
||||
s = _text(p)
|
||||
assert "<button" not in s and "<input" not in s and "document.createElement" not in s
|
||||
assert "sessionStorage." not in s and len(s.splitlines()) < 700
|
||||
assert "fetch(" not in _text(MODEL) and "document." not in _text(MODEL)
|
||||
sheet = _text(SHEET)
|
||||
for part in (
|
||||
"createCalcSheetRow",
|
||||
"createFormulaCell",
|
||||
'"/logic/sheet"',
|
||||
'"/logic/sheet/check"',
|
||||
):
|
||||
assert part in sheet, part
|
||||
assert "/save" not in sheet # 저장 = 상세 [저장](기존 길)
|
||||
assert sheet.count("opt.admin &&") >= 2 # [식 고침] · 식 칸 = 관리자만
|
||||
lab = _text(LAB)
|
||||
assert "const sheet = createLabSheet({\n admin," in lab.replace("\r\n", "\n")
|
||||
assert "sheet: sheet.root," in lab and "onAnswer: sheet.answer," in lab
|
||||
assert "edited: () => (touch(), drawEditor(), drawCalc())" in lab # 캐시(touch) → [저장]
|
||||
assert "setLabFormula(d.row, part, at, text)" in sheet and "opt.edited();" in sheet
|
||||
assert len(lab.splitlines()) <= 700 # 796-2 수식 탭 이음 14 줄
|
||||
view = _text(M01 / "M01_MasterData_UI_LogicLab_View.ts")
|
||||
assert ( # 796-2 — 첫 탭 = 수식 · 시트는 뒤로(걷지 않음)
|
||||
'host.dataset.view = "equation";' in view
|
||||
and '{ value: "sheet", label: tl("View_Sheet") }' in view
|
||||
)
|
||||
css = _text(M01 / "M01_MasterData_UI_LogicLab_Style.css")
|
||||
assert '[data-view="sheet"] [data-block]:not([data-block="sheet"])' in css
|
||||
edit = _text(M01 / "M01_MasterData_UI_Logic_Edit.ts")
|
||||
assert "textBox(item.수량" not in edit and "textBox(item.식" not in edit # GX 날 식 칸 갈음
|
||||
m02 = _text(ROOT / "M02_MasterTemplete" / "M02_MasterTemplete_Sangeun_SheetFormula.ts")
|
||||
assert "return createFormulaCell({" in m02 # M02 시트도 같은 식 칸
|
||||
main = _text(ROOT / "main.py")
|
||||
assert "app.include_router(m01_master_data_router, dependencies=system_admin_only)" in main
|
||||
|
||||
|
||||
# ── 서버 ─────────────────────────────────────────────────────────────
|
||||
@pytest.fixture
|
||||
def folder(tmp_path: Path, monkeypatch) -> Path:
|
||||
@@ -175,60 +124,3 @@ def test_시스템_관리자가_아니면_403(folder: Path) -> None:
|
||||
for url, body in (("/api/m01/logic/sheet", {"row": {}}),
|
||||
("/api/m01/logic/sheet/check", {"row": {}, "갈래": "중간", "차례": 0, "식": "1"})): # fmt: skip
|
||||
assert client.post(url, json=body).status_code == 403
|
||||
|
||||
|
||||
# ── 모델 ─────────────────────────────────────────────────────────────
|
||||
ROW = {
|
||||
"키": "GX1", "원문번호": "1-1", "이름": "견본", "결과단위": "원/㎡", "출처": "견본 1-1", "비고": "한 줄\n둘째 줄",
|
||||
"입력": [{"이름": "폭", "단위": "m"}],
|
||||
"중간": [{"이름": "넓이", "식": "폭 * 2"}],
|
||||
"호표": [{"종류": "인력", "요소": "LB000002", "이름": "보통인부", "단위": "인", "수량": "넓이 * 0.1", "비목": "노무비", "번지": "A01"}],
|
||||
"덧줄": [{"이름": "공구손료", "식": "노무비 * 1 / 100", "비목": "경비"}],
|
||||
"단계": [{"이름": "노무 소계", "단": 1, "줄": ["A01"]}],
|
||||
"끝수": {"대상": "비목", "방법": "버림", "자리": 0},
|
||||
} # fmt: skip
|
||||
|
||||
|
||||
def _run() -> dict:
|
||||
src = IMPORTS.sub("", _text(MODEL))
|
||||
body = (
|
||||
f"\nconst R = {json.dumps(ROW, ensure_ascii=False)};"
|
||||
"\nconst now = structuredClone(R);"
|
||||
"\nconst put = setLabFormula(now, '호표', 0, '넓이 * 0.2');"
|
||||
"\nconst miss = setLabFormula(now, '덧줄', 5, '1');"
|
||||
"\nconst answer = { ok: true, middle: { 넓이: 6 }, sums: { 노무비: 1000, 재료비: 0, 경비: 10, 계: 1010 },"
|
||||
" lines: [{ 금액: 1000 }, { 금액: 10.5 }] };"
|
||||
"\nconst items = labItems({ row: now, original: R, values: { 폭: '3' },"
|
||||
" names: { 중간: ['폭 × 2'], 호표: ['넓이 × 0.2'], 덧줄: ['노무비 × 1 / 100'] }, answer });"
|
||||
"\nconst bare = labItems({ row: R, original: null, values: {}, names: null, answer: { ok: false, reason: 'x' } });"
|
||||
"\nconsole.log(JSON.stringify({ put, miss, items, bare, cut: cutText('글자 끝수') }));"
|
||||
)
|
||||
result = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript", "-"],
|
||||
input=src + body, capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_모델_줄_갈래_값_고친_줄() -> None:
|
||||
got = _run()
|
||||
assert got["put"] is True and got["miss"] is False
|
||||
kinds = [(x["kind"], x["이름"]) for x in got["items"]]
|
||||
assert kinds == [("근거", "1-1"), ("근거", ""), ("근거", ""), ("옵션", "폭"), ("계산", "넓이"),
|
||||
("자원", "보통인부 (인)"), ("율", "공구손료"), ("단계", "노무 소계"), ("근거", "끝수"),
|
||||
("소계", "노무비"), ("소계", "재료비"), ("소계", "경비"), ("합계", "계")] # fmt: skip
|
||||
by = {(x["kind"], x["이름"]): x for x in got["items"]}
|
||||
assert by[("옵션", "폭")]["값"] == "3"
|
||||
assert by[("계산", "넓이")]["값"] == 6 and not by[("계산", "넓이")]["changed"]
|
||||
res = by[("자원", "보통인부 (인)")]
|
||||
assert res["값"] == 1000 and res["비목"] == "노" and res["번지"] == "A01" and res["changed"]
|
||||
assert res["식"] == "넓이 × 0.2" and res["part"] == "호표" and res["at"] == 0
|
||||
assert by[("율", "공구손료")]["값"] == 10.5 and by[("율", "공구손료")]["part"] == "덧줄"
|
||||
assert by[("단계", "노무 소계")]["식"] == "A01"
|
||||
assert by[("근거", "끝수")]["식"] == "비목 · 버림 · 0 자리"
|
||||
assert by[("합계", "계")]["값"] == 1010
|
||||
bare = {(x["kind"], x["이름"]): x for x in got["bare"]}
|
||||
assert bare[("계산", "넓이")]["식"] == "폭 × 2" # 이름 꼴 전 = 엔진 식 「*」 → 「×」
|
||||
assert bare[("자원", "보통인부 (인)")]["값"] is None and ("합계", "계") not in bare
|
||||
assert got["cut"] == "글자 끝수"
|
||||
|
||||
@@ -69,13 +69,14 @@ def test_계산_자리_화면은_모양만():
|
||||
|
||||
|
||||
def test_수식_탭이_첫_탭_단가산출_상세는_그대로():
|
||||
"""799-2 — 보기 둘(수식 · 단가산출 상세)만 남김(시트 · 인력·재료·경비 · 시험 계산 · 텍스트 수식 걷음)."""
|
||||
view = _text(M01 / "M01_MasterData_UI_LogicLab_View.ts")
|
||||
labels = re.findall(r'\{ value: "(\w+)", label: tl\("(\w+)"\) \}', view)
|
||||
assert [v for v, _ in labels] == ["equation", "ho", "sheet", "est", "calc", "formula"]
|
||||
assert [v for v, _ in labels] == ["equation", "ho"]
|
||||
assert 'host.dataset.view = "equation";' in view and 'value: "equation",\n size: 32' in view
|
||||
detail = _text(M01 / "M01_MasterData_UI_LogicLab_Detail.ts")
|
||||
assert 'block(ctx.equation, "equation")' in detail
|
||||
assert 'block(section(tl("Ho_Title"), [formula]), "formula")' in detail # 옛 탭은 걷지 않음
|
||||
assert '"formula"' not in detail and "buildFormula" not in detail # 799-2 텍스트 수식 걷음
|
||||
css = _text(M01 / "M01_MasterData_UI_LogicLab_Style.css")
|
||||
assert '[data-view="equation"] [data-block]:not([data-block="equation"])' in css
|
||||
lab = _text(M01 / "M01_MasterData_UI_LogicLab.ts")
|
||||
|
||||
@@ -178,7 +178,7 @@ def test_m01_change_kept_regen_reapplies(world, monkeypatch):
|
||||
|
||||
|
||||
def test_screen_same_part_first_tab():
|
||||
"""화면 — M02 수식 = M01 수식 탭 부품 다시 씀(복사 없음) · 첫 탭 · 옛 탭 뒤로 · 저장 = 페이지."""
|
||||
"""화면 — M02 수식 = M01 수식 탭 부품 다시 씀(복사 없음) · 첫 탭 · 옛 탭 걷음(799-2) · 저장 = 페이지."""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
@@ -186,7 +186,8 @@ def test_screen_same_part_first_tab():
|
||||
mine = (root / "M02_MasterTemplete_Sangeun_Equation.ts").read_text(encoding="utf-8")
|
||||
assert "createLabEquation(" in mine and "fetch(" not in mine and "createEqChain" not in mine
|
||||
assert "/sangeun/equation${suffix}" in mine and "/sangeun/copy/${how}" in mine
|
||||
sheet = (root / "M02_MasterTemplete_Sangeun_Sheet.ts").read_text(encoding="utf-8")
|
||||
views = re.findall(r'\{ value: "(\w+)", label: ', sheet)[:6]
|
||||
assert views == ["equation", "ho", "sheet", "est", "calc", "formula"]
|
||||
assert 'show("equation");' in sheet
|
||||
tabs = (root / "M02_MasterTemplete_Sangeun_Tabs.ts").read_text(encoding="utf-8")
|
||||
views = re.findall(r'\{ value: "(\w+)", label: ', tabs)
|
||||
# 799-2 — 시트 · 인력·재료·경비 · 시험 계산 · 텍스트 수식 걷음
|
||||
assert views == ["equation", "ho"]
|
||||
assert 'show("equation");' in tabs
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# ruff: noqa: E501
|
||||
"""799-2 옛 탭 넷 걷음 지킴 — 시트 · 인력/재료/경비 · 시험 계산 · 텍스트 수식 (M01 로직 상세 · M02 단가산출 상세).
|
||||
|
||||
- 걷은 파일은 없고 어디서도 안 부름 · 보기 = 수식 · 단가산출 상세 둘 · 기본정보는 맨 위
|
||||
- 셈 구동은 화면 칸 없이 `_Logic_Drive.ts`(서버 셈) — M01 상세는 구동만 · 시험 계산 칸(`Logic_Calc`)은 로직 복사 화면 몫
|
||||
- 서버 길은 남김(`/logic/sheet` · 시트 문서 칸 · 수식 검사가 같은 엔진)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
M01 = ROOT / "M01_MasterData"
|
||||
M02 = ROOT / "M02_MasterTemplete"
|
||||
GONE = [
|
||||
M01 / "M01_MasterData_UI_LogicLab_Sheet.ts",
|
||||
M01 / "logic" / "M01_Logic_Sheet_Model.ts",
|
||||
M01 / "M01_MasterData_UI_LogicLab_Formula.ts",
|
||||
M01 / "M01_MasterData_UI_LogicLab_FormulaTokens.ts",
|
||||
M01 / "M01_MasterData_UI_LogicLab_Formula.css",
|
||||
M01 / "M01_MasterData_UI_LogicLab_Est.ts",
|
||||
M02 / "M02_MasterTemplete_Sangeun_Sheet.ts",
|
||||
M02 / "M02_MasterTemplete_Sangeun_SheetEdit.ts",
|
||||
M02 / "M02_MasterTemplete_Sangeun_SheetFormula.ts",
|
||||
M02 / "M02_MasterTemplete_Sangeun_Sheet.css",
|
||||
M02 / "logic" / "M02_Sangeun_Sheet_Model.ts",
|
||||
M02 / "logic" / "M02_Sangeun_Const_Model.ts",
|
||||
M02 / "logic" / "M02_Sangeun_Formula_Model.ts",
|
||||
]
|
||||
|
||||
|
||||
def _text(p: Path) -> str:
|
||||
return p.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _tracked() -> list[Path]:
|
||||
out = subprocess.run(
|
||||
["git", "-c", "core.quotepath=off", "ls-files", "*.ts", "*.css"],
|
||||
cwd=ROOT, capture_output=True, text=True, encoding="utf-8", check=True,
|
||||
).stdout # fmt: skip
|
||||
return [ROOT / x for x in out.splitlines() if x and not x.startswith(("docs/", "tmp/"))]
|
||||
|
||||
|
||||
def test_걷은_파일_없고_부르는_곳_없음():
|
||||
assert not [p for p in GONE if p.exists()]
|
||||
names = [p.name.rsplit(".", 1)[0] for p in GONE]
|
||||
for p in _tracked():
|
||||
text = _text(p)
|
||||
for n in names:
|
||||
assert f'/{n}"' not in text and f'./{n}"' not in text and f"{n}.css" not in text, (
|
||||
p.name,
|
||||
n,
|
||||
)
|
||||
|
||||
|
||||
def test_보기는_수식_단가산출상세_둘():
|
||||
m01 = _text(M01 / "M01_MasterData_UI_LogicLab_View.ts")
|
||||
m02 = _text(M02 / "M02_MasterTemplete_Sangeun_Tabs.ts")
|
||||
for src in (m01, m02):
|
||||
assert re.findall(r'\{ value: "(\w+)", label: ', src) == ["equation", "ho"]
|
||||
assert 'export type LabView = "equation" | "ho";' in m01
|
||||
css = _text(M01 / "M01_MasterData_UI_LogicLab_Style.css")
|
||||
assert '[data-view="ho"] [data-block]:not([data-block="ho"])' in css
|
||||
assert 'data-view="sheet"' not in css and 'data-view="calc"' not in css
|
||||
|
||||
|
||||
def test_셈_구동은_화면_없이_상세는_구동만():
|
||||
drive = _text(M01 / "M01_MasterData_UI_Logic_Drive.ts")
|
||||
assert "document." not in drive and "sessionStorage." not in drive
|
||||
assert "export async function driveRun(" in drive and "export function createDrive(" in drive
|
||||
lab = _text(M01 / "M01_MasterData_UI_LogicLab.ts")
|
||||
assert "createDrive()" in lab and "buildCalc" not in lab and "data-calc-run" not in lab
|
||||
assert "rerun: () => void drive.run()," in lab
|
||||
detail = _text(M01 / "M01_MasterData_UI_LogicLab_Detail.ts")
|
||||
assert "calcHost" not in detail and "data-calc-run" not in detail and "ctx.rerun?.()" in detail
|
||||
calc = _text(
|
||||
M01 / "M01_MasterData_UI_Logic_Calc.ts"
|
||||
) # 로직 복사 화면이 쓰는 시험 계산 칸 — 구동은 공용
|
||||
assert "driveRun(ctx)" in calc and "runCalc" not in calc
|
||||
m02 = _text(M02 / "M02_MasterTemplete_Sangeun_Detail.ts")
|
||||
assert "buildCalc" not in m02 and "calcHost" not in m02 and "createSangeunTabs(editor" in m02
|
||||
|
||||
|
||||
def test_파일_700줄_안():
|
||||
for p in (M01 / "M01_MasterData_UI_LogicLab.ts", M01 / "M01_MasterData_UI_LogicLab_Detail.ts",
|
||||
M02 / "M02_MasterTemplete_Sangeun_Detail.ts", M02 / "M02_MasterTemplete_Sangeun_Tabs.ts",
|
||||
M01 / "M01_MasterData_UI_Logic_Drive.ts"): # fmt: skip
|
||||
assert len(_text(p).splitlines()) <= 700, p.name
|
||||
@@ -77,43 +77,6 @@
|
||||
width: 112px;
|
||||
}
|
||||
|
||||
/* 시트 줄(790-1) — 이름 | 식 | 값 112 | 꼬리 · 좁은 상자(560 아래)는 두 줄 · 고친 줄 = 왼쪽 경고 띠 */
|
||||
.ui-calc-row--sheet {
|
||||
grid-template-columns: minmax(96px, 0.7fr) minmax(0, 2fr) 112px minmax(56px, max-content);
|
||||
padding: var(--spacing-4);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.ui-calc-row--sheet > :nth-child(4) {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.ui-calc-row--sheet.is-changed {
|
||||
box-shadow: inset 3px 0 0 var(--color-warning);
|
||||
background: color-mix(in srgb, var(--color-warning) 10%, transparent);
|
||||
}
|
||||
|
||||
@container (max-width: 560px) {
|
||||
.ui-calc-row--sheet {
|
||||
grid-template-columns: minmax(0, 1fr) 112px;
|
||||
}
|
||||
|
||||
.ui-calc-row--sheet > :nth-child(2) {
|
||||
grid-row: 2;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.ui-calc-row--sheet > :nth-child(4) {
|
||||
grid-row: 3;
|
||||
grid-column: 1 / -1;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
/* 숨김 — display 가 [hidden] 을 덮지 않게(공용 기본 꼴이 안 실린 곳 · 페이지 꾸밈 위에서도) */
|
||||
:is(.ui-calc-row, .ui-calc-row__label--select)[hidden] {
|
||||
display: none !important;
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
* ui_template_calc_row.ts
|
||||
* 부품 「계산 줄」(557-2) — 계산서 한 줄: 이름 · 값 칸(입력 또는 계산 결과) · 덧칸(식 · 안내 · 둘째 값).
|
||||
* 꼴 = 값 줄(`createCalcRow` · 이름 | 값 112 | 덧칸) · 식 줄(`createCalcFormulaRow` · 이름 | 식 | 값 112)
|
||||
* · 시트 줄(`createCalcSheetRow` · 이름 | 식 | 값 112 | 꼬리 · 790-1 단가산출 시트)
|
||||
* · 식 글(`createCalcFormula` · `^…^` 위첨자 · `_…_` 아래첨자) · 이름 칸 안 고르기(`ui-calc-row__label--select`)
|
||||
* · 칸 조각 클래스 = 입력 `ui-calc-row__in` · 고르기 `__select`(값 칸 + 덧칸) · 결과 글 `__out`(숫자 오른쪽 · 중요 `is-key` 굵게) · 글 값 `__value` · 안내 `__hint`
|
||||
* 크기 = 줄 최소 30(작은 입력) · 값 칸 112 · 사이 8 · 이름 14 · 식 · 안내 12
|
||||
@@ -64,22 +63,3 @@ export function createCalcFormulaRow(
|
||||
row.append(span("ui-calc-row__label", label), middle ?? document.createElement("span"), value);
|
||||
return row;
|
||||
}
|
||||
|
||||
/** 시트 줄(790-1) — 이름 · 식 · 값 112 · 꼬리 칸(비목 · 단추) · 좁으면 이름 · 값 윗줄 + 식 · 꼬리 아랫줄 ·
|
||||
* 고친 줄 = `is-changed`(왼쪽 경고 색 띠) */
|
||||
export function createCalcSheetRow(
|
||||
label: string | HTMLElement,
|
||||
middle: HTMLElement | null,
|
||||
value: HTMLElement,
|
||||
tail?: HTMLElement,
|
||||
): HTMLElement {
|
||||
const row = document.createElement("div");
|
||||
row.className = "ui-calc-row ui-calc-row--sheet";
|
||||
row.append(
|
||||
typeof label === "string" ? span("ui-calc-row__label", label) : label,
|
||||
middle ?? document.createElement("span"),
|
||||
value,
|
||||
tail ?? document.createElement("span"),
|
||||
);
|
||||
return row;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* 크기 = 글 `--text-body` · 풀이 `--text-caption` · 접힘 단추 22
|
||||
* 상태 = 고른 줄 `data-selected`(왼쪽 띠 · 식 칸 자리 `slot` 이 줄 아래에 열림) · 펼친 블록 `data-open`
|
||||
* 쓰는 법 = `createEqChain(tree.줄, { path: "", open: new Set(), selected, onSelect, onVar, slot })`
|
||||
* 대신하는 옛 변종 = M01 · M02 시트 줄(`createCalcSheetRow`) 의 이름 | 식 | 값 한 줄(분수 · 풀이 없음)
|
||||
* 대신한 옛 변종 = 시트 줄(이름 | 식 | 값 한 줄 · 분수 · 풀이 없음 · 799-2 에 걷음)
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton } from "./ui_template_button";
|
||||
|
||||
@@ -42,12 +42,7 @@ import { settingCard, settingRow, valueText } from "./ui_template_setting_row";
|
||||
import { createTable, type Cell, type Column } from "./ui_template_table";
|
||||
import { createCard } from "./ui_template_card";
|
||||
import { createCollapsibleHead, createPanelCard } from "./ui_template_collapsible";
|
||||
import {
|
||||
createCalcFormula,
|
||||
createCalcFormulaRow,
|
||||
createCalcRow,
|
||||
createCalcSheetRow,
|
||||
} from "./ui_template_calc_row";
|
||||
import { createCalcFormula, createCalcFormulaRow, createCalcRow } from "./ui_template_calc_row";
|
||||
import { createDataTable } from "./ui_template_data_table";
|
||||
import { createHopyoGrid } from "./ui_template_hopyo_grid";
|
||||
import { createBarChart, createDonut, createLineChart } from "./ui_template_elements_chart";
|
||||
@@ -243,7 +238,6 @@ const kitCalc = (): HTMLElement => {
|
||||
createCalcFormula("V = 1/n × R^2/3^ × S^1/2^"),
|
||||
out("2.41", true),
|
||||
),
|
||||
createCalcSheetRow("형틀목공 (인)", createCalcFormula("형틀목공노임 × 형틀목공"), out("30,643.3")),
|
||||
);
|
||||
};
|
||||
/** 552-2 견본 조각 — 상태를 안쪽 요소에 굳힘 */
|
||||
|
||||
Reference in New Issue
Block a user