Files
Aislo/A00_Common/line_stack_edit.ts
T
eomsangdonandClaude Opus 5.5 c11baa441c 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>
2026-11-08 00:34:29 +09:00

378 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* line_stack_edit.ts
* 줄 쌓기 왼쪽 편집(PLAN 804-2 · 요구 670~677) — 항목(제목 · 가나다 · [+ 줄] · 지움) 안 줄 카드(위 · 아래 · 지움)
* 줄 = 인력 · 재료 · 기계 · 다른 단가산출 · 율 · 식(금액 줄 — 이름 · 단위 · 비목 · 수량 식 · 단가 고르기 또는 식)
* · 공식 묶음(`line_stack_rows.ts`) · 글 줄
* 고침 = 부른 쪽 `change(fn)` — fn 이 문서 · 차림을 고치면 부른 쪽이 `docOf` 로 굳히고 미리 셈 · 다시 그림(입력은 change 때)
* 자원 찾기 = 수식 탭과 같은 길(`/links` 요소 인력 · 재료 · 단가산출) · 기계 = 시간당 기계경비 단가산출 + 기종(그 문서 기계 목록)
* ========================================================================== */
import { createButton } from "@ui/ui_template_button";
import { createCheckbox } from "@ui/ui_template_checkbox";
import { el } from "@ui/ui_template_elements";
import { createInputField, createSelectField } from "@ui/ui_template_input";
import { varId, type FormulaDoc, type MoneyLine } from "../common_util/common_util_formula_doc";
import type { DocAnswer } from "../common_util/common_util_formula_doc_calc";
import {
dropSyms,
freshId,
kindOf,
newVar,
ownedOf,
type MoneyKind,
type StackDoc,
type StackItem,
} from "../common_util/common_util_line_stack";
import type { Pick } from "./formula_doc_defs";
import { buildBlock, insertTpl, type Tpl } from "./line_stack_rows";
import { lt, type LsKey } from "./line_stack_text";
export interface EditHooks {
doc: StackDoc;
answer: DocAnswer;
items: StackItem[];
change: (fn: (d: StackDoc, items: StackItem[]) => void) => void;
ask: (kind: string, words: string, group?: string) => Promise<{ 후보: Pick[] }>;
subDoc: (key: string) => FormulaDoc | null;
name: (key: string) => string;
/** 공식 묶음 펼친 자리(`항목.줄`) — 화면 상태만 */
open: Set<string>;
templates: () => Tpl[];
saveTpl: (tpl: Tpl) => void;
}
const field = (
label: string,
value: unknown,
set: (t: string) => void,
size: "s" | "m" | "l" = "m",
): HTMLElement => {
const f = createInputField({
label,
value: value === undefined || value === null ? "" : String(value),
compact: true,
size: 24,
});
f.root.classList.add(`lstk-f-${size}`);
f.input.addEventListener("change", () => set(f.input.value));
return f.root;
};
const btn = (label: string, onClick: () => void, variant: "ghost" | "danger-ghost" = "ghost") =>
createButton({ label, variant, size: 24, onClick });
/** 자원 찾기 — 찾는 말 → 후보 고르기 */
function finder(
h: EditHooks,
kind: string,
group: string | undefined,
seed: string,
onPick: (p: Pick) => void,
): HTMLElement {
const words = createInputField({
placeholder: lt("Find"),
value: seed,
compact: true,
size: 24,
type: "search",
});
const pick = createSelectField({
options: [{ value: "", text: lt("Pick") }],
compact: true,
size: 24,
ariaLabel: lt("Pick"),
});
let found: Pick[] = [];
const run = (): void =>
void h.ask(kind, words.input.value, group).then((got) => {
found = got.후보 ?? [];
pick.setOptions([
{ value: "", text: found.length ? lt("Pick") : lt("None_Found") },
...found.map((p, k) => ({
value: String(k),
text: [p.이름, p.규격].filter(Boolean).join(" "),
})),
]);
});
words.input.addEventListener("change", run);
pick.select.addEventListener("change", () => {
const p = found[Number(pick.select.value)];
if (pick.select.value !== "" && p) onPick(p);
});
return el("div", { className: "lstk-find", children: [words.root, pick.root] });
}
const KIND_KEY: Record<MoneyKind, LsKey> = {
인력: "K_인력",
재료: "K_재료",
기계: "K_기계",
단가산출: "K_단가산출",
율: "K_율",
};
/** 금액 줄 몸 */
function moneyBody(h: EditHooks, i: number): HTMLElement[] {
const d = h.doc;
const line = (d.금액 ?? [])[i];
const kind = kindOf(d, i, h.subDoc);
const own = ownedOf(d).get(i);
const v = own ? d.변수?.find((x) => varId(x) === own) : undefined;
const put = (fn: (l: MoneyLine & { 규격?: string }) => void): void =>
h.change((doc) => fn(doc.금액![i]));
const setVar = (
fn: (link: NonNullable<typeof v>["연결"] & object, l: MoneyLine & { 규격?: string }) => void,
): void =>
h.change((doc) => {
const x = doc.변수!.find((y) => varId(y) === own)!;
x.연결 ??= {};
fn(x.연결, doc.금액![i]);
});
const named = (p: Pick, l: MoneyLine & { 규격?: string }): void => {
l.이름 = p.이름 ?? l.이름;
if (p.규격) l.규격 = p.규격;
else delete l.규격;
};
const head = el("div", {
className: "lstk-row",
children: [
field(lt("Name"), line.이름, (t) => put((l) => (l.이름 = t)), "l"),
field(lt("Unit"), line.단위, (t) => put((l) => (l.단위 = t)), "s"),
...(line.비목 === "묶음"
? []
: [
createSelectField({
label: lt("Cost"),
options: ["노무비", "재료비", "경비"].map((x) => ({ value: x, text: x })),
value: line.비목 ?? "노무비",
compact: true,
size: 24,
onChange: (x) => put((l) => (l.비목 = x)),
}).root,
]),
],
});
const qty = field(
lt("Qty"),
line.수량 ?? "",
(t) => put((l) => (t.trim() ? (l.수량 = t.trim()) : delete l.수량)),
"l",
);
const price: HTMLElement[] = [];
if (own && v) {
const link = v.연결 ?? {};
const label = link.키
? link.갈래 === "단가산출"
? (h.subDoc(link.키)?.이름 ?? h.name(link.키))
: h.name(link.키)
: "";
price.push(el("span", { className: "lstk-link", text: label || "—" }));
if (link.갈래 === "요소")
price.push(
finder(h, "요소", kind === "인력" ? "인력" : "재료", "", (p) =>
setVar((k, l) => {
for (const x of Object.keys(k)) delete (k as Record<string, unknown>)[x];
Object.assign(k, { 갈래: "요소", 키: p.키 });
named(p, l);
}),
),
);
else {
const sub = link.키 ? h.subDoc(link.키) : null;
const codes = sub?.변수?.find((x) => x.열쇠 === "기계")?.연결;
if (kind === "기계" && codes?.목록) {
const now = String(link.입력?.기계 ?? `'${codes.값 ?? ""}'`).replace(/^'|'$/g, "");
price.push(
createSelectField({
label: lt("Machine"),
options: codes.목록.map((x) => ({ value: String(x), text: String(x) })),
value: now,
compact: true,
size: 24,
onChange: (x) => setVar((k) => (k.입력 = { ...(k.입력 ?? {}), 기계: `'${x}'` })),
}).root,
);
}
price.push(
finder(
h,
"단가산출",
undefined,
!link.키 && own.startsWith("P_") ? "시간당 기계경비" : "",
(p) =>
setVar((k, l) => {
const keep = k.키 === p.키 ? (k.입력 ?? {}) : {};
for (const x of Object.keys(k)) delete (k as Record<string, unknown>)[x];
Object.assign(k, { 갈래: "단가산출", 키: p.키, 입력: keep });
if (!l.이름) named(p, l);
}),
),
);
}
} else price.push(field(lt("Price"), line.단가, (t) => put((l) => (l.단가 = t.trim())), "l"));
const why = (h.answer.줄?.[i] as { 까닭?: string } | undefined)?.까닭;
return [
el("span", { className: "lstk-kind", text: lt(KIND_KEY[kind]) }),
head,
el("div", { className: "lstk-row", children: [qty, el("span", { text: "×" }), ...price] }),
...(why ? [el("div", { className: "lstk-miss", text: why })] : []),
];
}
/** [+ 줄] 고르개 값 → 새 줄 */
function addLine(h: EditHooks, at: number, kind: string): void {
if (kind.startsWith("tpl:")) {
const t = h.templates().find((x) => x.id === kind.slice(4));
if (t) h.change((d, items) => items[at].줄.push(insertTpl(d, t)));
return;
}
h.change((d, items) => {
const it = items[at];
const n = (d.금액 ??= []).length;
const money = (line: MoneyLine, id?: string, link?: Record<string, unknown>): void => {
if (id) (d.변수 ??= []).push(newVar(id, link as never));
d.금액!.push(line);
it.줄.push({ 꼴: "금액", i: n });
};
if (kind === "인력" || kind === "재료") {
const id = freshId(d, kind === "인력" ? "w" : "P", "새");
money(
{
이름: "",
단위: kind === "인력" ? "인" : "",
비목: kind === "인력" ? "노무비" : "재료비",
수량: "1",
단가: id,
},
id,
{ 갈래: "요소" },
);
} else if (kind === "기계" || kind === "단가산출") {
const id = freshId(d, kind === "기계" ? "P" : "B", "새");
money(
{ 이름: "", 단위: kind === "기계" ? "hr" : "", 비목: "묶음", 수량: "1", 단가: id },
id,
{ 갈래: "단가산출", 입력: {} },
);
} else if (kind === "율")
money({ 이름: "공구손료 및 경장비", 단위: "식", 비목: "경비", 단가: "노무비 × 3 %" });
else if (kind === "공식") {
const id = freshId(d, "x");
(d.변수 ??= []).push(newVar(id, { 갈래: "숫자", 값: 0 }));
it.줄.push({ 꼴: "공식", 기호: [id] });
h.open.add(`${at}.${it.줄.length - 1}`);
} else if (kind === "틀") {
const id = freshId(d, "Q", "틀");
(d.변수 ??= []).push(newVar(id, { 갈래: "단가산출", 키: "", 입력: {} }));
it.줄.push({ 꼴: "공식", 기호: [id] });
} else if (kind === "글") it.줄.push({ 꼴: "글", 글: "" });
});
}
/** 줄 옮김 — 항목 끝을 넘으면 옆 항목으로 */
function move(items: StackItem[], i: number, j: number, by: -1 | 1): void {
const [line] = items[i].줄.splice(j, 1);
const to = j + by;
if (to < 0 && i > 0) items[i - 1].줄.push(line);
else if (to > items[i].줄.length && i < items.length - 1) items[i + 1].줄.unshift(line);
else items[i].줄.splice(Math.max(0, Math.min(to, items[i].줄.length)), 0, line);
}
/** 줄 지움 — 금액 줄은 그 줄이 가진 연결 기호까지 · 공식 묶음은 기호까지 */
function dropLine(d: StackDoc, items: StackItem[], i: number, j: number): void {
const line = items[i].줄[j];
if (line.꼴 === "금액") {
const own = ownedOf(d).get(line.i);
if (own) dropSyms(d, [own]);
} else if (line.꼴 === "공식") dropSyms(d, line.기호);
items[i].줄.splice(j, 1);
}
export function buildEdit(h: EditHooks): HTMLElement {
const add = (at: number): HTMLElement =>
createSelectField({
options: [
{ value: "", text: `${lt("Add_Line")} ▾` },
...(["인력", "재료", "기계", "단가산출", "율"] as MoneyKind[]).map((k) => ({
value: k,
text: lt(KIND_KEY[k]),
})),
{ value: "공식", text: lt("K_새공식") },
{ value: "틀", text: lt("K_틀") },
...h
.templates()
.map((t) => ({ value: `tpl:${t.id}`, text: `${lt("K_공식")} — ${t.name}` })),
{ value: "글", text: lt("K_글") },
],
value: "",
compact: true,
size: 24,
ariaLabel: lt("Add_Line"),
onChange: (x) => x && addLine(h, at, x),
}).root;
const items = h.items.map((it, i) => {
const head = el("div", {
className: "lstk-ihead",
children: [
field(lt("Title"), it.제목, (t) => h.change((_, items) => (items[i].제목 = t.trim())), "l"),
createCheckbox(lt("Gana"), {
checked: !!it.가나다,
size: 24,
onChange: (c) =>
h.change((_, items) => (c ? (items[i].가나다 = true) : delete items[i].가나다)),
}).root,
add(i),
btn(
lt("Del_Item"),
() =>
h.change((d, items) => {
for (let j = items[i].줄.length - 1; j >= 0; j--) dropLine(d, items, i, j);
items.splice(i, 1);
}),
"danger-ghost",
),
],
});
const lines = it.줄.map((line, j) => {
const tools = el("div", {
className: "lstk-ltools",
children: [
btn("↑", () => h.change((_, items) => move(items, i, j, -1))),
btn("↓", () => h.change((_, items) => move(items, i, j, 1))),
btn(lt("Del"), () => h.change((d, items) => dropLine(d, items, i, j)), "danger-ghost"),
],
});
const body =
line.꼴 === "금액"
? moneyBody(h, line.i)
: line.꼴 === "글"
? [
field(
lt("K_글"),
line.글,
(t) => h.change((_, items) => ((items[i].줄[j] as { 글: string }).글 = t)),
"l",
),
]
: buildBlock(h, i, j, line);
const card = el("div", { className: `lstk-line k-${line.꼴}`, children: [tools, ...body] });
card.dataset.loc = `${i}.${j}`;
return card;
});
return el("div", {
className: "lstk-item",
children: [
head,
...(lines.length
? lines
: [el("div", { className: "lstk-empty", text: lt("Empty_Item") })]),
],
});
});
return el("div", {
className: "lstk-edit",
children: [
...items,
btn(lt("Add_Item"), () => h.change((_, items) => items.push({ 제목: "", 줄: [] }))),
],
});
}