- 공용 화면 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>
378 lines
14 KiB
TypeScript
378 lines
14 KiB
TypeScript
/* =============================================================================
|
||
* 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({ 제목: "", 줄: [] }))),
|
||
],
|
||
});
|
||
}
|