import React, { useState, useMemo, useRef, useEffect } from 'react'; import { useLanguage } from '../../contexts/LanguageContext'; import styles from './FormGenerator.module.css'; import { IoIosRefresh, IoIosCheckmark, IoIosClose } from "react-icons/io"; // 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 cellClassName?: (value: any, row: any) => string; // For custom cell styling } export interface FormGeneratorProps { 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; isRowSelectable?: (row: T) => boolean; loading?: boolean; actions?: { label: string | ((row: T) => string); onClick: (row: T) => void; icon?: string | React.ReactNode | ((row: T) => React.ReactNode); }[]; onDelete?: (row: T) => void; onDeleteMultiple?: (rows: T[]) => void; onRefresh?: () => void; className?: string; getRowDataAttributes?: (row: T, index: number) => Record; } export function FormGenerator>({ 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 isRowSelectable, loading = false, actions = [], onDelete, onDeleteMultiple, onRefresh, className = '', getRowDataAttributes }: FormGeneratorProps) { 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>({}); const [sortConfig, setSortConfig] = useState<{ key: string; direction: 'asc' | 'desc' } | null>(null); const [filters, setFilters] = useState>({}); const [columnWidths, setColumnWidths] = useState>({}); const [selectedRows, setSelectedRows] = useState>(new Set()); const [currentPage, setCurrentPage] = useState(1); const [currentPageSize, setCurrentPageSize] = useState(pageSize); // Delete confirmation state const [deleteConfirmRow, setDeleteConfirmRow] = useState(null); const [deletingRows, setDeletingRows] = useState>(new Set()); // Refs for action buttons containers to detect clicks outside const actionButtonsRefs = useRef>(new Map()); // Refs for resizing const tableRef = useRef(null); const resizingColumn = useRef(null); const startX = useRef(0); const startWidth = useRef(0); // Initialize column widths useEffect(() => { const initialWidths: Record = {}; 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]); // Handle clicks outside delete confirmation buttons useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if (deleteConfirmRow !== null) { const actionButtonsRef = actionButtonsRefs.current.get(deleteConfirmRow); if (actionButtonsRef) { // Check if the click is outside the action buttons container for this specific row if (!actionButtonsRef.contains(event.target as Node)) { setDeleteConfirmRow(null); } } } }; if (deleteConfirmRow !== null) { document.addEventListener('mousedown', handleClickOutside); return () => { document.removeEventListener('mousedown', handleClickOutside); }; } }, [deleteConfirmRow]); // 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 row = paginatedData[index]; if (isRowSelectable && !isRowSelectable(row)) 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; // Get only selectable rows const selectableIndices = paginatedData .map((row, index) => ({ row, index })) .filter(({ row }) => !isRowSelectable || isRowSelectable(row)) .map(({ index }) => index); if (selectedRows.size === selectableIndices.length) { setSelectedRows(new Set()); onRowSelect?.([]); } else { const allSelectableIndices = new Set(selectableIndices); setSelectedRows(allSelectableIndices); const selectableData = selectableIndices.map(i => paginatedData[i]); onRowSelect?.(selectableData); } }; // 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 confirmation const handleDeleteConfirm = (_row: T, index: number) => { setDeleteConfirmRow(index); }; // Handle delete confirmation - confirm const handleDeleteConfirmYes = async (row: T, index: number) => { if (onDelete) { setDeletingRows(prev => new Set(prev).add(index)); try { await 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); } } } finally { setDeletingRows(prev => { const newSet = new Set(prev); newSet.delete(index); return newSet; }); setDeleteConfirmRow(null); } } }; // Handle delete confirmation - cancel const handleDeleteConfirmNo = () => { setDeleteConfirmRow(null); }; // 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': try { const date = new Date(value); if (isNaN(date.getTime())) return '-'; const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); const hours = String(date.getHours()).padStart(2, '0'); const minutes = String(date.getMinutes()).padStart(2, '0'); const seconds = String(date.getSeconds()).padStart(2, '0'); const timezoneOffset = date.getTimezoneOffset(); const offsetHours = Math.floor(Math.abs(timezoneOffset) / 60); const offsetMinutes = Math.abs(timezoneOffset) % 60; const offsetSign = timezoneOffset <= 0 ? '+' : '-'; const timezone = `GMT${offsetSign}${offsetHours}${offsetMinutes > 0 ? ':' + offsetMinutes.toString().padStart(2, '0') : ''}`; return `${year}-${month}-${day} ${hours}:${minutes}:${seconds} ${timezone}`; } catch { return '-'; } case 'boolean': return value ? '✓' : '✗'; case 'number': return typeof value === 'number' ? value.toLocaleString() : value; default: return String(value); } }; return (
{(searchable || filterable || (selectable && selectedRows.size > 0)) && (
{/* Delete Controls - Show when items are selected */} {selectable && selectedRows.size > 0 && (
{selectedRows.size === 1 && onDelete && ( )} {selectedRows.size > 1 && onDeleteMultiple && ( )}
)} {/* Search Controls - Hide when items are selected */} {searchable && selectedRows.size === 0 && (
setSearchTerm(e.target.value)} onFocus={() => setSearchFocused(true)} onBlur={() => setSearchFocused(false)} className={`${styles.searchInput} ${searchFocused || searchTerm ? styles.focused : ''}`} />
{onRefresh && ( )}
)} {filterable && (
{detectedColumns.filter(col => col.filterable).map(column => (
{column.type === 'boolean' ? (
{filters[column.key] && ( )}
) : column.filterOptions ? (
{filters[column.key] && ( )}
) : column.type === 'date' ? (
{ 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} />
) : (
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 : ''}`} />
)}
))}
)}
)} {/* Table */}
{loading ? (

{t('common.loading', 'Loading...')}

) : ( {selectable && ( )} {actions.length > 0 && ( )} {detectedColumns.map(column => ( ))} {paginatedData.map((row, index) => { const dataAttributes = getRowDataAttributes ? getRowDataAttributes(row, index) : {}; return ( onRowClick?.(row, index)} {...Object.fromEntries( Object.entries(dataAttributes).map(([key, value]) => [`data-${key}`, value]) )} > {selectable && ( )} {actions.length > 0 && ( )} {detectedColumns.map(column => { const cellValue = row[column.key]; const customClassName = column.cellClassName ? column.cellClassName(cellValue, row) : ''; const combinedClassName = `${styles.td} ${customClassName}`.trim(); return ( ); })} ); })}
{ const selectableIndices = paginatedData .map((row, index) => ({ row, index })) .filter(({ row }) => !isRowSelectable || isRowSelectable(row)) .map(({ index }) => index); return selectedRows.size === selectableIndices.length && selectableIndices.length > 0; })()} onChange={handleSelectAll} title={t('formgen.select.all', 'Select all items')} /> column.sortable && handleSort(column.key)} >
{column.label} {sortable && column.sortable && ( {sortConfig?.key === column.key ? ( sortConfig.direction === 'asc' ? '↑' : '↓' ) : '↕'} )}
{resizable && (
handleMouseDown(e, column.key)} /> )}
handleRowSelect(index)} onClick={(e) => e.stopPropagation()} disabled={isRowSelectable && !isRowSelectable(row)} title={ isRowSelectable && !isRowSelectable(row) ? t('formgen.select.disabled', 'This item cannot be selected') : t('formgen.select.item', 'Select this item') } style={{ opacity: isRowSelectable && !isRowSelectable(row) ? 0.4 : 1, cursor: isRowSelectable && !isRowSelectable(row) ? 'not-allowed' : 'pointer' }} />
{ if (el) { actionButtonsRefs.current.set(index, el); } else { actionButtonsRefs.current.delete(index); } }} className={styles.actionButtons} > {actions.map((action, actionIndex) => { const actionLabel = typeof action.label === 'function' ? action.label(row) : action.label; const isDeleteAction = actionLabel.toLowerCase().includes('delete') || actionLabel.toLowerCase().includes('löschen') || actionLabel.toLowerCase().includes('supprimer') || (typeof action.label === 'string' && action.label.toLowerCase().includes('delete')); const isConfirmingDelete = deleteConfirmRow === index && isDeleteAction; const isDeleting = deletingRows.has(index); // Check if delete action is disabled (e.g., for system prompts) const isDeleteDisabled = isDeleteAction && ( actionLabel.toLowerCase().includes('disabled') || actionLabel.toLowerCase().includes('no permission') || actionLabel.toLowerCase().includes('keine berechtigung') ); if (isConfirmingDelete) { return (
); } return ( ); })}
{formatCellValue(cellValue, column, row)}
)}
{/* Pagination */} {pagination && (
{showPageSizeSelector && (
)} {totalPages > 1 && ( <> {t('formgen.pagination.info') .replace('{page}', currentPage.toString()) .replace('{total}', totalPages.toString()) .replace('{count}', filteredData.length.toString())} )}
)}
); } export default FormGenerator;