/* ============================================================================= * 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; 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 = { 인력: "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["연결"] & 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)[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)[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): 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({ 제목: "", 줄: [] }))), ], }); }