/* ============================================================================= * line_stack_edit.ts * 줄 쌓기 왼쪽 편집(PLAN 804-2 · 요구 670~677) — 항목(띠 · 번호 · 제목 · 가나다 · [+ 줄] · 접기 · X) 안 줄 카드(⋮⋮ · X) * 차림(804-5 · 요구 682) = 항목 머리 띠 · 줄 = 들여쓰기 + 왼쪽 세로 선 · 순서 = 손잡이 끌기(`line_stack_drag.ts`) · * X = 확인 없이 지우고 알림 눌러 되돌리기 한 번 · 자원 이름 = 단추(누르면 찾아 넣기 창 → 바꿈) * 줄 = 인력 · 재료 · 기계 · 다른 단가산출 · 율 · 식(금액 줄 — 이름 · 단위 · 비목 · 수량 식 · 단가 고르기 또는 식) * · 공식 묶음(`line_stack_rows.ts`) · 글 줄 * 고침 = 부른 쪽 `change(fn)` — fn 이 문서 · 차림을 고치면 부른 쪽이 `docOf` 로 굳히고 미리 셈 · 다시 그림(입력은 change 때) * 자원 고르기(804-3) = 찾아 넣기 창(`line_stack_pick.ts` — M02 [항목 추가] 창 그대로) · [+ 줄] 인력 · 재료 · 기계 · 다른 단가산출 = * 고르면 줄이 생김(닫으면 안 생김) · 있는 줄 = 자원 이름 단추 * ========================================================================== */ import { createButton, createIconButton } from "@ui/ui_template_button"; import { createRowMenu } from "@ui/ui_template_context_menu"; import { eqText } from "@ui/ui_template_equation"; import { el, showToast } 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 { isNum, type DocAnswer } from "../common_util/common_util_formula_doc_calc"; import { lineMap, offLine, type Values } from "../common_util/common_util_formula_doc_view"; import type { DocTable } from "../common_util/common_util_formula_doc_table"; import { dropSyms, docOf, freshId, kindOf, moveItem, moveLine, newVar, ownedOf, setRow, stackOf, type MoneyKind, type StackDoc, type StackItem, } from "../common_util/common_util_line_stack"; import { placeNames } from "../common_util/common_util_line_stack_copy"; import { addPicked, aliasText, repick, shownUnit, tableAlias, } from "../common_util/common_util_line_stack_pick"; import { createUnitPick, NO_UNIT } from "../M02_MasterTemplete/DataFlow/M02_DF_UnitPick"; import { reach } from "./formula_doc_render"; import { attachDrag } from "./line_stack_drag"; import { openStackPick, type StackTake } from "./line_stack_pick"; import { buildBlock, sourceTag } 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; /** 품셈 표 하나(표 고르기 창) */ table: (key: string) => Promise; subDoc: (key: string) => FormulaDoc | null; name: (key: string) => string; /** 공식 묶음 펼친 자리(`항목.줄`) — 화면 상태만 */ open: Set; /** 줄 · 항목 누름 — 오른쪽 짝 강조(679) */ pair: (loc: string) => void; /** M02 단가산출(804-4) — 다른 단가산출 · 일위대가 = 복사해 펼침 · M01 = 없음(M01 전체만) */ take?: StackTake; /** 저장본 식 줄 {기호: 식} — 만약 식을 고쳐 굳은 줄 표지(요구 696) */ before?: ReadonlyMap; } const field = ( label: string, value: unknown, set: (t: string) => void, size: "s" | "m" | "l" = "m", ): HTMLElement => { const f = createInputField({ placeholder: label, value: value === undefined || value === null ? "" : String(value), compact: true, size: 24, }); f.root.classList.add(`lstk-f-${size}`); f.input.setAttribute("aria-label", label); f.input.title = label; f.input.addEventListener("change", () => set(f.input.value)); return f.root; }; const btn = (label: string, onClick: () => void) => createButton({ label, variant: "ghost", size: 24, onClick }); const KIND_KEY: Record = { 인력: "K_인력", 재료: "K_재료", 기계: "K_기계", 단가산출: "K_단가산출", 율: "K_율", }; /** 금액 줄 몸 — 한 줄(804-7 · 요구 687-7): [종류][이름 단추 | 이름 칸 · 단가 식][수량][단위][비목] · 좁으면 접힘 */ 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])); // 표 값 기호 = 화면 이름(요구 683) — 칸 글은 화면 이름 ↔ 기호로 오감 const alias = tableAlias(d); const shown = (t: unknown): string => aliasText(String(t ?? ""), alias); const real = (t: string): string => aliasText(t.trim(), alias, true); const head: HTMLElement[] = []; if (own && v) { // 이름 단추(요구 687-9) — 누르면 찾아 넣기 창이 그 이름을 찾은 채 열림 · 고르면 바꿈 const link = v.연결 ?? {}; const mat = link.재료 as Record | undefined; const size = (line as { 규격?: string }).규격; const name = String(line.이름 ?? "") || String(mat?.검색어 ?? mat?.이름 ?? ""); const res = link.키 ? link.갈래 === "단가산출" ? (h.subDoc(link.키)?.이름 ?? h.name(link.키)) : h.name(link.키) : ""; const pick = createButton({ label: (size && !name.includes(size) ? `${name} ${size}` : name) || "—", variant: "ghost", tone: "link", size: 24, iconStart: "search", onClick: () => openStackPick( kind, (p) => h.change((doc) => repick(doc, i, own, kind, p)), undefined, String(mat?.검색어 ?? line.이름 ?? ""), ), }); pick.classList.add("lstk-res"); pick.title = `${lt("Repick")}${res ? ` — ${res}` : ""}`; pick.dataset.repick = String(i); head.push(pick); } else head.push( field(lt("Name"), line.이름, (t) => put((l) => (l.이름 = t)), "m"), field(lt("Price"), shown(line.단가), (t) => put((l) => (l.단가 = real(t))), "l"), ); const why = (h.answer.줄?.[i] as { 까닭?: string } | undefined)?.까닭; // 율 밑 번지(복사해 온 조각) — 칸 아래 읽는 이름(804-5) const places = /번지\./.test(`${line.단가} ${line.수량 ?? ""}`) ? placeNames(d, h.items) : null; const read = (t: unknown): string => shown(t).replace(/번지\.([^.\s()]+)\./g, (all, p: string) => places?.[p] ? `${places[p]} ` : all, ); const unit = el("span", { className: "lstk-upick" }); const drawUnit = (): void => unit.replaceChildren( createUnitPick({ value: String(line.단위 ?? "") || NO_UNIT, size: 24, free: true, ariaLabel: lt("Unit"), extra: [...new Set((d.금액 ?? []).map((m) => String(m.단위 ?? "")).filter(Boolean))], onPick: (u) => put((l) => (u === NO_UNIT ? delete l.단위 : (l.단위 = u))), }), ); drawUnit(); const bare = !!line.합밖 && !line.비목; // 합 밖 소계 · 합계 줄(810-2) = 금액만 · 비목 칸 빈칸 return [ el("span", { className: "lstk-kind", text: lt(bare ? "K_소계" : KIND_KEY[kind]) }), ...head, // 율 · 식 줄(자원 없음)에 수량이 없으면 칸 안 보임(한 줄 유지) ...(own || String(line.수량 ?? "") ? [qtyBox(h, i, shown, real)] : []), unit, ...(line.비목 === "묶음" || bare ? [] : [ createSelectField({ options: ["노무비", "재료비", "경비"].map((x) => ({ value: x, text: x })), value: line.비목 ?? "노무비", compact: true, size: 24, ariaLabel: lt("Cost"), onChange: (x) => put((l) => (l.비목 = x)), }).root, ]), ...(places ? [ el("div", { className: "lstk-hint lstk-wide", text: `= ${[line.수량, line.단가].filter(Boolean).map(read).join(" × ")}`, }), ] : []), ...(why ? [el("div", { className: "lstk-miss lstk-wide", text: why })] : []), ]; } /** 수량 = 늘 식 글 칸(기호 하나면 그 이름 · 요구 701-1) + 그 기호에 연결이 있으면 옆 출처 꼬리표(표 = 표 고르기) · * 칸을 눌러도 안 바뀜 — 글을 실제로 고칠 때만: 표 값 기호 자리에 수를 치면 그 기호 값(별칭 · 「직접 입력」 · 804-9) · * 그 밖 = 수량 식 */ function qtyBox( h: EditHooks, i: number, shown: (t: unknown) => string, real: (t: string) => string, ): HTMLElement { const line = (h.doc.금액 ?? [])[i]; const text = String(line.수량 ?? ""); const v = h.doc.변수?.find((x) => varId(x) === text); const f = field(lt("Qty"), shown(text), (t) => h.change((doc) => { const want = real(t); if (v?.연결?.표 && /^-?[\d.,]+$/.test(want)) return setRow(doc, text, want); if (want) doc.금액![i].수량 = want; else delete doc.금액![i].수량; }), ); f.dataset.qty = String(i); const val = v ? (h.answer.값 ?? {})[text] : undefined; const tag = v ? sourceTag(h, text, v.연결) : null; if (tag && isNum(val)) tag.title = `${eqText(val)}${shownUnit(v?.단위) ? ` ${shownUnit(v?.단위)}` : ""} — ${tag.title || tag.textContent}`; return el("span", { className: "lstk-qty", children: tag ? [f, tag] : [f] }); } /** [+ 줄] 고르개 값 → 새 줄 */ function addLine(h: EditHooks, at: number, kind: string): void { if (["인력", "재료", "기계", "단가산출"].includes(kind)) return openStackPick( kind as MoneyKind, (p) => h.change((d, items) => void addPicked(d, items[at], kind as MoneyKind, p)), h.take, ); h.change((d, items) => { const it = items[at]; const n = (d.금액 ??= []).length; const money = (line: MoneyLine): void => { d.금액!.push(line); it.줄.push({ 꼴: "금액", i: n }); }; 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 === "글") it.줄.push({ 꼴: "글", 글: "" }); }); } /** 줄 지움 — 금액 줄은 그 줄이 가진 연결 기호까지 · 공식 묶음은 기호까지 */ 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 { // [+] 줄 더함 메뉴(요구 687-13 · 18) — 인력 · 재료 · 기계 · 단가산출 · 율 · 식 · 근거 글 const add = (at: number): HTMLElement => { const kinds: [string, LsKey][] = [ ["인력", "K_인력"], ["재료", "K_재료"], ["기계", "K_기계"], ["단가산출", h.take ? "K_복사" : "K_단가산출"], ["율", "Add_율"], ["공식", "Add_식"], ["글", "K_글"], ]; const menu = createRowMenu( kinds.map(([k, label]) => ({ label: lt(label), onClick: () => addLine(h, at, k) })), { icon: "plus", title: lt("Add_Line"), size: 24 }, ); menu.classList.add("lstk-add"); return menu; }; // X 지움 — 확인 없이 · 알림 누르면 되돌림(그 뒤 다른 고침이 없을 때만) const drop = (what: string, fn: (d: StackDoc, items: StackItem[]) => void): void => { const snap = JSON.stringify(h.doc); let after = ""; h.change((d, items) => { fn(d, items); after = JSON.stringify(docOf(d, items)); }); showToast(`${what || lt("K_줄")} ${lt("Dropped")}`, "info", 6000, () => h.change((d, items) => { if (JSON.stringify(docOf(d, items)) !== after) return void showToast(lt("Undo_Late"), "warning"); const back = JSON.parse(snap) as StackDoc; for (const k of Object.keys(d)) delete (d as Record)[k]; Object.assign(d, back); items.splice(0, items.length, ...stackOf(back)); }), ); }; const x = (title: string, onClick: () => void): HTMLElement => { const b = createIconButton({ icon: "x", title, size: 24, onClick }); b.classList.add("lstk-x"); return b; }; const grip = (): HTMLElement => el("span", { className: "ui-list-row__grip lstk-grip", attrs: { title: lt("Drag") } }); const root = el("div", { className: "lstk-edit" }); /** 항목 접힘 표시(화면 상태)를 항목 차례 바뀜에 맞춤 */ const refold = (fn: (flags: boolean[]) => void): void => { const flags = h.items.map((_, k) => h.open.has(`fold:${k}`)); fn(flags); h.items.forEach((_, k) => h.open.delete(`fold:${k}`)); flags.forEach((f, k) => f && h.open.add(`fold:${k}`)); }; // 항목 번호 — 산출근거와 같은 셈(제목 있는 항목 1. 2. · 옛 가나다 항목 = 가. 나. · 체크 걷음 요구 687-5) let n = 0; let sub = 0; const nos = h.items.map((it) => { if (!it.제목.trim()) return ""; const no = it.가나다 ? `${"가나다라마바사아자차카타파하"[sub++] ?? "?"}.` : `${++n}.`; if (!it.가나다) sub = 0; return no; }); // 만약(IF) 안 탄 갈래(요구 687-10) — 갈래를 다 열면 닿고 지금 값으로는 안 닿는 기호 · 꺼진 금액 줄 = 숨김(산출근거와 같음) const vals = (h.answer.값 ?? {}) as Values; const taken = new Set(reach(h.doc, vals)); const every = new Set(reach(h.doc, {}, true)); const steps = lineMap(h.doc); const off = (line: StackItem["줄"][number]): boolean => line.꼴 === "금액" ? offLine((h.doc.금액 ?? [])[line.i] ?? {}, vals, steps) : line.꼴 === "공식" && line.기호.length > 0 && line.기호.every((id) => every.has(id) && !taken.has(id)); const items = h.items.map((it, i) => { const folded = h.open.has(`fold:${i}`); const itemGrip = grip(); // 그룹 머리 한 줄(요구 687-6 · 688) — 손잡이 · 접기 · 번호 · 이름 · [+] · X · 접어도 자리 그대로 const head = el("div", { className: "lstk-ihead", children: [ itemGrip, createIconButton({ icon: folded ? "chevronRight" : "chevronDown", title: folded ? lt("Unfold_Item") : lt("Fold_Item"), size: 24, onClick: () => { if (folded) h.open.delete(`fold:${i}`); else h.open.add(`fold:${i}`); h.change(() => undefined); }, }), el("b", { className: "lstk-no", text: nos[i] }), field(lt("Title"), it.제목, (t) => h.change((_, items) => (items[i].제목 = t.trim())), "l"), el("span", { className: "lstk-hint lstk-count", text: folded ? `${lt("K_줄")} ${it.줄.length}` : "", }), add(i), x(lt("Del_Item"), () => drop(it.제목.trim() || lt("Item"), (d, items) => { refold((f) => f.splice(i, 1)); for (let j = items[i].줄.length - 1; j >= 0; j--) dropLine(d, items, i, j); items.splice(i, 1); }), ), ], }); const lines = it.줄.flatMap((line, j) => { if (off(line)) return []; const lineGrip = grip(); const del = x(lt("Del"), () => drop(line.꼴 === "금액" ? String(h.doc.금액?.[line.i]?.이름 ?? "") : "", (d, items) => dropLine(d, items, i, j), ), ); const body = line.꼴 === "금액" ? moneyBody(h, line.i) : line.꼴 === "글" ? [ el("span", { className: "lstk-kind", text: lt("K_글") }), field( lt("Text_Ph"), line.글, (t) => h.change((_, items) => ((items[i].줄[j] as { 글: string }).글 = t)), "l", ), ] : buildBlock(h, i, j, line, (id) => every.has(id) && !taken.has(id)); // 금액 · 글 = 한 줄(X 끝) · 공식 묶음 = 머리 줄 끝에 X const card = el("div", { className: `lstk-line k-${line.꼴}`, children: line.꼴 === "공식" ? [lineGrip, ...body.slice(0, 1).map((hd) => (hd.append(del), hd)), ...body.slice(1)] : [lineGrip, ...body, del], }); card.dataset.loc = `${i}.${j}`; card.addEventListener("pointerdown", () => h.pair(`${i}.${j}`)); attachDrag(lineGrip, { what: "line", root, self: card, drop: (to) => { const [k, p] = to.split(".").map(Number); h.change((_, items) => moveLine(items, i, j, k, p)); }, }); return [card]; }); const box = el("div", { className: `lstk-item${it.가나다 ? " is-sub" : ""}${folded ? " is-folded" : ""}`, attrs: { "data-loc": String(i) }, children: [ head, ...(folded ? [] : [ el("div", { className: "lstk-lines", children: lines.length ? lines : [el("div", { className: "lstk-empty", text: lt("Empty_Item") })], }), ]), ], }); head.addEventListener("pointerdown", () => h.pair(String(i))); attachDrag(itemGrip, { what: "item", root, self: box, drop: (to) => { const p = Number(to); refold((f) => moveItem(f, i, p)); h.change((_, items) => moveItem(items, i, p)); }, }); return box; }); root.append( ...items, btn(lt("Add_Item"), () => h.change((_, items) => items.push({ 제목: "", 줄: [] }))), ); return root; }