feat(804-2): 단가산출 「줄 쌓기」 새 탭 — M01 단가산출 기초 · M02 단가산출 · 기계 시간당 경비 수식 펼침 · KaTeX
- 공용 화면 A00_Common/line_stack_* — 위 옵션 · 왼쪽 줄 쌓기 편집 · 오른쪽 산출근거(경쟁사 시트 꼴) · 맨 아래 합계 상자 - 모델 common_util_line_stack.ts — 수식 문서 ↔ 줄 쌓기 한 벌 왕복(차림 = 문서 칸 `쌓기` · 기본과 다를 때만) - 기계 · 단가산출 줄 단가 수 누름 = 그 문서를 같은 계산기로 셈해 펼침(손료 식 · 운전경비 · 노 · 재 · 경 · 계) - 공식 묶음 · 틀 풀기(단가산출 결과 → 이 문서 식) · 내 틀(이 브라우저) · 「내 틀 (… 에서 변경)」 - 수식 탭과 같은 길 · 같은 캐시 · 탭 바꾸면 다시 읽음 · [수식 저장] = 서버 정본 셈 - KaTeX(MIT) — vite 미리 묶기에서 뺌(rolldown 이 글자 규칙 서로게이트 범위를 깨뜨림) - 시험 test_804_2 6(왕복 1,518 · 금액 · 기계 펼침 = hourly · 틀 풀기 · 저장 · 이음) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
/* =============================================================================
|
||||
* line_stack_rows.ts
|
||||
* 줄 쌓기 공식 묶음(PLAN 804-2 · 요구 675) — 「이름 = 값」 · 「이름 = 식」 줄 묶음 · 틀 이름표 · [식으로 풀기] · [틀로 저장]
|
||||
* 접힘 = 값 줄은 값 칸(연결 값을 고쳐 적으면 숫자 = 고정값 · 요구 664) · 식 줄은 「이름 = 식 = 값」 글 · 단가산출 결과 기호 = [식으로 풀기]
|
||||
* 풀림 = 줄마다 이름 · 식 · 단위 칸 + 지움 · [+ 줄] — 이름 바꿈 = 문서 전부(`rename`) · 식 = 식 줄 · 수 = 숫자 연결
|
||||
* 틀 = 단가산출 결과 문서(굴착기 Q 등)를 이 문서 식으로 옮김(`unfold`) · 내 틀 = 이 브라우저 저장(localStorage) — 꼴이 바뀌면
|
||||
* 「내 틀 (틀 이름 에서 변경)」
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton } from "@ui/ui_template_button";
|
||||
import { eqText } from "@ui/ui_template_equation";
|
||||
import { askText, el, showToast } from "@ui/ui_template_elements";
|
||||
import { createInputField } from "@ui/ui_template_input";
|
||||
import {
|
||||
DocError,
|
||||
rename,
|
||||
splitId,
|
||||
varId,
|
||||
type FormulaDoc,
|
||||
} from "../common_util/common_util_formula_doc";
|
||||
import { isNum } from "../common_util/common_util_formula_doc_calc";
|
||||
import {
|
||||
dropSyms,
|
||||
freshId,
|
||||
newVar,
|
||||
setRow,
|
||||
shapeOf,
|
||||
unfold,
|
||||
type StackDoc,
|
||||
type StackItem,
|
||||
type StackLine,
|
||||
} from "../common_util/common_util_line_stack";
|
||||
import type { EditHooks } from "./line_stack_edit";
|
||||
import { lt } from "./line_stack_text";
|
||||
|
||||
/** 내 틀 — 기호 · 식 조각(이 브라우저) */
|
||||
export interface Tpl {
|
||||
id: string;
|
||||
name: string;
|
||||
문서: FormulaDoc;
|
||||
}
|
||||
|
||||
const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v)) as T;
|
||||
type Block = Extract<StackLine, { 꼴: "공식" }>;
|
||||
|
||||
/** 틀 넣기 — 새 공식 묶음 줄 */
|
||||
export function insertTpl(d: StackDoc, t: Tpl): Block {
|
||||
const moved = unfold(d, t.문서);
|
||||
return { 꼴: "공식", 기호: moved, 틀: `my:${t.id}`, 틀꼴: shapeOf(d, moved) };
|
||||
}
|
||||
|
||||
const LINK_TAG: Record<string, string> = {
|
||||
표: "표",
|
||||
요소: "요소",
|
||||
단가산출: "단가산출",
|
||||
선택: "선택",
|
||||
입력: "입력",
|
||||
};
|
||||
|
||||
const input = (
|
||||
value: string,
|
||||
placeholder: string,
|
||||
cls: string,
|
||||
set: (t: string) => void,
|
||||
): HTMLElement => {
|
||||
const f = createInputField({ value, placeholder, compact: true, size: 24 });
|
||||
f.root.classList.add(cls);
|
||||
f.input.addEventListener("change", () => set(f.input.value));
|
||||
return f.root;
|
||||
};
|
||||
|
||||
export function buildBlock(h: EditHooks, i: number, j: number, line: Block): HTMLElement[] {
|
||||
const d = h.doc;
|
||||
const at = `${i}.${j}`;
|
||||
const open = h.open.has(at);
|
||||
const vals = (h.answer.값 ?? {}) as Record<string, unknown>;
|
||||
const vars = new Map((d.변수 ?? []).map((v) => [varId(v), v]));
|
||||
const lines = new Map((d.식 ?? []).map((l) => [String(l.기호 ?? ""), String(l.식 ?? "")]));
|
||||
const blockOf = (items: StackItem[]): Block => items[i].줄[j] as Block;
|
||||
const valText = (id: string): string => {
|
||||
const v = vals[id];
|
||||
if (isNum(v) || typeof v === "string") return `${eqText(v)} ${vars.get(id)?.단위 ?? ""}`.trim();
|
||||
if (v && typeof v === "object")
|
||||
return ["노무비", "재료비", "경비"]
|
||||
.map((c) => `${c} ${eqText((v as Record<string, unknown>)[c])}`)
|
||||
.join(" · ");
|
||||
return `— ${h.answer.빈값?.[id] ?? ""}`.trim();
|
||||
};
|
||||
|
||||
// ── 이름표 ──
|
||||
const tplName = (k: string): string =>
|
||||
k.startsWith("my:")
|
||||
? (h.templates().find((t) => `my:${t.id}` === k)?.name ?? lt("My_Tpl"))
|
||||
: (h.subDoc(k)?.이름 ?? (h.name(k) || k));
|
||||
const changed = !!line.틀 && !!line.틀꼴 && shapeOf(d, line.기호) !== line.틀꼴;
|
||||
const label = !line.틀
|
||||
? lt("K_공식")
|
||||
: changed
|
||||
? `${lt("My_Tpl")} (${tplName(line.틀)} ${lt("Changed_From")})`
|
||||
: tplName(line.틀);
|
||||
const head = el("div", {
|
||||
className: "lstk-fhead",
|
||||
children: [
|
||||
el("span", { className: "lstk-kind", text: label }),
|
||||
createButton({
|
||||
label: open ? lt("Fold") : lt("Unfold"),
|
||||
variant: "ghost",
|
||||
size: 24,
|
||||
onClick: () => {
|
||||
if (open) h.open.delete(at);
|
||||
else h.open.add(at);
|
||||
h.change(() => undefined);
|
||||
},
|
||||
}),
|
||||
createButton({
|
||||
label: lt("Save_Tpl"),
|
||||
variant: "ghost",
|
||||
size: 24,
|
||||
onClick: () =>
|
||||
void askText({ message: lt("Tpl_Name"), value: label }).then((name) => {
|
||||
if (!name?.trim()) return;
|
||||
const ids = new Set(line.기호);
|
||||
const id = `t${Date.now()}`;
|
||||
h.saveTpl({
|
||||
id,
|
||||
name: name.trim(),
|
||||
문서: {
|
||||
변수: clone((d.변수 ?? []).filter((v) => ids.has(varId(v)))),
|
||||
식: clone((d.식 ?? []).filter((l) => ids.has(String(l.기호 ?? "")))),
|
||||
},
|
||||
});
|
||||
h.change((doc, items) =>
|
||||
Object.assign(blockOf(items), { 틀: `my:${id}`, 틀꼴: shapeOf(doc, line.기호) }),
|
||||
);
|
||||
}),
|
||||
}),
|
||||
],
|
||||
});
|
||||
head.dataset.tpl = changed ? "changed" : line.틀 ? "tpl" : "";
|
||||
|
||||
// ── 줄 ──
|
||||
const renameRow = (old: string, now: string): void => {
|
||||
const t = now.trim();
|
||||
if (!t || t === old) return;
|
||||
try {
|
||||
const copy = clone(d);
|
||||
const [s, lo, up] = splitId(t);
|
||||
rename(copy, old, s, lo, up);
|
||||
} catch (e) {
|
||||
if (e instanceof DocError) return void showToast(lt("Bad_Name"), "warning");
|
||||
throw e;
|
||||
}
|
||||
h.change((doc, items) => {
|
||||
const [s, lo, up] = splitId(t);
|
||||
rename(doc, old, s, lo, up);
|
||||
for (const it of items)
|
||||
for (const l of it.줄) if (l.꼴 === "공식") l.기호 = l.기호.map((x) => (x === old ? t : x));
|
||||
});
|
||||
};
|
||||
const unfoldRow = (id: string, sub: FormulaDoc, key: string): void =>
|
||||
h.change((doc, items) => {
|
||||
const moved = unfold(doc, sub, id);
|
||||
const blk = blockOf(items);
|
||||
blk.기호 = blk.기호.filter((x) => x !== id);
|
||||
items[i].줄.splice(j + 1, 0, { 꼴: "공식", 기호: moved, 틀: key, 틀꼴: shapeOf(doc, moved) });
|
||||
if (!blk.기호.length) items[i].줄.splice(j, 1);
|
||||
});
|
||||
const rows = line.기호.map((id) => {
|
||||
const v = vars.get(id);
|
||||
const link = v?.연결;
|
||||
const expr = lines.get(id);
|
||||
const sub = link?.갈래 === "단가산출" && link.키 ? h.subDoc(link.키) : null;
|
||||
const tag =
|
||||
link && LINK_TAG[link.갈래 ?? ""] && !expr
|
||||
? el("span", {
|
||||
className: "lstk-tag",
|
||||
text: `${LINK_TAG[link.갈래!]}${link.키 && !link.키.includes("{") ? ` · ${sub?.이름 ?? h.name(link.키) ?? ""}` : ""}`,
|
||||
})
|
||||
: null;
|
||||
const tools: HTMLElement[] = [];
|
||||
if (sub?.결과 && link?.키)
|
||||
tools.push(
|
||||
createButton({
|
||||
label: lt("Unfold"),
|
||||
variant: "ghost",
|
||||
size: 24,
|
||||
onClick: () => unfoldRow(id, sub, link.키!),
|
||||
}),
|
||||
);
|
||||
if (link?.갈래 === "단가산출" && !link.키)
|
||||
tools.push(el("span", { className: "lstk-hint", text: lt("K_틀") }));
|
||||
const name = el("b", { className: "lstk-sym", text: id });
|
||||
const valueOf =
|
||||
expr ??
|
||||
(link?.갈래 === "숫자" ? String(link.값 ?? "") : isNum(vals[id]) ? String(vals[id]) : "");
|
||||
let body: (HTMLElement | string)[];
|
||||
if (open)
|
||||
body = [
|
||||
input(id, lt("Row_Name"), "lstk-f-s", (t) => renameRow(id, t)),
|
||||
"=",
|
||||
input(valueOf, lt("Row_Expr"), "lstk-f-l", (t) => h.change((doc) => setRow(doc, id, t))),
|
||||
input(v?.단위 ?? "", lt("Unit"), "lstk-f-s", (t) =>
|
||||
h.change((doc) => {
|
||||
let x = doc.변수?.find((y) => varId(y) === id);
|
||||
if (!x) (doc.변수 ??= []).push((x = newVar(id, { 갈래: "식" })));
|
||||
x.단위 = t.trim();
|
||||
}),
|
||||
),
|
||||
el("span", { className: "lstk-val", text: `= ${valText(id)}` }),
|
||||
createButton({
|
||||
label: "×",
|
||||
variant: "danger-ghost",
|
||||
size: 24,
|
||||
onClick: () => h.change((doc) => dropSyms(doc, [id])),
|
||||
}),
|
||||
];
|
||||
else if (expr !== undefined || sub || link?.갈래 === "단가산출")
|
||||
body = [
|
||||
name,
|
||||
`= ${expr ?? ""}${expr ? " =" : ""}`,
|
||||
el("span", { className: "lstk-val", text: valText(id) }),
|
||||
];
|
||||
else
|
||||
body = [
|
||||
name,
|
||||
"=",
|
||||
input(valueOf, valText(id), "lstk-f-m", (t) => h.change((doc) => setRow(doc, id, t))),
|
||||
el("span", { className: "lstk-unit", text: v?.단위 ?? "" }),
|
||||
];
|
||||
const row = el("div", {
|
||||
className: `lstk-frow ${open ? "is-open" : ""}`,
|
||||
children: [...body, ...(tag ? [tag] : []), ...tools],
|
||||
});
|
||||
row.dataset.sym = id;
|
||||
return row;
|
||||
});
|
||||
const more = open
|
||||
? [
|
||||
createButton({
|
||||
label: lt("Add_Row"),
|
||||
variant: "ghost",
|
||||
size: 24,
|
||||
onClick: () =>
|
||||
h.change((doc, items) => {
|
||||
const id = freshId(doc, "x");
|
||||
(doc.변수 ??= []).push(newVar(id, { 갈래: "숫자", 값: 0 }));
|
||||
blockOf(items).기호.push(id);
|
||||
}),
|
||||
}),
|
||||
]
|
||||
: [];
|
||||
return [head, ...rows, ...more];
|
||||
}
|
||||
Reference in New Issue
Block a user