import * as z from "zod"; import { applyRichTextStylePatch, normalizeExtraRichText, type RichTextStylePatch, } from "@/lib/rich-text"; const extraSheetId = z.string().min(1).max(128); export const extraSheetSchema = z.strictObject({ columns: z.array(z.strictObject({ id: extraSheetId, label: z.string().trim().min(1).max(120), })).min(1), rows: z.array(z.strictObject({ id: extraSheetId, cells: z.record(z.string(), z.string().max(10_000)), backgrounds: z.record(z.string(), z.string().regex(/^#[0-9A-Fa-f]{6}$/)).default({}), })).min(1), }).superRefine((sheet, context) => { const columnIds = new Set(sheet.columns.map((column) => column.id)); if (columnIds.size !== sheet.columns.length) { context.addIssue({ code: "custom", path: ["columns"], message: "รหัสคอลัมน์ต้องไม่ซ้ำกัน" }); } const columnLabels = new Set(sheet.columns.map((column) => column.label.toLocaleLowerCase())); if (columnLabels.size !== sheet.columns.length) { context.addIssue({ code: "custom", path: ["columns"], message: "ชื่อคอลัมน์ต้องไม่ซ้ำกัน" }); } const rowIds = new Set(sheet.rows.map((row) => row.id)); if (rowIds.size !== sheet.rows.length) { context.addIssue({ code: "custom", path: ["rows"], message: "รหัสแถวต้องไม่ซ้ำกัน" }); } sheet.rows.forEach((row, rowIndex) => { [...Object.keys(row.cells), ...Object.keys(row.backgrounds ?? {})].forEach((columnId) => { if (!columnIds.has(columnId)) { context.addIssue({ code: "custom", path: ["rows", rowIndex, "cells", columnId], message: "ไม่พบคอลัมน์ของเซลล์นี้", }); } }); }); }); export type ExtraSheet = z.output; export type ExtraSheetCell = { value: string; backgroundColor?: string }; export type ExtraSheetMatrix = Array>; type SheetPoint = { row: number; column: number }; export function nextExtraSheetColumnLabel(columns: ExtraSheet["columns"]): string { const labels = new Set(columns.map((column) => column.label.toLocaleLowerCase())); let index = columns.length + 1; while (labels.has(`คอลัมน์ ${index}`.toLocaleLowerCase())) index += 1; return `คอลัมน์ ${index}`; } export function createExtraSheet(createId: (prefix: string) => string): ExtraSheet { const columns = [1, 2].map((index) => ({ id: createId("extra-sheet-column"), label: `คอลัมน์ ${index}`, })); return { columns, rows: [1, 2].map(() => ({ id: createId("extra-sheet-row"), cells: Object.fromEntries(columns.map((column) => [column.id, ""])), backgrounds: {}, })), }; } export function extraSheetToMatrix(sheet: ExtraSheet): ExtraSheetMatrix { return sheet.rows.map((row) => sheet.columns.map((column) => ({ value: row.cells[column.id] ?? "", backgroundColor: row.backgrounds?.[column.id], })), ); } export function matrixToExtraSheet( sheet: ExtraSheet, matrix: ExtraSheetMatrix, createId: (prefix: string) => string, ): ExtraSheet { const columnCount = Math.max( sheet.columns.length, ...matrix.map((row) => row.length), ); const columns = [...sheet.columns]; while (columns.length < columnCount) { columns.push({ id: createId("extra-sheet-column"), label: nextExtraSheetColumnLabel(columns), }); } const rowCount = Math.max(sheet.rows.length, matrix.length); const rows = Array.from({ length: rowCount }, (_, rowIndex) => ({ id: sheet.rows[rowIndex]?.id ?? createId("extra-sheet-row"), cells: Object.fromEntries(columns.map((column, columnIndex) => [ column.id, String(matrix[rowIndex]?.[columnIndex]?.value ?? ""), ])), backgrounds: Object.fromEntries(columns.flatMap((column, columnIndex) => { const backgroundColor = matrix[rowIndex]?.[columnIndex]?.backgroundColor ?? sheet.rows[rowIndex]?.backgrounds?.[column.id]; return backgroundColor ? [[column.id, backgroundColor]] : []; })), })); return { columns, rows }; } export function addExtraSheetColumn( sheet: ExtraSheet, label: string, id: string, ): ExtraSheet { return { columns: [...sheet.columns, { id, label }], rows: sheet.rows.map((row) => ({ ...row, cells: { ...row.cells, [id]: "" } })), }; } export function addExtraSheetRow(sheet: ExtraSheet, id: string): ExtraSheet { return { ...sheet, rows: [...sheet.rows, { id, cells: Object.fromEntries(sheet.columns.map((column) => [column.id, ""])), backgrounds: {}, }], }; } export function moveExtraSheetColumn( sheet: ExtraSheet, from: number, to: number, ): ExtraSheet { if (to < 0 || to >= sheet.columns.length || from === to) return sheet; const columns = [...sheet.columns]; const [column] = columns.splice(from, 1); columns.splice(to, 0, column); return { ...sheet, columns }; } export function moveExtraSheetRow( sheet: ExtraSheet, from: number, to: number, ): ExtraSheet { if (to < 0 || to >= sheet.rows.length || from === to) return sheet; const rows = [...sheet.rows]; const [row] = rows.splice(from, 1); rows.splice(to, 0, row); return { ...sheet, rows }; } export function deleteExtraSheetColumn(sheet: ExtraSheet, index: number): ExtraSheet { if (sheet.columns.length === 1) return sheet; const columnId = sheet.columns[index]?.id; if (!columnId) return sheet; return { columns: sheet.columns.filter((_, columnIndex) => columnIndex !== index), rows: sheet.rows.map((row) => ({ ...row, cells: Object.fromEntries(Object.entries(row.cells).filter(([key]) => key !== columnId)), backgrounds: Object.fromEntries(Object.entries(row.backgrounds ?? {}).filter(([key]) => key !== columnId)), })), }; } export function deleteExtraSheetRow(sheet: ExtraSheet, index: number): ExtraSheet { if (sheet.rows.length === 1) return sheet; return { ...sheet, rows: sheet.rows.filter((_, rowIndex) => rowIndex !== index) }; } export function updateExtraSheetCell( sheet: ExtraSheet, rowIndex: number, columnIndex: number, value: string, ): ExtraSheet { const row = sheet.rows[rowIndex]; const column = sheet.columns[columnIndex]; if (!row || !column) return sheet; return { ...sheet, rows: sheet.rows.map((candidate, index) => index === rowIndex ? { ...candidate, cells: { ...candidate.cells, [column.id]: value } } : candidate), }; } export function updateExtraSheetCells( sheet: ExtraSheet, points: Iterable, update: { value?: string; backgroundColor?: string | null }, ): ExtraSheet { const selected = new Set(Array.from(points, ({ row, column }) => `${row}:${column}`)); return { ...sheet, rows: sheet.rows.map((row, rowIndex) => { const cells = { ...row.cells }; const backgrounds = { ...(row.backgrounds ?? {}) }; sheet.columns.forEach((column, columnIndex) => { if (!selected.has(`${rowIndex}:${columnIndex}`)) return; if (update.value !== undefined) cells[column.id] = update.value; if (update.backgroundColor === null) delete backgrounds[column.id]; else if (update.backgroundColor !== undefined) backgrounds[column.id] = update.backgroundColor; }); return { ...row, cells, backgrounds }; }), }; } export function styleExtraSheetCells( sheet: ExtraSheet, points: Iterable, patch: RichTextStylePatch, ): ExtraSheet { const selected = new Set(Array.from(points, ({ row, column }) => `${row}:${column}`)); return { ...sheet, rows: sheet.rows.map((row, rowIndex) => ({ ...row, cells: Object.fromEntries(Object.entries(row.cells).map(([columnId, value]) => { const columnIndex = sheet.columns.findIndex((column) => column.id === columnId); return [ columnId, selected.has(`${rowIndex}:${columnIndex}`) ? applyRichTextStylePatch(value, patch) : value, ]; })), })), }; } export function normalizeExtraSheetRichText(sheet: ExtraSheet): ExtraSheet { return { ...sheet, rows: sheet.rows.map((row) => ({ ...row, cells: Object.fromEntries(Object.entries(row.cells).map(([columnId, value]) => [ columnId, normalizeExtraRichText(value), ])), })), }; }