feat(790-1): 단가산출 줄 식 시트 — 상세 첫 탭 「시트」 · 이름 | 식 | 값 | 비목 · 산출서 보기 · 상태줄 금액 셋
- 새 M02_MasterTemplete_Sangeun_Sheet.ts(+ .css) — 보기 고르기(시트 + 옛 넷 그대로) · 줄 갈래(근거 · 옵션 · 변수 · 계산 · 자원 · 율 · 소계 · 합계) · 칸에서 옵션 · 품 · 단가 · 율 % · 빼기 · 되살림 · 되돌림 · [+ 자원 줄] · [+ 율 줄] · 고칠 때마다 서버 셈(최신 응답만) · M01 과 다른 줄 색 띠 - 새 logic/M02_Sangeun_Sheet_Model.ts — 시트 줄 · 읽기 쉬운 식(텍스트 수식 이름글 · 별칭) · 엔진 날식 숨김 · 산출서 한 줄 글 · 상태줄(M01 원래 · 지금 · 고치기 전) - ui_template_calc_row 시트 줄(createCalcSheetRow · is-changed) + 부품 모음 견본 한 줄 - Sangeun_Detail = 탭 등록만(createViewPicker → createSangeunSheet) - 시험 test_790_1 새 · test_721 단추 자리 단언을 새 보기 고르기 이름으로 고침(createViewPicker 걷음) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -33,12 +33,12 @@ import {
|
||||
} from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
|
||||
import { buildLabDetail } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Detail";
|
||||
import { buildHo, lineOpener, type HoEdit } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Ho";
|
||||
import { createViewPicker } from "../M01_MasterData/M01_MasterData_UI_LogicLab_View";
|
||||
import type { Layer } from "./M02_MasterTemplete_Api_Fetch";
|
||||
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 { createSangeunSide, tabOf, type SourceTab } from "./M02_MasterTemplete_Sangeun_Side";
|
||||
import { baseUnitOf, buildUnitSwap, type SwapHow } from "./M02_MasterTemplete_Sangeun_Unit";
|
||||
import {
|
||||
@@ -103,7 +103,10 @@ 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 views = createViewPicker(editor);
|
||||
const sheet = createSangeunSheet(editor, {
|
||||
...{ edit, opened: () => opened(), touch: () => touch(), onInput: (n, v) => onInput(n, v) },
|
||||
...{ addLine: () => addButton.click(), addRate: () => rateButton.click() }, // 790-1 시트 첫 탭
|
||||
});
|
||||
const calcHost = el("div", { className: "m01lab__calc" });
|
||||
const ownHost = el("div");
|
||||
const addButton = createButton({
|
||||
@@ -166,7 +169,8 @@ export function mountSangeunDetail(
|
||||
children: [
|
||||
head,
|
||||
notes,
|
||||
el("div", { className: "m02-sk__head", children: [views.root, addButton, rateButton] }),
|
||||
el("div", { className: "m02-sk__head", children: [sheet.picker, addButton, rateButton] }),
|
||||
sheet.host,
|
||||
editor,
|
||||
],
|
||||
});
|
||||
@@ -336,6 +340,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 });
|
||||
const hoCtx = {
|
||||
row: view ? { ...row, 호표: view.호표, 덧줄: view.덧줄 } : row,
|
||||
prices: base?.prices ?? {},
|
||||
@@ -558,6 +563,7 @@ export function mountSangeunDetail(
|
||||
if (one) one.바탕판 = l.바탕판; // 둘째 기초도 같음(727-2)
|
||||
}
|
||||
saved = JSON.stringify(doc);
|
||||
sheet.saved();
|
||||
return clone(doc);
|
||||
},
|
||||
getValues: () => ({ ...values }), // 723-3 — 일위대가 줄 모달 [적용] = 옵션을 그 줄에 남김
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/* 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);
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Sangeun_Sheet.ts
|
||||
* M02 단가산출(SK) 줄 식 시트(PLAN 790-1 · E1 · 요구 650~652 · 요구서_652 1 장 4) — 상세 첫 탭 「시트」.
|
||||
* 보기 고르기 = 시트 · 옛 보기 넷(단가산출 상세 · 인력·재료·경비 · 시험 계산 · 텍스트 수식 — E7 까지 둠)
|
||||
* 시트 = 한 줄 「이름 | 식 | 값 | 비목」(줄 꼴 = `logic/M02_Sangeun_Sheet_Model`) · 안 보기 전환 「시트 ↔ 산출서」
|
||||
* 고침 = 지금 되는 것만(모델 안 바꿈) — 옵션 고름 · 품 · 단가 · 율 % · 줄 빼기 · 되살림 · [+ 자원 줄] · [+ 율 줄]
|
||||
* → SK 문서(덮개 · 더한줄 · 뺀줄 = `M02_Sangeun_Cover_Model`) → 상세 `touch` = 서버 셈 한 번(최신 응답만)
|
||||
* 상태줄 = M01 원래 · 지금 · 고치기 전(화면 캐시) + 차이 · 자동저장 없음([저장] = 상세 그대로)
|
||||
* 부품 = `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 {
|
||||
dropLine,
|
||||
PART_OF,
|
||||
restoreLine,
|
||||
setLineCover,
|
||||
type CoverRow,
|
||||
type HoView,
|
||||
type LineCover,
|
||||
type SkAnswer,
|
||||
type SkDoc,
|
||||
} from "./logic/M02_Sangeun_Cover_Model";
|
||||
import {
|
||||
diffText,
|
||||
num,
|
||||
paperLines,
|
||||
sheetItems,
|
||||
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"],
|
||||
Now: ["지금", "Now"],
|
||||
Before: ["고치기 전", "Before edit"],
|
||||
근거: ["근거", "Basis"],
|
||||
옵션: ["옵션", "Options"],
|
||||
상수: ["변수 · 계산", "Variables"],
|
||||
자원: ["자원", "Resources"],
|
||||
율: ["율", "Rates"],
|
||||
소계: ["소계 · 합계", "Subtotals"],
|
||||
Qty: ["품", "Qty"],
|
||||
Price: ["단가", "Price"],
|
||||
Rate: ["율 %", "Rate %"],
|
||||
Drop: ["빼기", "Remove"],
|
||||
Undo: ["되돌림", "Revert"],
|
||||
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;
|
||||
}
|
||||
|
||||
type View = "sheet" | "ho" | "est" | "calc" | "formula";
|
||||
|
||||
/** 보기 고르기(시트 + 옛 넷) · 시트 그릇 — `editor` = 옛 보기 그릇(시트일 때 숨김) */
|
||||
export function createSangeunSheet(
|
||||
editor: HTMLElement,
|
||||
opt: SheetOptions,
|
||||
): { picker: HTMLElement; host: HTMLElement; draw: (d: SheetDraw) => void; saved: () => void } {
|
||||
const host = el("div", { className: "m02-sks", attrs: { "data-sk-sheet": "" } });
|
||||
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; // 고치기 전 = 연 뒤 · 저장 뒤 첫 셈 계(화면 캐시)
|
||||
const show = (v: View): void => {
|
||||
host.hidden = v !== "sheet";
|
||||
editor.hidden = v === "sheet";
|
||||
if (v !== "sheet") editor.dataset.view = v;
|
||||
};
|
||||
const picker = createSegment({
|
||||
options: [
|
||||
{ value: "sheet", label: t("Sheet") },
|
||||
{ value: "ho", label: tl("Ho_Title") },
|
||||
{ value: "est", label: tl("View_Est") },
|
||||
{ value: "calc", label: tl("Calc_Title") },
|
||||
{ value: "formula", label: tl("Formula_Title") },
|
||||
],
|
||||
value: "sheet",
|
||||
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 = "";
|
||||
host.append(createToolbar({ items: [inner.root, "divider", status], label: t("Mode") }), body);
|
||||
show("sheet");
|
||||
|
||||
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("Origin"), s.원래),
|
||||
part("now", t("Now"), s.지금, diffText(s.지금, s.원래)),
|
||||
part("before", t("Before"), s.전, diffText(s.지금, s.전)),
|
||||
);
|
||||
}
|
||||
|
||||
/** 글 칸 — 바꾸면(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(v), (raw) => {
|
||||
const x = raw ? Number(raw.replaceAll(",", "")) : null;
|
||||
if (x !== null && !Number.isFinite(x)) return void (input.value = v == null ? "" : String(v));
|
||||
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 cells = [
|
||||
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): HTMLElement {
|
||||
const r = it.row;
|
||||
const kids: HTMLElement[] = it.비목
|
||||
? [createBadge(it.비목, it.비목 === "별도" ? "warning" : "neutral")]
|
||||
: [];
|
||||
for (const tag of it.표) kids.push(createBadge(`${t("Table")} ${tag}`, "info"));
|
||||
if (r && opt.edit) {
|
||||
if (r.added === null && (r.covered.qty || r.covered.price))
|
||||
kids.push(
|
||||
createButton({
|
||||
label: t("Undo"),
|
||||
variant: "ghost",
|
||||
size: 22,
|
||||
onClick: () => (setLineCover(d.doc, r, null, opt.opened()), opt.touch()),
|
||||
}),
|
||||
);
|
||||
const drop = createButton({
|
||||
label: t("Drop"),
|
||||
variant: "danger-ghost",
|
||||
size: 22,
|
||||
onClick: () => (dropLine(d.doc, r), 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) });
|
||||
};
|
||||
|
||||
function rowOf(d: SheetDraw, it: SheetItem): HTMLElement[] {
|
||||
const middle =
|
||||
it.kind === "옵션"
|
||||
? optionCell(d, it)
|
||||
: el("div", {
|
||||
className: "m02-sks__mid",
|
||||
children: [
|
||||
createCalcFormula(it.식 || (it.kind === "계산" ? t("Hidden") : "")),
|
||||
...(it.row ? editLine(d, it.row) : []),
|
||||
],
|
||||
});
|
||||
const row = createCalcSheetRow(it.이름, middle, valueOf(it), tailOf(d, it));
|
||||
row.dataset.sheetKind = it.kind;
|
||||
if (it.row) row.dataset.sheetCid = it.row.cid;
|
||||
if (it.changed) row.classList.add("is-changed");
|
||||
return [row];
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
const out: HTMLElement[] = [];
|
||||
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)
|
||||
: [];
|
||||
if (!mine.length && !tools.length) continue;
|
||||
out.push(head(sec, tools));
|
||||
if (sec === "자원")
|
||||
out.push(
|
||||
...removedStrip(d.removed, opt.edit, (cid) => (restoreLine(d.doc, cid), opt.touch())),
|
||||
);
|
||||
for (const it of mine) out.push(...rowOf(d, it));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function drawPaper(d: SheetDraw): HTMLElement[] {
|
||||
return paperLines(d.text, d.rows, d.ans).map((p) => {
|
||||
const line = el("p", {
|
||||
className: `m02-sks__paper m02-sks__paper--${p.kind === "줄" ? "line" : "sum"}`,
|
||||
attrs: { "data-paper": p.kind },
|
||||
text: p.글,
|
||||
});
|
||||
return line;
|
||||
});
|
||||
}
|
||||
|
||||
function draw(d: SheetDraw): void {
|
||||
last = d;
|
||||
if (before === null && d.ans?.기준?.계 != null) before = d.ans.기준.계;
|
||||
drawStatus(d);
|
||||
const active = document.activeElement as HTMLElement | null;
|
||||
const keep = active && host.contains(active) ? active.dataset.sheetCell : undefined;
|
||||
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: () => void (before = last?.ans?.기준?.계 ?? null),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
/* =============================================================================
|
||||
* 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;
|
||||
}
|
||||
|
||||
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;
|
||||
}): 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;
|
||||
if (NUM_RE.test(raw)) item({ kind: "상수", 이름: f.이름, 값: 값 ?? Number(raw) });
|
||||
else if (LOOKUP_RE.test(raw)) item({ kind: "표", 이름: f.이름, 값, 표: tablesOf(raw) });
|
||||
else item({ kind: "계산", 이름: f.이름, 식: 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,
|
||||
});
|
||||
}
|
||||
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,
|
||||
): 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 : [];
|
||||
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}` });
|
||||
}
|
||||
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))}`;
|
||||
@@ -27,8 +27,8 @@ def _text(path: Path) -> str:
|
||||
def test_721_1_단추_이름과_자리():
|
||||
detail = _text(M02 / "M02_MasterTemplete_Sangeun_Detail.ts")
|
||||
assert (
|
||||
"children: [views.root, addButton, rateButton]" in detail
|
||||
) # 750-2 안내 걷음 · 778-4 율 줄 추가
|
||||
"children: [sheet.picker, addButton, rateButton]" in detail
|
||||
) # 750-2 안내 걷음 · 778-4 율 줄 추가 · 790-1 보기 고르기 = 시트 탭 부품
|
||||
assert "M02_SangeunPickBase" not in detail # 727-2 단추 걷음
|
||||
locale = _text(ROOT / "ui_template" / "ui_template_locale_m2.ts")
|
||||
assert "M02_SangeunPickBase" not in locale
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
"""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", ""]
|
||||
@@ -77,6 +77,43 @@
|
||||
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,6 +2,7 @@
|
||||
* 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
|
||||
@@ -63,3 +64,22 @@ 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;
|
||||
}
|
||||
|
||||
@@ -40,7 +40,12 @@ 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 } from "./ui_template_calc_row";
|
||||
import {
|
||||
createCalcFormula,
|
||||
createCalcFormulaRow,
|
||||
createCalcRow,
|
||||
createCalcSheetRow,
|
||||
} 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";
|
||||
@@ -227,6 +232,7 @@ 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 견본 조각 — 상태를 안쪽 요소에 굳힘 */
|
||||
@@ -373,7 +379,7 @@ export const KIT_PARTS: KitPart[] = [
|
||||
demo: (s) => (s === "default" ? statGrid([createStat({ label: "진행 중", value: 12, unit: "건", delta: "+3" }), createStat({ label: "디스크", value: "71 %", gauge: 0.71, tone: "warn" }), createStat({ label: "오류", value: 2, delta: "-1", tone: "bad" }), createStat({ label: "회사", value: 8, compact: true })]) : null) },
|
||||
{ key: "kv", name: "값 목록", group: "compound", kinds: "이름 : 값 줄 · 판정(✓ ✗) · 큰 값 · 규칙 꼴(키 칩 = 설명)", file: "ui_template_kv.ts",
|
||||
demo: (s) => (s === "default" ? col(createKv([{ label: "연장", value: "1,080 m", big: true }, { label: "종단 경사", value: "12 %", ok: true }, { label: "최소 곡선반경", value: "12 m", ok: false }, { label: "비고", value: null }]), createKv([{ label: kitKeys("Ctrl", "Z"), value: "되돌리기" }, { label: kitKeys("휠 끌기"), value: "화면 옮기기" }, { label: kitKeys("Esc") }], { rule: true })) : null) },
|
||||
{ key: "calc-row", name: "계산 줄", group: "compound", kinds: "값 줄(이름 · 값 112 · 덧칸) · 식 줄(이름 · 식 · 값) · 위 · 아래첨자 · 중요 결과 굵게", file: "ui_template_calc_row.ts",
|
||||
{ key: "calc-row", name: "계산 줄", group: "compound", kinds: "값 줄(이름 · 값 112 · 덧칸) · 식 줄(이름 · 식 · 값) · 시트 줄(이름 · 식 · 값 · 꼬리) · 위 · 아래첨자 · 중요 결과 굵게", file: "ui_template_calc_row.ts",
|
||||
demo: (s) => (s === "default" ? kitCalc() : null) },
|
||||
{ key: "hopyo-grid", name: "일위대가 격자", group: "compound", kinds: "호표 머리(이름 · 규격 · 단위 · 1 단위 합계) · 구성 줄(품명 · 규격 · 수량 · 단위 · 비목 단가 / 금액 · 비고) · 줄 꼬리표 품셈 · 고정 · 합계 줄 빈 줄 붉게", file: "ui_template_hopyo_grid.ts", demo: (s) => (s === "default" ? createHopyoGrid([{ key: "D", no: 17, name: "돌기슭막이 H=2.0", unit: "m", total: { sum: 121748.6, labor: 94447.5, material: 0, expense: 27301.1 }, lines: [{ name: "돌쌓기 찰쌓기", spec: "뒷길이 35cm", qty: 2.09, unit: "㎡", prices: { sum: 52995, labor: 41250, expense: 11745 }, amounts: { sum: 110759.6, labor: 86212.5, expense: 24547.1 }, note: "산근 1호표", tag: "품셈" }, { name: "야면석 채집 운반", spec: "계곡 내", qty: 0.45, unit: "㎥", prices: { sum: 24420, labor: 18300, expense: 6120 }, amounts: { sum: 10989, labor: 8235, expense: 2754 }, tag: "고정" }, { name: "잡재료", spec: "노무비의", qty: null, unit: "%", prices: {}, amounts: {}, note: "수량 없음", tag: "품셈" }] }]).root : null) },
|
||||
{ key: "progress", name: "진행 막대", group: "compound", kinds: "막대 6 · 8 · 쌓임 막대 · 원 · 원 귀퉁이(corner) · 오류", file: "ui_template_progress.ts",
|
||||
|
||||
Reference in New Issue
Block a user