767 lines
No EOL
29 KiB
TypeScript
767 lines
No EOL
29 KiB
TypeScript
import React, { useState, useMemo, useRef, useEffect } from 'react';
|
|
import { useLanguage } from '../../contexts/LanguageContext';
|
|
import styles from './FormGenerator.module.css';
|
|
|
|
// Types for the FormGenerator
|
|
export interface ColumnConfig {
|
|
key: string;
|
|
label: string;
|
|
type?: 'string' | 'number' | 'date' | 'boolean' | 'enum';
|
|
width?: number;
|
|
minWidth?: number;
|
|
maxWidth?: number;
|
|
sortable?: boolean;
|
|
filterable?: boolean;
|
|
searchable?: boolean;
|
|
formatter?: (value: any, row: any) => React.ReactNode;
|
|
filterOptions?: string[]; // For enum/select filters
|
|
}
|
|
|
|
export interface FormGeneratorProps<T = any> {
|
|
data: T[];
|
|
columns?: ColumnConfig[];
|
|
title?: string;
|
|
searchable?: boolean;
|
|
filterable?: boolean;
|
|
sortable?: boolean;
|
|
resizable?: boolean;
|
|
pagination?: boolean;
|
|
pageSize?: number;
|
|
pageSizeOptions?: number[];
|
|
showPageSizeSelector?: boolean;
|
|
onRowClick?: (row: T, index: number) => void;
|
|
onRowSelect?: (selectedRows: T[]) => void;
|
|
selectable?: boolean;
|
|
loading?: boolean;
|
|
actions?: {
|
|
label: string;
|
|
onClick: (row: T) => void;
|
|
icon?: string | React.ReactNode | ((row: T) => React.ReactNode);
|
|
}[];
|
|
onDelete?: (row: T) => void;
|
|
onDeleteMultiple?: (rows: T[]) => void;
|
|
className?: string;
|
|
}
|
|
|
|
export function FormGenerator<T extends Record<string, any>>({
|
|
data,
|
|
columns: providedColumns,
|
|
searchable = true,
|
|
filterable = true,
|
|
sortable = true,
|
|
resizable = true,
|
|
pagination = true,
|
|
pageSize = 10,
|
|
pageSizeOptions = [10, 25, 50, 100],
|
|
showPageSizeSelector = true,
|
|
onRowClick,
|
|
onRowSelect,
|
|
selectable = true, // Default to true for selection functionality
|
|
loading = false,
|
|
actions = [],
|
|
onDelete,
|
|
onDeleteMultiple,
|
|
className = ''
|
|
}: FormGeneratorProps<T>) {
|
|
const { t } = useLanguage();
|
|
// Auto-detect columns if not provided
|
|
const detectedColumns = useMemo((): ColumnConfig[] => {
|
|
if (providedColumns) return providedColumns;
|
|
|
|
if (data.length === 0) return [];
|
|
|
|
const sampleRow = data[0];
|
|
return Object.keys(sampleRow).map(key => {
|
|
const value = sampleRow[key];
|
|
let type: ColumnConfig['type'] = 'string';
|
|
|
|
// Auto-detect type based on value
|
|
if (typeof value === 'number') {
|
|
type = 'number';
|
|
} else if (typeof value === 'boolean') {
|
|
type = 'boolean';
|
|
} else if (value instanceof Date || (typeof value === 'string' && !isNaN(Date.parse(value)) && value.includes('-'))) {
|
|
type = 'date';
|
|
}
|
|
|
|
return {
|
|
key,
|
|
label: key.charAt(0).toUpperCase() + key.slice(1).replace(/([A-Z])/g, ' $1'),
|
|
type,
|
|
sortable: true,
|
|
filterable: true,
|
|
searchable: type === 'string',
|
|
width: 150,
|
|
minWidth: 100,
|
|
maxWidth: 400
|
|
};
|
|
});
|
|
}, [data, providedColumns]);
|
|
|
|
// State management
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [searchFocused, setSearchFocused] = useState(false);
|
|
const [filterFocused, setFilterFocused] = useState<Record<string, boolean>>({});
|
|
const [sortConfig, setSortConfig] = useState<{ key: string; direction: 'asc' | 'desc' } | null>(null);
|
|
const [filters, setFilters] = useState<Record<string, any>>({});
|
|
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
|
|
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const [currentPageSize, setCurrentPageSize] = useState(pageSize);
|
|
|
|
// Refs for resizing
|
|
const tableRef = useRef<HTMLTableElement>(null);
|
|
const resizingColumn = useRef<string | null>(null);
|
|
const startX = useRef<number>(0);
|
|
const startWidth = useRef<number>(0);
|
|
|
|
// Initialize column widths
|
|
useEffect(() => {
|
|
const initialWidths: Record<string, number> = {};
|
|
|
|
detectedColumns.forEach(col => {
|
|
// Set a default width if none specified to ensure all columns have explicit widths
|
|
initialWidths[col.key] = col.width || 150;
|
|
});
|
|
setColumnWidths(initialWidths);
|
|
}, [detectedColumns]);
|
|
|
|
// Filter and search data
|
|
const filteredData = useMemo(() => {
|
|
let result = [...data];
|
|
|
|
// Apply search filter
|
|
if (searchTerm && searchable) {
|
|
const searchLower = searchTerm.toLowerCase();
|
|
result = result.filter(row => {
|
|
return detectedColumns.some(col => {
|
|
if (!col.searchable) return false;
|
|
const value = row[col.key];
|
|
return String(value).toLowerCase().includes(searchLower);
|
|
});
|
|
});
|
|
}
|
|
|
|
// Apply column filters
|
|
Object.entries(filters).forEach(([key, filterValue]) => {
|
|
if (filterValue !== undefined && filterValue !== '') {
|
|
result = result.filter(row => {
|
|
const value = row[key];
|
|
const column = detectedColumns.find(col => col.key === key);
|
|
|
|
if (column?.type === 'boolean') {
|
|
return Boolean(value) === Boolean(filterValue);
|
|
} else if (column?.type === 'number') {
|
|
return Number(value) === Number(filterValue);
|
|
} else if (column?.type === 'date') {
|
|
// Convert row value to DD.MM.YYYY format for comparison
|
|
const rowDate = new Date(value);
|
|
const rowFormatted = `${rowDate.getDate().toString().padStart(2, '0')}.${(rowDate.getMonth() + 1).toString().padStart(2, '0')}.${rowDate.getFullYear()}`;
|
|
|
|
// Check if filter value is complete (DD.MM.YYYY)
|
|
if (filterValue.length === 10 && filterValue.match(/^\d{2}\.\d{2}\.\d{4}$/)) {
|
|
return rowFormatted === filterValue;
|
|
} else {
|
|
// Partial matching for incomplete dates
|
|
return rowFormatted.startsWith(filterValue);
|
|
}
|
|
} else {
|
|
return String(value).toLowerCase().includes(String(filterValue).toLowerCase());
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
// Apply sorting
|
|
if (sortConfig) {
|
|
result.sort((a, b) => {
|
|
const aVal = a[sortConfig.key];
|
|
const bVal = b[sortConfig.key];
|
|
|
|
if (aVal < bVal) return sortConfig.direction === 'asc' ? -1 : 1;
|
|
if (aVal > bVal) return sortConfig.direction === 'asc' ? 1 : -1;
|
|
return 0;
|
|
});
|
|
}
|
|
|
|
return result;
|
|
}, [data, searchTerm, filters, sortConfig, detectedColumns, searchable]);
|
|
|
|
// Pagination
|
|
const paginatedData = useMemo(() => {
|
|
if (!pagination) return filteredData;
|
|
|
|
const startIndex = (currentPage - 1) * currentPageSize;
|
|
return filteredData.slice(startIndex, startIndex + currentPageSize);
|
|
}, [filteredData, currentPage, currentPageSize, pagination]);
|
|
|
|
const totalPages = Math.ceil(filteredData.length / currentPageSize);
|
|
|
|
// Handle sorting
|
|
const handleSort = (key: string) => {
|
|
if (!sortable) return;
|
|
|
|
setSortConfig(current => {
|
|
if (current?.key === key) {
|
|
return current.direction === 'asc'
|
|
? { key, direction: 'desc' }
|
|
: null;
|
|
}
|
|
return { key, direction: 'asc' };
|
|
});
|
|
};
|
|
|
|
// Handle filtering
|
|
const handleFilter = (key: string, value: any) => {
|
|
setFilters(prev => ({
|
|
...prev,
|
|
[key]: value
|
|
}));
|
|
setCurrentPage(1); // Reset to first page when filtering
|
|
};
|
|
|
|
// Handle filter input focus
|
|
const handleFilterFocus = (key: string, focused: boolean) => {
|
|
setFilterFocused(prev => ({
|
|
...prev,
|
|
[key]: focused
|
|
}));
|
|
};
|
|
|
|
// Handle row selection
|
|
const handleRowSelect = (index: number) => {
|
|
if (!selectable) return;
|
|
|
|
const newSelected = new Set(selectedRows);
|
|
if (newSelected.has(index)) {
|
|
newSelected.delete(index);
|
|
} else {
|
|
newSelected.add(index);
|
|
}
|
|
setSelectedRows(newSelected);
|
|
|
|
if (onRowSelect) {
|
|
const selectedData = Array.from(newSelected).map(i => paginatedData[i]);
|
|
onRowSelect(selectedData);
|
|
}
|
|
};
|
|
|
|
// Handle select all
|
|
const handleSelectAll = () => {
|
|
if (!selectable) return;
|
|
|
|
if (selectedRows.size === paginatedData.length) {
|
|
setSelectedRows(new Set());
|
|
onRowSelect?.([]);
|
|
} else {
|
|
const allIndices = new Set(paginatedData.map((_, index) => index));
|
|
setSelectedRows(allIndices);
|
|
onRowSelect?.(paginatedData);
|
|
}
|
|
};
|
|
|
|
// Handle delete single item
|
|
const handleDeleteSingle = (row: T, index: number) => {
|
|
if (onDelete) {
|
|
onDelete(row);
|
|
// Remove from selection if it was selected
|
|
if (selectedRows.has(index)) {
|
|
const newSelected = new Set(selectedRows);
|
|
newSelected.delete(index);
|
|
setSelectedRows(newSelected);
|
|
if (onRowSelect) {
|
|
const selectedData = Array.from(newSelected).map(i => paginatedData[i]);
|
|
onRowSelect(selectedData);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
// Handle delete multiple items
|
|
const handleDeleteMultiple = () => {
|
|
if (onDeleteMultiple && selectedRows.size > 0) {
|
|
const selectedData = Array.from(selectedRows).map(i => paginatedData[i]);
|
|
onDeleteMultiple(selectedData);
|
|
// Clear selection
|
|
setSelectedRows(new Set());
|
|
onRowSelect?.([]);
|
|
}
|
|
};
|
|
|
|
// Handle page size change
|
|
const handlePageSizeChange = (newPageSize: number) => {
|
|
setCurrentPageSize(newPageSize);
|
|
setCurrentPage(1); // Reset to first page when page size changes
|
|
};
|
|
|
|
// Handle column resizing
|
|
const handleMouseDown = (e: React.MouseEvent, columnKey: string) => {
|
|
if (!resizable) return;
|
|
|
|
e.preventDefault();
|
|
resizingColumn.current = columnKey;
|
|
startX.current = e.clientX;
|
|
startWidth.current = columnWidths[columnKey] || 150;
|
|
|
|
document.addEventListener('mousemove', handleMouseMove);
|
|
document.addEventListener('mouseup', handleMouseUp);
|
|
};
|
|
|
|
const handleMouseMove = (e: MouseEvent) => {
|
|
if (!resizingColumn.current) return;
|
|
|
|
const diff = e.clientX - startX.current;
|
|
let newWidth = Math.max(100, startWidth.current + diff);
|
|
|
|
// Prevent extending beyond table container
|
|
const tableContainer = tableRef.current?.parentElement;
|
|
if (tableContainer) {
|
|
const containerWidth = tableContainer.clientWidth;
|
|
const actionsColumnWidth = actions.length > 0 ? 120 : 0; // Fixed width actions column
|
|
const selectColumnWidth = selectable ? 50 : 0; // Fixed width select column
|
|
const fixedWidth = actionsColumnWidth + selectColumnWidth;
|
|
|
|
// Calculate total width of all OTHER data columns (excluding the one being resized)
|
|
const otherDataColumnsWidth = detectedColumns.reduce((total, col) => {
|
|
if (col.key !== resizingColumn.current) {
|
|
return total + (columnWidths[col.key] || col.width || 150);
|
|
}
|
|
return total;
|
|
}, 0);
|
|
|
|
// Maximum allowed width for this column
|
|
const maxAllowedWidth = containerWidth - fixedWidth - otherDataColumnsWidth - 40; // 40px buffer
|
|
newWidth = Math.min(newWidth, Math.max(100, maxAllowedWidth));
|
|
}
|
|
|
|
setColumnWidths(prev => ({
|
|
...prev,
|
|
[resizingColumn.current!]: newWidth
|
|
}));
|
|
};
|
|
|
|
const handleMouseUp = () => {
|
|
resizingColumn.current = null;
|
|
document.removeEventListener('mousemove', handleMouseMove);
|
|
document.removeEventListener('mouseup', handleMouseUp);
|
|
};
|
|
|
|
// Format cell value
|
|
const formatCellValue = (value: any, column: ColumnConfig, row: T) => {
|
|
if (column.formatter) {
|
|
return column.formatter(value, row);
|
|
}
|
|
|
|
if (value === null || value === undefined) {
|
|
return '-';
|
|
}
|
|
|
|
switch (column.type) {
|
|
case 'date':
|
|
return new Date(value).toLocaleDateString();
|
|
case 'boolean':
|
|
return value ? '✓' : '✗';
|
|
case 'number':
|
|
return typeof value === 'number' ? value.toLocaleString() : value;
|
|
default:
|
|
return String(value);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className={`${styles.formGenerator} ${className}`}>
|
|
|
|
{(searchable || filterable || (selectable && selectedRows.size > 0)) && (
|
|
<div className={styles.controls}>
|
|
{/* Delete Controls - Show when items are selected */}
|
|
{selectable && selectedRows.size > 0 && (
|
|
<div className={styles.deleteControlsIntegrated}>
|
|
{selectedRows.size === 1 && onDelete && (
|
|
<button
|
|
onClick={() => {
|
|
const selectedIndex = Array.from(selectedRows)[0];
|
|
const selectedRow = paginatedData[selectedIndex];
|
|
handleDeleteSingle(selectedRow, selectedIndex);
|
|
}}
|
|
className={styles.deleteButton}
|
|
title={t('formgen.delete.single', 'Delete selected item')}
|
|
>
|
|
<span className={styles.deleteIcon}>✕</span>
|
|
{t('formgen.delete.single', 'Delete')}
|
|
</button>
|
|
)}
|
|
{selectedRows.size > 1 && onDeleteMultiple && (
|
|
<button
|
|
onClick={handleDeleteMultiple}
|
|
className={styles.deleteAllButton}
|
|
title={t('formgen.delete.multiple', `Delete ${selectedRows.size} selected items`)}
|
|
>
|
|
<span className={styles.deleteIcon}>✕</span>
|
|
{t('formgen.delete.multiple', `Delete All (${selectedRows.size})`)}
|
|
</button>
|
|
)}
|
|
|
|
</div>
|
|
)}
|
|
|
|
{/* Search Controls - Hide when items are selected */}
|
|
{searchable && selectedRows.size === 0 && (
|
|
<div className={styles.searchContainer}>
|
|
<div className={styles.floatingLabelInput}>
|
|
<input
|
|
type="text"
|
|
placeholder=" "
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
onFocus={() => setSearchFocused(true)}
|
|
onBlur={() => setSearchFocused(false)}
|
|
className={`${styles.searchInput} ${searchFocused || searchTerm ? styles.focused : ''}`}
|
|
/>
|
|
<label className={searchFocused || searchTerm ? styles.focusedLabel : styles.label}>{t('formgen.search.placeholder')}</label>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{filterable && (
|
|
<div className={styles.filtersContainer}>
|
|
{detectedColumns.filter(col => col.filterable).map(column => (
|
|
<div key={column.key} className={styles.filterGroup}>
|
|
{column.type === 'boolean' ? (
|
|
<div className={styles.customSelectContainer}>
|
|
<select
|
|
value={filters[column.key] || ''}
|
|
onChange={(e) => handleFilter(column.key, e.target.value === '' ? undefined : e.target.value === 'true')}
|
|
className={`${styles.filterSelect} ${filters[column.key] ? styles.hasValue : ''}`}
|
|
>
|
|
<option value="" disabled hidden>{column.label}</option>
|
|
<option value="true">{t('formgen.filter.yes')}</option>
|
|
<option value="false">{t('formgen.filter.no')}</option>
|
|
</select>
|
|
{filters[column.key] && (
|
|
<button
|
|
type="button"
|
|
onClick={() => handleFilter(column.key, '')}
|
|
className={styles.clearFilterButton}
|
|
title={t('formgen.filter.clear')}
|
|
>
|
|
✕
|
|
</button>
|
|
)}
|
|
</div>
|
|
) : column.filterOptions ? (
|
|
<div className={styles.customSelectContainer}>
|
|
<select
|
|
value={filters[column.key] || ''}
|
|
onChange={(e) => handleFilter(column.key, e.target.value)}
|
|
className={`${styles.filterSelect} ${filters[column.key] ? styles.hasValue : ''}`}
|
|
>
|
|
<option value="" disabled hidden>{column.label}</option>
|
|
|
|
{column.filterOptions.map(option => (
|
|
<option key={option} value={option}>{option}</option>
|
|
))}
|
|
</select>
|
|
{filters[column.key] && (
|
|
<button
|
|
type="button"
|
|
onClick={() => handleFilter(column.key, '')}
|
|
className={styles.clearFilterButton}
|
|
title={t('formgen.filter.clear')}
|
|
>
|
|
✕
|
|
</button>
|
|
)}
|
|
</div>
|
|
) : column.type === 'date' ? (
|
|
<div className={styles.floatingLabelInput}>
|
|
<input
|
|
type="text"
|
|
placeholder=" "
|
|
value={filters[column.key] || ''}
|
|
onChange={(e) => {
|
|
let value = e.target.value;
|
|
const currentValue = filters[column.key] || '';
|
|
|
|
// Check if user is deleting (new value is shorter)
|
|
const isDeleting = value.length < currentValue.length;
|
|
|
|
if (isDeleting) {
|
|
// When deleting, preserve the exact input without auto-formatting
|
|
handleFilter(column.key, value);
|
|
return;
|
|
}
|
|
|
|
// Auto-pad single digits followed by dot (e.g., "4." -> "04.")
|
|
value = value.replace(/^(\d)\./, '0$1.');
|
|
value = value.replace(/\.(\d)\./, '.0$1.');
|
|
|
|
// Allow typing and format as DD.MM.YYYY
|
|
const digitsOnly = value.replace(/\D/g, ''); // Remove non-digits
|
|
|
|
let formatted = '';
|
|
if (digitsOnly.length >= 8) {
|
|
// Full format: DDMMYYYY -> DD.MM.YYYY
|
|
const day = digitsOnly.slice(0, 2);
|
|
const month = digitsOnly.slice(2, 4);
|
|
const year = digitsOnly.slice(4, 8);
|
|
|
|
// Validate day (01-31) and month (01-12)
|
|
if (parseInt(day) > 31 || parseInt(month) > 12 || parseInt(day) === 0 || parseInt(month) === 0) {
|
|
return; // Don't update if invalid
|
|
}
|
|
formatted = `${day}.${month}.${year}`;
|
|
} else if (digitsOnly.length >= 4) {
|
|
// Partial format: DDMM -> DD.MM.
|
|
const day = digitsOnly.slice(0, 2);
|
|
const month = digitsOnly.slice(2, 4);
|
|
const remaining = digitsOnly.slice(4);
|
|
|
|
// Validate day (01-31) and month (01-12)
|
|
if (parseInt(day) > 31 || parseInt(month) > 12 || parseInt(day) === 0 || parseInt(month) === 0) {
|
|
return; // Don't update if invalid
|
|
}
|
|
formatted = `${day}.${month}.${remaining}`;
|
|
} else if (digitsOnly.length >= 2) {
|
|
// Start format: DD -> DD.
|
|
const day = digitsOnly.slice(0, 2);
|
|
const remaining = digitsOnly.slice(2);
|
|
|
|
// Validate day (01-31)
|
|
if (parseInt(day) > 31 || parseInt(day) === 0) {
|
|
return; // Don't update if invalid
|
|
}
|
|
formatted = `${day}.${remaining}`;
|
|
} else {
|
|
// Just digits
|
|
formatted = digitsOnly;
|
|
}
|
|
|
|
handleFilter(column.key, formatted);
|
|
}}
|
|
onFocus={() => handleFilterFocus(column.key, true)}
|
|
onBlur={() => handleFilterFocus(column.key, false)}
|
|
className={`${styles.filterInput} ${filterFocused[column.key] || filters[column.key] ? styles.focused : ''}`}
|
|
maxLength={10}
|
|
/>
|
|
<label className={filterFocused[column.key] || filters[column.key] ? styles.focusedLabel : styles.label}>
|
|
{column.label}
|
|
</label>
|
|
</div>
|
|
) : (
|
|
<div className={styles.floatingLabelInput}>
|
|
<input
|
|
type="text"
|
|
placeholder=" "
|
|
value={filters[column.key] || ''}
|
|
onChange={(e) => handleFilter(column.key, e.target.value)}
|
|
onFocus={() => handleFilterFocus(column.key, true)}
|
|
onBlur={() => handleFilterFocus(column.key, false)}
|
|
className={`${styles.filterInput} ${filterFocused[column.key] || filters[column.key] ? styles.focused : ''}`}
|
|
/>
|
|
<label className={filterFocused[column.key] || filters[column.key] ? styles.focusedLabel : styles.label}>
|
|
{t('formgen.filter.placeholder').replace('{column}', column.label)}
|
|
</label>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Table */}
|
|
<div className={styles.tableContainer}>
|
|
{loading ? (
|
|
<div className={styles.loadingState}>
|
|
<div className={styles.loadingSpinner}></div>
|
|
<p>{t('common.loading', 'Loading...')}</p>
|
|
</div>
|
|
) : (
|
|
<table ref={tableRef} className={styles.table}>
|
|
<thead>
|
|
<tr>
|
|
{selectable && (
|
|
<th className={styles.selectColumn} style={{ width: '50px', minWidth: '50px', maxWidth: '50px' }}>
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedRows.size === paginatedData.length && paginatedData.length > 0}
|
|
onChange={handleSelectAll}
|
|
title="Select all items"
|
|
/>
|
|
</th>
|
|
)}
|
|
{actions.length > 0 && (
|
|
<th
|
|
className={styles.actionsColumn}
|
|
style={{ width: '120px', minWidth: '120px', maxWidth: '120px' }}
|
|
>
|
|
|
|
</th>
|
|
)}
|
|
{detectedColumns.map(column => (
|
|
<th
|
|
key={column.key}
|
|
className={`${styles.th} ${sortable && column.sortable ? styles.sortable : ''}`}
|
|
style={{
|
|
width: columnWidths[column.key] || column.width || 150,
|
|
minWidth: columnWidths[column.key] || column.width || 150,
|
|
maxWidth: columnWidths[column.key] || column.width || 150
|
|
}}
|
|
onClick={() => column.sortable && handleSort(column.key)}
|
|
>
|
|
<div className={styles.headerContent}>
|
|
<span>{column.label}</span>
|
|
{sortable && column.sortable && (
|
|
<span className={styles.sortIcon}>
|
|
{sortConfig?.key === column.key ? (
|
|
sortConfig.direction === 'asc' ? '↑' : '↓'
|
|
) : '↕'}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{resizable && (
|
|
<div
|
|
className={styles.resizeHandle}
|
|
onMouseDown={(e) => handleMouseDown(e, column.key)}
|
|
/>
|
|
)}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{paginatedData.map((row, index) => (
|
|
<tr
|
|
key={index}
|
|
className={`${styles.tr} ${selectedRows.has(index) ? styles.selected : ''} ${onRowClick ? styles.clickable : ''}`}
|
|
onClick={() => onRowClick?.(row, index)}
|
|
>
|
|
{selectable && (
|
|
<td className={styles.selectColumn} style={{ width: '50px', minWidth: '50px', maxWidth: '50px' }}>
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedRows.has(index)}
|
|
onChange={() => handleRowSelect(index)}
|
|
onClick={(e) => e.stopPropagation()}
|
|
title="Select this item"
|
|
/>
|
|
</td>
|
|
)}
|
|
{actions.length > 0 && (
|
|
<td
|
|
className={styles.actionsColumn}
|
|
style={{ width: '120px', minWidth: '120px', maxWidth: '120px' }}
|
|
>
|
|
<div className={styles.actionButtons}>
|
|
{actions.map((action, actionIndex) => (
|
|
<button
|
|
key={actionIndex}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
action.onClick(row);
|
|
}}
|
|
className={styles.actionButton}
|
|
title={action.label}
|
|
>
|
|
{action.icon && (
|
|
<span className={styles.actionIcon}>
|
|
{typeof action.icon === 'function' ? action.icon(row) : action.icon}
|
|
</span>
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</td>
|
|
)}
|
|
{detectedColumns.map(column => (
|
|
<td
|
|
key={column.key}
|
|
className={styles.td}
|
|
style={{
|
|
width: columnWidths[column.key] || column.width || 150,
|
|
minWidth: columnWidths[column.key] || column.width || 150,
|
|
maxWidth: columnWidths[column.key] || column.width || 150
|
|
}}
|
|
>
|
|
{formatCellValue(row[column.key], column, row)}
|
|
</td>
|
|
))}
|
|
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</div>
|
|
|
|
{/* Pagination */}
|
|
{pagination && (
|
|
<div className={styles.pagination}>
|
|
{showPageSizeSelector && (
|
|
<div className={styles.pageSizeSelector}>
|
|
<label htmlFor="pageSize">{t('formgen.pagination.pageSize', 'Items per page:')}</label>
|
|
<select
|
|
id="pageSize"
|
|
value={currentPageSize}
|
|
onChange={(e) => handlePageSizeChange(Number(e.target.value))}
|
|
className={styles.pageSizeSelect}
|
|
>
|
|
{pageSizeOptions.map(size => (
|
|
<option key={size} value={size}>{size}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
)}
|
|
|
|
{totalPages > 1 && (
|
|
<>
|
|
<button
|
|
onClick={() => setCurrentPage(1)}
|
|
disabled={currentPage === 1}
|
|
className={styles.paginationButton}
|
|
title={t('formgen.pagination.first')}
|
|
>
|
|
««
|
|
</button>
|
|
<button
|
|
onClick={() => setCurrentPage(currentPage - 1)}
|
|
disabled={currentPage === 1}
|
|
className={styles.paginationButton}
|
|
title={t('formgen.pagination.prev')}
|
|
>
|
|
«
|
|
</button>
|
|
|
|
<span className={styles.paginationInfo}>
|
|
{t('formgen.pagination.info')
|
|
.replace('{page}', currentPage.toString())
|
|
.replace('{total}', totalPages.toString())
|
|
.replace('{count}', filteredData.length.toString())}
|
|
</span>
|
|
|
|
<button
|
|
onClick={() => setCurrentPage(currentPage + 1)}
|
|
disabled={currentPage === totalPages}
|
|
className={styles.paginationButton}
|
|
title={t('formgen.pagination.next')}
|
|
>
|
|
»
|
|
</button>
|
|
<button
|
|
onClick={() => setCurrentPage(totalPages)}
|
|
disabled={currentPage === totalPages}
|
|
className={styles.paginationButton}
|
|
title={t('formgen.pagination.last')}
|
|
>
|
|
»»
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default FormGenerator;
|