frontend_nyla/src/components/FormGenerator/FormGenerator.tsx
2025-08-21 18:09:19 +02:00

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;