diff --git a/client/src/pages/TableEditor.tsx b/client/src/pages/TableEditor.tsx index 682cd31..8d139b3 100644 --- a/client/src/pages/TableEditor.tsx +++ b/client/src/pages/TableEditor.tsx @@ -89,8 +89,9 @@ export default function TableEditor() { const [treeColumnIndex, setTreeColumnIndex] = useState(null); const [treeDisplayColumnIndex, setTreeDisplayColumnIndex] = useState(null); const [importOpen, setImportOpen] = useState(false); - const [importPreview, setImportPreview] = useState<{ - added: { values: string[] }[]; + const [importPreview, setImportPreview] = useState<{ + added: { values: string[]; parentId?: number | null }[]; + updated: { id: number; values: string[]; oldPosition: number; newPosition: number; parentId: number | null; oldParentId: number | null }[]; deleted: { id: number; values: string[] }[]; reordered: { values: string[]; oldPosition: number; newPosition: number }[]; } | null>(null); @@ -478,8 +479,13 @@ export default function TableEditor() { const handleExport = async () => { if (!data?.table) return; const cols = data.table.columns as TableColumn[]; - const headers = cols.map(c => c.name); - const rows = (rowsData?.rows || data.table.rows).map(r => r.values); + const headers = ['_id', '_parent_id', '_position', ...cols.map(c => c.name)]; + const rows = (rowsData?.rows || data.table.rows).map(r => [ + r.id, + r.parentId ?? '', + r.position, + ...r.values, + ]); const wsData = [headers, ...rows]; // Notify server about the export for audit logging (fire-and-forget) @@ -497,6 +503,21 @@ export default function TableEditor() { toast({ title: 'Файл экспортирован' }); }; + const handleDownloadTemplate = async () => { + if (!data?.table) return; + const cols = data.table.columns as TableColumn[]; + const headers = ['_id', '_parent_id', '_position', ...cols.map(c => c.name)]; + const exampleRow = [1, '', 0, ...cols.map(() => '')]; + const noteRow = ['id строки (авто)', 'id родителя (можно пусто)', 'порядок', ...cols.map(c => `значение: ${c.name}`)]; + const wsData = [headers, noteRow, exampleRow]; + + const ws = XLSX.utils.aoa_to_sheet(wsData); + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, 'Шаблон'); + XLSX.writeFile(wb, `${data.table.name}_шаблон.xlsx`); + toast({ title: 'Шаблон скачан' }); + }; + const handleImportFile = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file || !data?.table) return; @@ -506,18 +527,44 @@ export default function TableEditor() { const buffer = await file.arrayBuffer(); const wb = XLSX.read(buffer, { type: 'array' }); const ws = wb.Sheets[wb.SheetNames[0]]; - const importedData: string[][] = XLSX.utils.sheet_to_json(ws, { header: 1 }); + const importedData: (string | number | null)[][] = XLSX.utils.sheet_to_json(ws, { header: 1 }); if (importedData.length === 0) { toast({ title: 'Файл пуст', variant: 'destructive' }); return; } - const hasHeaders = importedData[0].some(cell => - tableColumns.some(col => col.name.toLowerCase() === String(cell || '').toLowerCase()) - ); - const dataRows = hasHeaders ? importedData.slice(1) : importedData; - const rows = dataRows.map(row => ({ values: row.map(cell => String(cell || '')) })); + const headers = importedData[0].map(h => String(h || '').trim().toLowerCase()); + const idColIndex = headers.indexOf('_id'); + const parentIdColIndex = headers.indexOf('_parent_id'); + const positionColIndex = headers.indexOf('_position'); + const isExtendedFormat = idColIndex !== -1 || parentIdColIndex !== -1 || positionColIndex !== -1; + + const firstDataRowIndex = importedData.slice(1).findIndex(row => row.some(cell => cell != null && String(cell).trim() !== '')); + const dataRows = firstDataRowIndex !== -1 ? importedData.slice(1 + firstDataRowIndex) : []; + + let rows: any[]; + if (isExtendedFormat) { + rows = dataRows.map(row => { + const id = idColIndex !== -1 && row[idColIndex] != null && String(row[idColIndex]).trim() !== '' + ? Number(row[idColIndex]) + : undefined; + const parentId = parentIdColIndex !== -1 && row[parentIdColIndex] != null && String(row[parentIdColIndex]).trim() !== '' + ? Number(row[parentIdColIndex]) + : null; + const values = row + .map((cell, idx) => ({ cell, idx })) + .filter(({ idx }) => idx !== idColIndex && idx !== parentIdColIndex && idx !== positionColIndex) + .map(({ cell }) => String(cell ?? '')); + return { id, parentId, values }; + }); + } else { + const hasHeaders = importedData[0].some(cell => + tableColumns.some(col => col.name.toLowerCase() === String(cell || '').toLowerCase()) + ); + const legacyDataRows = hasHeaders ? importedData.slice(1) : importedData; + rows = legacyDataRows.map(row => ({ values: row.map(cell => String(cell ?? '')) })); + } setImportRows(rows); const response = await apiRequest('POST', `/api/directories/${tableId}/sync`, { apply: false, rows }); @@ -527,6 +574,7 @@ export default function TableEditor() { added: result.addedRows || [], deleted: result.deletedRows || [], reordered: result.reorderedRows || [], + updated: result.updatedRows || [], }); setImportOpen(true); } catch (error) { @@ -651,6 +699,10 @@ export default function TableEditor() { {canEdit && ( <> + +{importPreview.added.length} Добавлено +
+ {importPreview.updated.length} + Изменено +
{importPreview.reordered.length} Переупорядочено @@ -1484,6 +1540,28 @@ export default function TableEditor() {
)} + {importPreview.updated.length > 0 && ( +
+

Будут изменены:

+
+ {importPreview.updated.slice(0, 10).map((row, idx) => ( +
+ {Array.isArray(row.values) ? row.values.join(' | ') : '—'} + {row.parentId !== row.oldParentId && ( + (родитель: {row.oldParentId || 'корень'} → {row.parentId || 'корень'}) + )} + {row.oldPosition !== row.newPosition && ( + (позиция: {row.oldPosition + 1} → {row.newPosition + 1}) + )} +
+ ))} + {importPreview.updated.length > 10 && ( +
...и ещё {importPreview.updated.length - 10}
+ )} +
+
+ )} + {importPreview.reordered.length > 0 && (

Изменён порядок:

@@ -1517,7 +1595,7 @@ export default function TableEditor() {
)} - {importPreview.added.length === 0 && importPreview.reordered.length === 0 && importPreview.deleted.length === 0 && ( + {importPreview.added.length === 0 && importPreview.updated.length === 0 && importPreview.reordered.length === 0 && importPreview.deleted.length === 0 && (
Изменений не обнаружено. Данные уже синхронизированы.
@@ -1528,9 +1606,9 @@ export default function TableEditor() { -