From 20403340edae9876a05e93922b979bcc39b4b1e4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 25 Sep 2026 09:41:29 +0900 Subject: [PATCH] =?UTF-8?q?feat(sheet):=20=EC=97=91=EC=85=80=EC=B2=98?= =?UTF-8?q?=EB=9F=BC=20=EB=8F=84=EB=8A=94=20=ED=91=9C=20=EB=B6=80=ED=92=88?= =?UTF-8?q?=20createSheet=20=E2=80=94=20master=20=C2=B7=20project=20?= =?UTF-8?q?=EB=91=90=20=EB=AA=A8=EB=93=9C=20(PLAN=2010-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 여러 층 머리(null = 세로 합침 · 같은 앞머리 = 가로 합침) · 단위 줄 · 합계 줄 · 틀고정 · 열 너비 끌기 - 셀 편집은 M01 startEdit 그대로 · 화살표 · Tab · Enter · F2 · 바로 치기 · Delete 비움 - 줄 더하기 · 지우기 · master 는 열 더하기 · 지우기 · 손 입력 열 · 식 · 들어갈 것 · 일위대가 줄 - project 는 바인딩 · 계산 열 잠금 · 손 열만 입력 · 전구간 줄 맨 위 · 쪽줄 50 으로 쪽(쪽마다 머리 · 합계 마지막 쪽) - 고칠 때마다 같은 TS 풀이로 즉시 다시 그림 · onChange 로 문서를 넘김(자동저장 없음) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Tjoit7rxvpLMM7cafeVTo1 --- ui_template/sheet/ui_template_sheet.css | 189 ++++++++++ ui_template/sheet/ui_template_sheet.ts | 323 ++++++++++++++++++ ui_template/sheet/ui_template_sheet_header.ts | 61 ++++ ui_template/sheet/ui_template_sheet_ops.ts | 107 ++++++ ui_template/sheet/ui_template_sheet_render.ts | 236 +++++++++++++ ui_template/sheet/ui_template_sheet_text.ts | 33 ++ 6 files changed, 949 insertions(+) create mode 100644 ui_template/sheet/ui_template_sheet.css create mode 100644 ui_template/sheet/ui_template_sheet.ts create mode 100644 ui_template/sheet/ui_template_sheet_header.ts create mode 100644 ui_template/sheet/ui_template_sheet_ops.ts create mode 100644 ui_template/sheet/ui_template_sheet_render.ts create mode 100644 ui_template/sheet/ui_template_sheet_text.ts diff --git a/ui_template/sheet/ui_template_sheet.css b/ui_template/sheet/ui_template_sheet.css new file mode 100644 index 00000000..2390fd79 --- /dev/null +++ b/ui_template/sheet/ui_template_sheet.css @@ -0,0 +1,189 @@ +/* ============================================================================= + * ui_template_sheet.css — 엑셀처럼 도는 표 부품(`ui_template_sheet.ts`) + * 색은 테마 토큰만 — 설계값 = 초록 · 계산 = 노랑 · 손 입력 = 보라 기운 + * ========================================================================== */ + +.ui-sheet { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + min-width: 0; + outline: none; +} + +.ui-sheet__toolbar { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-8); +} + +.ui-sheet__toolbar .ui-btn.is-on { + background: var(--color-mist-violet); +} + +.ui-sheet__hint { + margin: 0; + color: var(--color-text-muted); + font-size: 0.8rem; +} + +.ui-sheet__scroll { + overflow: auto; + max-height: calc(100vh - 260px); + border: 1px solid var(--color-border); + background: var(--color-canvas); +} + +.ui-sheet__pages { + display: flex; + flex-direction: column; + gap: var(--spacing-16); +} + +.ui-sheet__table { + table-layout: fixed; + border-collapse: separate; + border-spacing: 0; + font-size: 0.82rem; +} + +.ui-sheet__page { + padding: var(--spacing-4) var(--spacing-8); + color: var(--color-text-secondary); + text-align: left; +} + +.ui-sheet__table th, +.ui-sheet__table td { + position: relative; + height: 26px; + padding: 2px 6px; + overflow: hidden; + border-right: 1px solid var(--color-border); + border-bottom: 1px solid var(--color-border); + background: var(--color-canvas); + color: var(--color-text-body); + text-overflow: ellipsis; + white-space: nowrap; +} + +.ui-sheet__table thead th, +.ui-sheet__table thead td { + background: var(--color-surface); + font-weight: 600; + text-align: center; +} + +.ui-sheet__gutter { + position: sticky; + left: 0; + z-index: 2; + background: var(--color-surface) !important; + color: var(--color-text-secondary); + font-weight: 500; + text-align: center; +} + +.ui-sheet__table .is-frozen { + position: sticky; + z-index: 1; + box-shadow: 1px 0 0 var(--color-border); +} + +.ui-sheet__table td.is-num { + text-align: right; + font-variant-numeric: tabular-nums; +} + +.ui-sheet__table td.is-bound { + background: color-mix(in srgb, var(--color-success) 7%, var(--color-canvas)); +} + +.ui-sheet__table td.is-calc { + background: color-mix(in srgb, var(--color-warning) 10%, var(--color-canvas)); +} + +.ui-sheet__table td.is-hand { + background: color-mix(in srgb, var(--color-lavender-wash) 14%, var(--color-canvas)); +} + +.ui-sheet--project .ui-sheet__table tbody td.is-locked { + color: var(--color-text-secondary); +} + +.ui-sheet__table td.is-override { + font-style: italic; +} + +.ui-sheet__table td.is-error { + color: var(--color-danger); +} + +.ui-sheet__table .is-editable { + cursor: cell; +} + +.ui-sheet__table td.is-selected { + outline: 2px solid var(--color-focus-ring); + outline-offset: -2px; +} + +.ui-sheet__table tr.is-fixed td { + border-bottom: 2px solid var(--color-border); +} + +.ui-sheet__meta td { + color: var(--color-text-secondary); + font-size: 0.76rem; + white-space: normal; +} + +.ui-sheet__total td, +.ui-sheet__total th { + border-top: 2px solid var(--color-border); + font-weight: 600; +} + +.ui-sheet__chip { + display: inline-block; + margin-right: 4px; + padding: 0 4px; + border-radius: 3px; + font-size: 0.7rem; + font-weight: 600; +} + +.ui-sheet__chip.is-bound { + background: color-mix(in srgb, var(--color-success) 18%, var(--color-canvas)); + color: var(--color-success); +} + +.ui-sheet__chip.is-calc { + background: color-mix(in srgb, var(--color-warning) 22%, var(--color-canvas)); + color: var(--color-warning); +} + +.ui-sheet__chip.is-hand { + background: var(--color-mist-violet); + color: var(--color-accent); +} + +.ui-sheet__resize { + position: absolute; + top: 0; + right: -3px; + z-index: 3; + width: 6px; + height: 100%; + cursor: col-resize; +} + +.ui-sheet__table td input, +.ui-sheet__table th input { + width: 100%; + min-width: 0; + padding: 0 2px; + border: 0; + font: inherit; + text-align: inherit; +} diff --git a/ui_template/sheet/ui_template_sheet.ts b/ui_template/sheet/ui_template_sheet.ts new file mode 100644 index 00000000..33c55e15 --- /dev/null +++ b/ui_template/sheet/ui_template_sheet.ts @@ -0,0 +1,323 @@ +/* ============================================================================= + * 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, +} from "./ui_template_sheet_ops"; +import { recalcSheet } from "./ui_template_sheet_recalc"; +import { + columnWidth, + 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 => { + 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 raw = state.doc.줄.find((r) => `d:${r.id}` === key)?.값[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); + startEdit(th, th.textContent ?? "", (value) => { + for (const col of span) { + while (col.머리.length <= level) col.머리.push(null); + col.머리[level] = value.trim(); + } + changed(); + }); + }; + + // ── 사건 ────────────────────────────────────────────────────────── + 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"); + drag = { id: col.id, x: event.clientX, width: columnWidth(state.doc, col), 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; + }, + }; +} diff --git a/ui_template/sheet/ui_template_sheet_header.ts b/ui_template/sheet/ui_template_sheet_header.ts new file mode 100644 index 00000000..2f5ec996 --- /dev/null +++ b/ui_template/sheet/ui_template_sheet_header.ts @@ -0,0 +1,61 @@ +/* ============================================================================= + * ui_template_sheet_header.ts + * 여러 층 머리 짜기 — 열마다 `머리`(층 수만큼 글) → 층마다 칸(colspan · rowspan). + * + * `null` = 위 칸과 합침(세로) · 모자란 층도 `null` 로 채움(위 칸이 늘어남). + * 옆 열과 그 층까지 앞머리가 모두 같고 아래 합침 모양도 같으면 가로로 합침. + * ========================================================================== */ + +import type { SheetColumn } from "./ui_template_sheet_types"; + +export interface HeadCell { + level: number; + col: number; + colspan: number; + rowspan: number; + label: string; +} + +export function headDepth(cols: SheetColumn[], layers?: string[]): number { + return Math.max(1, layers?.length ?? 0, ...cols.map((c) => c.머리.length)); +} + +/** 층마다 칸 목록 — 앞 층부터. */ +export function layoutHead(cols: SheetColumn[], depth: number): HeadCell[][] { + const paths = cols.map((c) => + Array.from({ length: depth }, (_, i) => (i === 0 ? (c.머리[0] ?? "") : (c.머리[i] ?? null))), + ); + const span = (j: number, i: number): number => { + let k = i + 1; + while (k < depth && paths[j][k] === null) k += 1; + return k - i; + }; + const samePrefix = (a: number, b: number, i: number): boolean => + paths[a].slice(0, i + 1).every((label, lv) => label === paths[b][lv]); + + const levels: HeadCell[][] = []; + for (let i = 0; i < depth; i += 1) { + const cells: HeadCell[] = []; + let j = 0; + while (j < cols.length) { + if (paths[j][i] === null) { + j += 1; + continue; + } + const rowspan = span(j, i); + let colspan = 1; + while ( + j + colspan < cols.length && + paths[j + colspan][i] !== null && + samePrefix(j, j + colspan, i) && + span(j + colspan, i) === rowspan + ) { + colspan += 1; + } + cells.push({ level: i, col: j, colspan, rowspan, label: paths[j][i] as string }); + j += colspan; + } + levels.push(cells); + } + return levels; +} diff --git a/ui_template/sheet/ui_template_sheet_ops.ts b/ui_template/sheet/ui_template_sheet_ops.ts new file mode 100644 index 00000000..ebf1e309 --- /dev/null +++ b/ui_template/sheet/ui_template_sheet_ops.ts @@ -0,0 +1,107 @@ +/* ============================================================================= + * ui_template_sheet_ops.ts + * 표 문서 고치기 — 줄 · 열 더하기 · 지우기 · 칸 값. 문서를 그 자리에서 바꿈(부른 쪽이 다시 그림). + * 화면(DOM)을 모름 — 격자(`ui_template_sheet.ts`)가 부름. + * ========================================================================== */ + +import type { SheetCell, SheetColumn, SheetDoc, SheetRow } from "./ui_template_sheet_types"; + +export type SheetMode = "master" | "project"; + +export const ROW_FIXED_ALL = "전구간"; + +/** 겹치지 않는 새 id — `c1` · `c2` … */ +function freshId(prefix: string, used: Iterable): string { + const taken = new Set(used); + let n = 1; + while (taken.has(`${prefix}${n}`)) n += 1; + return `${prefix}${n}`; +} + +/** 보이는 줄 차례 — 「전구간」 줄이 맨 위 · 나머지는 문서 차례. */ +export function orderedRows(doc: SheetDoc): SheetRow[] { + const fixed = doc.줄.filter((r) => r.고정 === ROW_FIXED_ALL); + return [...fixed, ...doc.줄.filter((r) => r.고정 !== ROW_FIXED_ALL)]; +} + +export const isCalcColumn = (col: SheetColumn): boolean => !!col.식; +export const isBoundColumn = (col: SheetColumn): boolean => !!col.바인딩; + +/** 본문 칸을 고칠 수 있나 — master 는 계산 열 밖 전부 · project 는 손 열만(+ 사용자가 더한 줄의 글 열). */ +export function cellEditable(mode: SheetMode, col: SheetColumn, row: SheetRow): boolean { + if (row.식?.[col.id] || isCalcColumn(col)) return false; + if (mode === "master") return true; + if (isBoundColumn(col)) return false; + return !!col.손 || (!!row.손 && col.꼴 === "글"); +} + +export function canDeleteRow(mode: SheetMode, row: SheetRow): boolean { + if (mode === "master") return true; + return !!row.손 && row.고정 !== ROW_FIXED_ALL; +} + +/** `after` 줄 뒤에 빈 줄(없으면 맨 끝) — 새 줄 id. project 에서 더한 줄은 `손`. */ +export function addRow(doc: SheetDoc, mode: SheetMode, after: string | null): string { + const id = freshId( + "r", + doc.줄.map((r) => r.id), + ); + const row: SheetRow = { id, 값: {}, ...(mode === "project" ? { 손: true } : {}) }; + const at = doc.줄.findIndex((r) => r.id === after); + doc.줄.splice(at < 0 ? doc.줄.length : at + 1, 0, row); + return id; +} + +export function deleteRow(doc: SheetDoc, id: string): void { + doc.줄 = doc.줄.filter((r) => r.id !== id); +} + +/** `after` 열 오른쪽에 같은 묶음의 새 열 — 윗머리는 그대로 · 맨 아래 글만 「새 열」. */ +export function addColumn(doc: SheetDoc, after: string | null, label: string): string { + const id = freshId( + "c", + doc.열.map((c) => c.id), + ); + const at = doc.열.findIndex((c) => c.id === after); + const base = at < 0 ? null : doc.열[at]; + const head = base ? [...base.머리] : [label]; + if (base) { + let last = head.length - 1; + while (last > 0 && head[last] === null) last -= 1; + head[last] = label; + } + const col: SheetColumn = { id, 머리: head, 단위: base?.단위 ?? null, 꼴: "수" }; + doc.열.splice(at < 0 ? doc.열.length : at + 1, 0, col); + return id; +} + +/** 열을 지움 — 그 열을 쓰던 식은 남아 오류 칸으로 보임(조용히 고치지 않음). */ +export function deleteColumn(doc: SheetDoc, id: string): void { + doc.열 = doc.열.filter((c) => c.id !== id); + for (const row of doc.줄) { + delete row.값[id]; + if (row.식) delete row.식[id]; + } + if (doc.보기?.열너비) delete doc.보기.열너비[id]; +} + +/** 칸에 친 글 → 저장 값. 수 열은 수 꼴이면 수 · 빈 글은 칸을 비움. */ +export function setCell(doc: SheetDoc, rowId: string, col: SheetColumn, text: string): void { + const row = doc.줄.find((r) => r.id === rowId); + if (!row) return; + const trimmed = text.trim(); + if (trimmed === "") { + delete row.값[col.id]; + return; + } + const plain = trimmed.replace(/,/g, ""); + const value: SheetCell = + col.꼴 !== "글" && /^-?(\d+(\.\d*)?|\.\d+)$/.test(plain) ? Number(plain) : trimmed; + // ⚠ 긴 소수는 Number 로 바꾸면 끝자리가 틀어짐 — 보이는 글 그대로 둠(풀이가 십진으로 읽음) + row.값[col.id] = typeof value === "number" && String(value) !== plain ? plain : value; +} + +export function setColumnWidth(doc: SheetDoc, id: string, width: number): void { + doc.보기 ??= {}; + (doc.보기.열너비 ??= {})[id] = Math.round(width); +} diff --git a/ui_template/sheet/ui_template_sheet_render.ts b/ui_template/sheet/ui_template_sheet_render.ts new file mode 100644 index 00000000..816130f8 --- /dev/null +++ b/ui_template/sheet/ui_template_sheet_render.ts @@ -0,0 +1,236 @@ +/* ============================================================================= + * ui_template_sheet_render.ts + * 표 그리기 — 문서 + 풀이 결과 → 쪽마다 ``(머리 층 · 단위 줄 · 본문 · 합계 줄). + * + * 칸마다 `data-r`(줄 열쇠) · `data-c`(열 id) — 고치기 · 키보드는 격자(`ui_template_sheet.ts`)가 위임으로 받음. + * 줄 열쇠 — 본문 `d:<줄id>` · 합계 `t:<합계id>` · 마스터 줄 `s:unit|formula|desc|price`. + * master = 값 대신 열마다 식 · 들어갈 것 · 일위대가 줄 · project = 쪽줄마다 쪽을 나눔(쪽마다 머리 · 합계는 마지막 쪽). + * ========================================================================== */ + +import { el } from "@ui/ui_template_elements"; +import { headDepth, layoutHead } from "./ui_template_sheet_header"; +import { + cellEditable, + isBoundColumn, + isCalcColumn, + orderedRows, + type SheetMode, +} from "./ui_template_sheet_ops"; +import { groupDigits, totalFormula } from "./ui_template_sheet_recalc"; +import { st } from "./ui_template_sheet_text"; +import type { SheetColumn, SheetDoc, SheetResult, SheetRow } from "./ui_template_sheet_types"; + +export const KEY_UNIT = "s:unit"; +const MASTER_ROWS = ["s:formula", "s:desc", "s:price"] as const; +const GUTTER = 48; +const DEFAULT_WIDTH = { 수: 72, 글: 96 }; +const PAGE_ROWS = 50; + +export interface RenderState { + doc: SheetDoc; + mode: SheetMode; + result: SheetResult; + sel: { r: string; c: string } | null; +} + +export const columnWidth = (doc: SheetDoc, col: SheetColumn): number => + doc.보기?.열너비?.[col.id] ?? DEFAULT_WIDTH[col.꼴 === "글" ? "글" : "수"]; + +/** 키보드로 옮겨 다니는 줄 차례(쪽을 가로지름). */ +export function navRows(doc: SheetDoc, mode: SheetMode): string[] { + const special = mode === "master" ? [KEY_UNIT, ...MASTER_ROWS] : [KEY_UNIT]; + return [ + ...special, + ...orderedRows(doc).map((r) => `d:${r.id}`), + ...(doc.합계줄 ?? []).map((t) => `t:${t.id}`), + ]; +} + +/** 칸을 고칠 수 있나 — 줄 열쇠 기준(머리 칸은 따로). */ +export function keyEditable(state: RenderState, key: string, col: SheetColumn): boolean { + if (key.startsWith("s:")) return state.mode === "master"; + if (key.startsWith("t:")) return false; + const row = state.doc.줄.find((r) => `d:${r.id}` === key); + return !!row && cellEditable(state.mode, col, row); +} + +const shown = (text: string | undefined): string => + !text || text === "0" ? "" : groupDigits(text); + +function kindOf(col: SheetColumn): { cls: string; label: string } | null { + if (isBoundColumn(col)) return { cls: "is-bound", label: st("Kind_Bound") }; + if (isCalcColumn(col)) return { cls: "is-calc", label: st("Kind_Calc") }; + if (col.손) return { cls: "is-hand", label: st("Kind_Hand") }; + return null; +} + +export function renderSheet(state: RenderState): HTMLElement { + const { doc, mode } = state; + const cols = doc.열; + const depth = headDepth(cols, doc.층); + const head = layoutHead(cols, depth); + const errors = new Map(state.result.오류.map((e) => [`d:${e.줄}|${e.열}`, e.까닭])); + for (const e of state.result.오류) errors.set(`t:${e.줄}|${e.열}`, e.까닭); + const frozen = Math.min(doc.보기?.틀고정?.열 ?? 0, cols.length); + const lefts: number[] = []; + let left = GUTTER; + for (const col of cols) { + lefts.push(left); + left += columnWidth(doc, col); + } + + const freeze = (cell: HTMLElement, from: number, span = 1): void => { + if (from + span > frozen) return; + cell.classList.add("is-frozen"); + cell.style.left = `${lefts[from]}px`; + }; + const gutter = (tag: "th" | "td", text: string, cls = ""): HTMLElement => + el(tag, { className: `ui-sheet__gutter ${cls}`.trim(), text }); + + const bodyCell = (key: string, col: SheetColumn, j: number, text: string): HTMLElement => { + const td = el("td", { text, attrs: { "data-r": key, "data-c": col.id } }); + const kind = kindOf(col); + if (kind) td.classList.add(kind.cls); + if (col.꼴 !== "글") td.classList.add("is-num"); + td.classList.add(keyEditable(state, key, col) ? "is-editable" : "is-locked"); + const why = errors.get(`${key}|${col.id}`); + if (why) { + td.classList.add("is-error"); + td.textContent = st("Error"); + td.title = why; + } else if (text) td.title = text; + if (state.sel?.r === key && state.sel.c === col.id) td.classList.add("is-selected"); + freeze(td, j); + return td; + }; + + const dataRow = (row: SheetRow, n: number): HTMLElement => { + const key = `d:${row.id}`; + const tr = el("tr", { children: [gutter("th", String(n))] }); + if (row.고정) tr.classList.add("is-fixed"); + cols.forEach((col, j) => { + const formula = row.식?.[col.id] ?? col.식; + const raw = row.값[col.id]; + const text = formula + ? shown(state.result.계산[row.id]?.[col.id]) + : raw === null || raw === undefined + ? "" + : col.꼴 !== "글" && typeof raw === "number" + ? groupDigits(String(raw)) + : String(raw); + const td = bodyCell(key, col, j, text); + if (row.식?.[col.id]) td.classList.add("is-override"); + tr.append(td); + }); + return tr; + }; + + const masterRow = (key: (typeof MASTER_ROWS)[number]): HTMLElement => { + const label = { "s:formula": "Row_Formula", "s:desc": "Row_Desc", "s:price": "Row_Unit_Price" }[ + key + ] as "Row_Formula" | "Row_Desc" | "Row_Unit_Price"; + const tr = el("tr", { className: "ui-sheet__meta", children: [gutter("th", st(label))] }); + cols.forEach((col, j) => { + const text = + key === "s:formula" + ? (col.식 ?? "") + : key === "s:desc" + ? (col.설명 ?? "") + : (col.일위대가 ?? ""); + const td = bodyCell(key, col, j, text); + td.classList.remove("is-num"); + const kind = kindOf(col); + if (key === "s:desc" && kind) { + const chip = el("span", { + className: `ui-sheet__chip ${kind.cls}`, + text: col.펼침 ? `${kind.label} · ${st("Kind_Spread")}` : kind.label, + }); + td.prepend(chip); + } + tr.append(td); + }); + return tr; + }; + + const thead = (): HTMLElement => { + const rows = head.map((cells, level) => { + const tr = el("tr", { children: [gutter("th", doc.층?.[level] ?? "")] }); + for (const cell of cells) { + const th = el("th", { + text: cell.label, + attrs: { + colspan: String(cell.colspan), + rowspan: String(cell.rowspan), + "data-level": String(cell.level), + "data-col": cols[cell.col].id, + }, + }); + if (mode === "master") th.classList.add("is-editable"); + freeze(th, cell.col, cell.colspan); + tr.append(th); + } + return tr; + }); + const unit = el("tr", { + className: "ui-sheet__unit", + children: [gutter("th", st("Row_Unit"))], + }); + cols.forEach((col, j) => { + const th = bodyCell(KEY_UNIT, col, j, col.단위 ?? ""); + th.classList.remove("is-num"); + th.append(el("span", { className: "ui-sheet__resize", attrs: { "data-resize": col.id } })); + unit.append(th); + }); + return el("thead", { children: [...rows, unit] }); + }; + + const totalRows = (): HTMLElement[] => + (doc.합계줄 ?? []).map((total) => { + const tr = el("tr", { className: "ui-sheet__total", children: [gutter("th", total.이름)] }); + cols.forEach((col, j) => { + const td = bodyCell(`t:${total.id}`, col, j, shown(state.result.합계[total.id]?.[col.id])); + if (totalFormula(total, col)) td.title = totalFormula(total, col)!; + tr.append(td); + }); + return tr; + }); + + const colgroup = el("colgroup", { + children: [ + el("col", { attrs: { style: `width:${GUTTER}px` } }), + ...cols.map((col) => + el("col", { attrs: { style: `width:${columnWidth(doc, col)}px`, "data-col": col.id } }), + ), + ], + }); + + const data = orderedRows(doc); + const size = mode === "project" ? Math.max(1, doc.쪽줄 ?? PAGE_ROWS) : Math.max(1, data.length); + const pageCount = Math.max(1, Math.ceil(data.length / size)); + const pages: HTMLElement[] = []; + for (let p = 0; p < pageCount; p += 1) { + const slice = data.slice(p * size, (p + 1) * size); + const body = el("tbody", { + children: [ + ...(mode === "master" && p === 0 ? MASTER_ROWS.map(masterRow) : []), + ...slice.map((row, i) => dataRow(row, p * size + i + 1)), + ...(p === pageCount - 1 ? totalRows() : []), + ], + }); + const table = el("table", { + className: "ui-sheet__table", + attrs: { style: `width:${left}px` }, + children: [colgroup.cloneNode(true) as HTMLElement, thead(), body], + }); + if (pageCount > 1) { + table.prepend( + el("caption", { + text: `${p + 1} / ${pageCount} ${st("Page")}`, + className: "ui-sheet__page", + }), + ); + } + pages.push(table); + } + return el("div", { className: "ui-sheet__pages", children: pages }); +} diff --git a/ui_template/sheet/ui_template_sheet_text.ts b/ui_template/sheet/ui_template_sheet_text.ts new file mode 100644 index 00000000..d830c843 --- /dev/null +++ b/ui_template/sheet/ui_template_sheet_text.ts @@ -0,0 +1,33 @@ +/* ============================================================================= + * ui_template_sheet_text.ts + * 표 부품 글자 — [한국어, 영어] + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; + +const TEXT = { + Row_Add: ["줄 더하기", "Add row"], + Row_Delete: ["줄 지우기", "Delete row"], + Col_Add: ["열 더하기", "Add column"], + Col_Delete: ["열 지우기", "Delete column"], + Col_Hand: ["손 입력 열", "Manual column"], + Col_New: ["새 열", "New column"], + Row_Unit: ["단위", "Unit"], + Row_Formula: ["식", "Formula"], + Row_Desc: ["들어갈 것", "Source"], + Row_Unit_Price: ["일위대가", "Unit price"], + Kind_Bound: ["설계값", "Design"], + Kind_Calc: ["계산", "Calc"], + Kind_Hand: ["손 입력", "Manual"], + Kind_Spread: ["값마다 열", "Per value"], + Page: ["쪽", "page"], + Error: ["#오류", "#ERR"], + Hint: [ + "칸을 누르고 바로 치거나 Enter · F2 로 고침 · 화살표 · Tab 으로 옮김 · Delete 로 비움", + "Type or press Enter / F2 to edit · arrows / Tab to move · Delete to clear", + ], +} as const; + +export function st(key: keyof typeof TEXT): string { + return TEXT[key][currentLanguageIndex] ?? TEXT[key][0]; +}