/* ============================================================================= * ui_template_sheet.ts * 엑셀처럼 도는 표 부품 — `createSheet(칸, 문서, {mode, onChange})` → `{getDoc, setDoc, recalc, destroy}`. * * master = 시스템 관리자 양식 고치기 — 머리 · 단위 · 식 · 들어갈 것 · 일위대가 · 줄 · 열 더하기·지우기 · 손 열. * project = 프로젝트 표 — 바인딩 · 계산 열 잠금 · 손 열만 입력 · 「전구간」 줄 맨 위 · 쪽줄마다 쪽. * 칸을 고치면 문서를 그 자리에서 바꾸고 같은 풀이(`_recalc`)로 즉시 다시 그림 — 서버 왕복 없음. * 저장은 부른 쪽 몫(`onChange` 로 문서를 받음 · 자동저장 없음 · [저장] 때 서버가 Node 로 다시 풂). * 칸 고치기는 M01 `startEdit`(Enter · 칸 밖 = 확정 · Esc = 취소) 를 그대로 씀. * ========================================================================== */ import { createButton, el } from "@ui/ui_template_elements"; import { startEdit } from "../../M01_MasterData/M01_MasterData_UI_Cells"; import { addColumn, addRow, canDeleteRow, deleteColumn, deleteRow, type SheetMode, setCell, setColumnWidth, setHeadLabel, } from "./ui_template_sheet_ops"; import { headDepth } from "./ui_template_sheet_header"; import { recalcSheet } from "./ui_template_sheet_recalc"; import { KEY_UNIT, keyEditable, navRows, type RenderState, renderSheet, } from "./ui_template_sheet_render"; import { st } from "./ui_template_sheet_text"; import type { SheetColumn, SheetDoc, SheetResult } from "./ui_template_sheet_types"; import "./ui_template_sheet.css"; export type { SheetMode } from "./ui_template_sheet_ops"; export type { SheetDoc, SheetResult } from "./ui_template_sheet_types"; export interface SheetOptions { mode: SheetMode; onChange?: (doc: SheetDoc) => void; } export interface SheetHandle { getDoc(): SheetDoc; setDoc(doc: SheetDoc): void; recalc(): SheetResult; destroy(): void; } const MIN_WIDTH = 32; export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptions): SheetHandle { const state: RenderState = { doc: structuredClone(input), mode: opts.mode, result: { 계산: {}, 합계: {}, 오류: [] }, sel: null, }; const scroll = el("div", { className: "ui-sheet__scroll" }); const toolbar = el("div", { className: "ui-sheet__toolbar" }); const root = el("div", { className: `ui-sheet ui-sheet--${opts.mode}`, attrs: { tabindex: "0" }, children: [toolbar, el("p", { className: "ui-sheet__hint", text: st("Hint") }), scroll], }); host.replaceChildren(root); const colOf = (id: string): SheetColumn | undefined => state.doc.열.find((c) => c.id === id); const rowIdOf = (key: string | undefined): string | null => key?.startsWith("d:") ? key.slice(2) : null; // ── 단추 ────────────────────────────────────────────────────────── const button = (label: string, run: () => void): HTMLButtonElement => createButton({ label, variant: "ghost", onClick: run }); const rowAdd = button(st("Row_Add"), () => { const id = addRow(state.doc, state.mode, rowIdOf(state.sel?.r)); state.sel = { r: `d:${id}`, c: state.sel?.c ?? state.doc.열[0]?.id ?? "" }; changed(); }); const rowDelete = button(st("Row_Delete"), () => { const id = rowIdOf(state.sel?.r); if (!id) return; const keys = navRows(state.doc, state.mode); deleteRow(state.doc, id); const next = keys[keys.indexOf(`d:${id}`) + 1] ?? keys[keys.indexOf(`d:${id}`) - 1]; state.sel = next && state.sel ? { r: next, c: state.sel.c } : null; changed(); }); const colAdd = button(st("Col_Add"), () => { const id = addColumn(state.doc, state.sel?.c ?? null, st("Col_New")); state.sel = { r: state.sel?.r ?? KEY_UNIT, c: id }; changed(); }); const colDelete = button(st("Col_Delete"), () => { const id = state.sel?.c; if (!id || state.doc.열.length <= 1) return; const at = state.doc.열.findIndex((c) => c.id === id); deleteColumn(state.doc, id); const next = state.doc.열[Math.min(at, state.doc.열.length - 1)]; state.sel = state.sel && next ? { r: state.sel.r, c: next.id } : null; changed(); }); const colHand = button(st("Col_Hand"), () => { const col = state.sel && colOf(state.sel.c); if (!col) return; if (col.손) delete col.손; else col.손 = true; changed(); }); toolbar.append(rowAdd, rowDelete); if (opts.mode === "master") toolbar.append(colAdd, colDelete, colHand); const syncToolbar = (): void => { const rowId = rowIdOf(state.sel?.r); const row = state.doc.줄.find((r) => r.id === rowId); rowDelete.disabled = !row || !canDeleteRow(state.mode, row); const col = state.sel ? colOf(state.sel.c) : undefined; colDelete.disabled = !col || state.doc.열.length <= 1; colHand.disabled = !col; colHand.classList.toggle("is-on", !!col?.손); }; // ── 그리기 · 고름 ───────────────────────────────────────────────── const render = (): void => { root.querySelector(".ui-sheet__headhint")?.remove(); state.result = recalcSheet(state.doc); const { scrollLeft, scrollTop } = scroll; scroll.replaceChildren(renderSheet(state)); scroll.scrollLeft = scrollLeft; scroll.scrollTop = scrollTop; syncToolbar(); }; const changed = (): void => { render(); opts.onChange?.(structuredClone(state.doc)); }; const cellsAt = (r: string, c: string): HTMLElement[] => [ ...scroll.querySelectorAll( `td[data-r="${CSS.escape(r)}"][data-c="${CSS.escape(c)}"]`, ), ]; const select = (r: string, c: string, reveal = false): void => { for (const cell of scroll.querySelectorAll(".is-selected")) cell.classList.remove("is-selected"); state.sel = { r, c }; const cells = cellsAt(r, c); for (const cell of cells) cell.classList.add("is-selected"); if (reveal) cells[0]?.scrollIntoView({ block: "nearest", inline: "nearest" }); syncToolbar(); }; const move = (dr: number, dc: number): void => { const keys = navRows(state.doc, state.mode); const cols = state.doc.열; if (!state.sel) { if (keys.length && cols.length) select(keys[0], cols[0].id, true); return; } const r = Math.max(0, Math.min(keys.length - 1, keys.indexOf(state.sel.r) + dr)); const at = cols.findIndex((c) => c.id === state.sel!.c); const c = Math.max(0, Math.min(cols.length - 1, at + dc)); select(keys[r], cols[c].id, true); }; // ── 칸 고치기 ───────────────────────────────────────────────────── const rawText = (key: string, col: SheetColumn): string => { if (key === KEY_UNIT) return col.단위 ?? ""; if (key === "s:formula") return col.식 ?? ""; if (key === "s:desc") return col.설명 ?? ""; if (key === "s:price") return col.일위대가 ?? ""; const row = state.doc.줄.find((r) => `d:${r.id}` === key); const formula = row?.식?.[col.id] ?? col.식; if (formula) return `=${formula}`; const raw = row?.값[col.id]; return raw === null || raw === undefined ? "" : String(raw); }; const write = (key: string, col: SheetColumn, text: string): void => { const value = text.trim(); if (key === KEY_UNIT) col.단위 = value || null; else if (key === "s:formula") { if (value) col.식 = value; else delete col.식; } else if (key === "s:desc") col.설명 = value; else if (key === "s:price") col.일위대가 = value || null; else setCell(state.doc, key.slice(2), col, text); changed(); }; const edit = (initial?: string): void => { const sel = state.sel; const col = sel && colOf(sel.c); if (!sel || !col || !keyEditable(state, sel.r, col)) return; const td = cellsAt(sel.r, sel.c)[0]; if (!td) return; startEdit(td, rawText(sel.r, col), (value) => write(sel.r, col, value)); const box = td.querySelector("input"); if (box && initial !== undefined) box.value = initial; }; const editHead = (th: HTMLElement): void => { const start = state.doc.열.findIndex((c) => c.id === th.dataset.col); const level = Number(th.dataset.level); if (start < 0 || Number.isNaN(level)) return; const span = state.doc.열.slice(start, start + (th as HTMLTableCellElement).colSpan); const depth = headDepth(state.doc.열, state.doc.층); const tip = el("div", { className: "ui-sheet__headhint", text: st("Head_Hint") }); const at = th.getBoundingClientRect(); tip.style.left = `${at.left}px`; tip.style.top = `${at.bottom + 2}px`; root.append(tip); startEdit(th, th.textContent ?? "", (value) => { for (const col of span) setHeadLabel(col.머리, level, value, depth); changed(); }); th.querySelector("input")?.addEventListener("blur", () => tip.remove()); }; // ── 사건 ────────────────────────────────────────────────────────── const onClick = (event: MouseEvent): void => { const target = event.target as HTMLElement; if (target.closest("input")) return; const td = target.closest("td[data-r]"); if (td) select(td.dataset.r!, td.dataset.c!); const th = target.closest("th[data-col]"); if (th) select(state.sel?.r ?? KEY_UNIT, th.dataset.col!); root.focus({ preventScroll: true }); }; const onDblClick = (event: MouseEvent): void => { const target = event.target as HTMLElement; if (target.closest("input")) return; const th = target.closest("th[data-col]"); if (th && state.mode === "master") editHead(th); else if (target.closest("td[data-r]")) edit(); }; const onKey = (event: KeyboardEvent): void => { const typing = (event.target as HTMLElement).tagName === "INPUT"; if (typing) { if (event.key === "Enter") { move(1, 0); root.focus({ preventScroll: true }); } else if (event.key === "Tab") { event.preventDefault(); (event.target as HTMLInputElement).blur(); move(0, event.shiftKey ? -1 : 1); root.focus({ preventScroll: true }); } else if (event.key === "Escape") { render(); root.focus({ preventScroll: true }); } return; } const arrows: Record = { ArrowUp: [-1, 0], ArrowDown: [1, 0], ArrowLeft: [0, -1], ArrowRight: [0, 1], }; if (event.key in arrows) { event.preventDefault(); move(...arrows[event.key]); } else if (event.key === "Tab") { event.preventDefault(); move(0, event.shiftKey ? -1 : 1); } else if (event.key === "Enter" || event.key === "F2") { event.preventDefault(); edit(); } else if (event.key === "Delete" || event.key === "Backspace") { const col = state.sel && colOf(state.sel.c); if (state.sel && col && keyEditable(state, state.sel.r, col)) { event.preventDefault(); write(state.sel.r, col, ""); } } else if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) { event.preventDefault(); edit(event.key); } }; // 열 너비 끌기 — 단위 줄 칸 오른쪽 손잡이 let drag: { id: string; x: number; width: number; total: number } | null = null; const onPointerDown = (event: PointerEvent): void => { const handle = (event.target as HTMLElement).closest("[data-resize]"); const col = handle && colOf(handle.dataset.resize!); if (!handle || !col) return; event.preventDefault(); handle.setPointerCapture(event.pointerId); const total = parseFloat(scroll.querySelector("table")?.style.width ?? "0"); const now = scroll.querySelector(`col[data-col="${CSS.escape(col.id)}"]`); drag = { id: col.id, x: event.clientX, width: parseFloat(now?.style.width ?? "72"), total }; }; const onPointerMove = (event: PointerEvent): void => { if (!drag) return; const width = Math.max(MIN_WIDTH, drag.width + event.clientX - drag.x); for (const c of scroll.querySelectorAll(`col[data-col="${CSS.escape(drag.id)}"]`)) c.style.width = `${width}px`; for (const table of scroll.querySelectorAll("table")) table.style.width = `${drag.total - drag.width + width}px`; }; const onPointerUp = (event: PointerEvent): void => { if (!drag) return; const width = Math.max(MIN_WIDTH, drag.width + event.clientX - drag.x); const id = drag.id; drag = null; setColumnWidth(state.doc, id, width); changed(); }; root.addEventListener("click", onClick); root.addEventListener("dblclick", onDblClick); root.addEventListener("keydown", onKey); root.addEventListener("pointerdown", onPointerDown); root.addEventListener("pointermove", onPointerMove); root.addEventListener("pointerup", onPointerUp); render(); return { getDoc: () => structuredClone(state.doc), setDoc: (doc) => { state.doc = structuredClone(doc); state.sel = null; render(); }, recalc: () => { render(); return structuredClone(state.result); }, destroy: () => { root.remove(); drag = null; }, }; }