Enhance webpack configuration for improved performance and development experience. Add filesystem caching and watch options. Update KreditorSelector to handle prefilled data and improve state management. Refactor TransactionsTable to manage focus during dialog interactions. Update admin tables to manage focus restoration and improve dialog handling. Implement IBAN filtering in IbanSelectionFilter and enhance document rendering with Kreditor information. Update SQL schema to allow multiple IBANs for the same Kreditor and adjust API routes for better data handling.

This commit is contained in:
sebseb7
2025-08-01 12:03:15 +02:00
parent 481f4db389
commit 6cde543938
17 changed files with 671 additions and 140 deletions

View File

@@ -70,9 +70,7 @@
setTimeout(() => {
const betragHeader = document.querySelector('.ag-header-cell[col-id="numericAmount"]');
if (betragHeader) {
console.log('Found Betrag header:', betragHeader);
console.log('Header classes:', betragHeader.className);
console.log('Header HTML:', betragHeader.innerHTML);
} else {
console.log('Could not find Betrag header with col-id="numericAmount"');
// Try to find it by text content

View File

@@ -41,12 +41,48 @@ class KreditorSelector extends Component {
componentDidMount() {
this.loadKreditors();
// If prefilled data is provided, set it in the newKreditor state
const updates = {};
if (this.props.prefilledIban) {
updates.iban = this.props.prefilledIban;
}
if (this.props.prefilledName) {
updates.name = this.props.prefilledName;
}
if (Object.keys(updates).length > 0) {
this.setState({
newKreditor: {
...this.state.newKreditor,
...updates
}
});
}
}
componentDidUpdate(prevProps) {
if (prevProps.selectedKreditorId !== this.props.selectedKreditorId) {
this.setState({ selectedKreditorId: this.props.selectedKreditorId || '' });
}
// If prefilled props change, update the newKreditor state
const updates = {};
if (prevProps.prefilledIban !== this.props.prefilledIban && this.props.prefilledIban) {
updates.iban = this.props.prefilledIban;
}
if (prevProps.prefilledName !== this.props.prefilledName && this.props.prefilledName) {
updates.name = this.props.prefilledName;
}
if (Object.keys(updates).length > 0) {
this.setState({
newKreditor: {
...this.state.newKreditor,
...updates
}
});
}
}
loadKreditors = async () => {
@@ -83,7 +119,11 @@ class KreditorSelector extends Component {
handleCreateDialogClose = () => {
this.setState({
createDialogOpen: false,
newKreditor: { iban: '', name: '', kreditorId: '' },
newKreditor: {
iban: this.props.prefilledIban || '',
name: this.props.prefilledName || '',
kreditorId: ''
},
validationErrors: [],
error: null
});
@@ -181,10 +221,12 @@ class KreditorSelector extends Component {
{kreditor.name} ({kreditor.kreditorId}) - {kreditor.iban}
</MenuItem>
))}
<MenuItem value="create_new" sx={{ color: 'primary.main', fontWeight: 'bold' }}>
<AddIcon sx={{ mr: 1 }} />
Neuen Kreditor erstellen
</MenuItem>
{(this.props.allowCreate !== false) && (
<MenuItem value="create_new" sx={{ color: 'primary.main', fontWeight: 'bold' }}>
<AddIcon sx={{ mr: 1 }} />
Neuen Kreditor erstellen
</MenuItem>
)}
</Select>
</FormControl>

View File

@@ -51,6 +51,31 @@ class TransactionsTable extends Component {
};
window.addEventListener('resize', this.handleResize);
// Add dialog open listener to blur grid focus
this.handleDialogOpen = () => {
if (this.gridApi) {
// Clear any focused cells to prevent aria-hidden conflicts
this.gridApi.clearFocusedCell();
// Also blur any focused elements within the grid
const gridElement = document.querySelector('.ag-root-wrapper');
if (gridElement) {
const focusedElement = gridElement.querySelector(':focus');
if (focusedElement) {
focusedElement.blur();
}
}
}
};
// Listen for dialog open events (Material-UI dialogs)
this.handleFocusIn = (event) => {
// If focus moves to a dialog, blur the grid
if (event.target.closest('[role="dialog"]')) {
this.handleDialogOpen();
}
};
document.addEventListener('focusin', this.handleFocusIn);
}
componentWillUnmount() {
@@ -59,7 +84,13 @@ class TransactionsTable extends Component {
window.removeEventListener('resize', this.handleResize);
}
if (this.gridApi) {
// Clean up dialog focus listener
if (this.handleFocusIn) {
document.removeEventListener('focusin', this.handleFocusIn);
}
// Check if grid API is still valid before removing listeners
if (this.gridApi && !this.gridApi.isDestroyed()) {
this.gridApi.removeEventListener('modelUpdated', this.onModelUpdated);
this.gridApi.removeEventListener('filterChanged', this.onFilterChanged);
}
@@ -317,7 +348,6 @@ class TransactionsTable extends Component {
animateRows={true}
// Maintain state across data updates
maintainColumnOrder={true}
suppressColumnStateEvents={false}
/>
</div>
</Box>

View File

@@ -44,6 +44,11 @@ class BUTable extends Component {
},
};
this.authService = new AuthService();
// Focus management refs
this.triggerRef = React.createRef();
this.dialogRef = React.createRef();
this.confirmDialogRef = React.createRef();
}
componentDidMount() {
@@ -66,6 +71,9 @@ class BUTable extends Component {
};
handleOpenDialog = (bu = null) => {
// Store reference to the trigger element for focus restoration
this.triggerRef.current = document.activeElement;
this.setState({
dialogOpen: true,
editingBU: bu,
@@ -91,6 +99,13 @@ class BUTable extends Component {
vst: '',
},
});
// Restore focus to the trigger element after dialog closes
setTimeout(() => {
if (this.triggerRef.current && this.triggerRef.current.focus) {
this.triggerRef.current.focus();
}
}, 100);
};
handleInputChange = (field) => (event) => {
@@ -145,6 +160,9 @@ class BUTable extends Component {
};
handleDeleteClick = (bu) => {
// Store reference to the trigger element for focus restoration
this.triggerRef.current = document.activeElement;
this.setState({
confirmDialogOpen: true,
itemToDelete: bu,
@@ -156,6 +174,13 @@ class BUTable extends Component {
if (!itemToDelete) return;
this.setState({ confirmDialogOpen: false, itemToDelete: null });
// Restore focus to the trigger element after dialog closes
setTimeout(() => {
if (this.triggerRef.current && this.triggerRef.current.focus) {
this.triggerRef.current.focus();
}
}, 100);
try {
const response = await this.authService.apiCall(`/admin/buchungsschluessel/${itemToDelete.id}`, {
@@ -179,6 +204,13 @@ class BUTable extends Component {
confirmDialogOpen: false,
itemToDelete: null,
});
// Restore focus to the trigger element after dialog closes
setTimeout(() => {
if (this.triggerRef.current && this.triggerRef.current.focus) {
this.triggerRef.current.focus();
}
}, 100);
};
render() {
@@ -250,11 +282,22 @@ class BUTable extends Component {
</Table>
</TableContainer>
<Dialog open={dialogOpen} onClose={this.handleCloseDialog} maxWidth="sm" fullWidth>
<DialogTitle>
<Dialog
open={dialogOpen}
onClose={this.handleCloseDialog}
maxWidth="sm"
fullWidth
ref={this.dialogRef}
disableAutoFocus={false}
disableEnforceFocus={false}
disableRestoreFocus={true}
aria-labelledby="bu-dialog-title"
aria-describedby="bu-dialog-content"
>
<DialogTitle id="bu-dialog-title">
{editingBU ? 'Buchungsschlüssel bearbeiten' : 'Neuer Buchungsschlüssel'}
</DialogTitle>
<DialogContent>
<DialogContent id="bu-dialog-content">
<TextField
autoFocus
margin="dense"
@@ -310,9 +353,15 @@ class BUTable extends Component {
onClose={this.handleDeleteCancel}
maxWidth="sm"
fullWidth
ref={this.confirmDialogRef}
disableAutoFocus={false}
disableEnforceFocus={false}
disableRestoreFocus={true}
aria-labelledby="confirm-dialog-title"
aria-describedby="confirm-dialog-content"
>
<DialogTitle>Löschen bestätigen</DialogTitle>
<DialogContent>
<DialogTitle id="confirm-dialog-title">Löschen bestätigen</DialogTitle>
<DialogContent id="confirm-dialog-content">
<Typography>
{this.state.itemToDelete &&
`Buchungsschlüssel "${this.state.itemToDelete.bu} - ${this.state.itemToDelete.name}" wirklich löschen?`

View File

@@ -43,6 +43,11 @@ class KontoTable extends Component {
},
};
this.authService = new AuthService();
// Focus management refs
this.triggerRef = React.createRef();
this.dialogRef = React.createRef();
this.confirmDialogRef = React.createRef();
}
componentDidMount() {
@@ -65,6 +70,9 @@ class KontoTable extends Component {
};
handleOpenDialog = (konto = null) => {
// Store reference to the trigger element for focus restoration
this.triggerRef.current = document.activeElement;
this.setState({
dialogOpen: true,
editingKonto: konto,
@@ -87,6 +95,13 @@ class KontoTable extends Component {
name: '',
},
});
// Restore focus to the trigger element after dialog closes
setTimeout(() => {
if (this.triggerRef.current && this.triggerRef.current.focus) {
this.triggerRef.current.focus();
}
}, 100);
};
handleInputChange = (field) => (event) => {
@@ -134,6 +149,9 @@ class KontoTable extends Component {
};
handleDeleteClick = (konto) => {
// Store reference to the trigger element for focus restoration
this.triggerRef.current = document.activeElement;
this.setState({
confirmDialogOpen: true,
itemToDelete: konto,
@@ -145,6 +163,13 @@ class KontoTable extends Component {
if (!itemToDelete) return;
this.setState({ confirmDialogOpen: false, itemToDelete: null });
// Restore focus to the trigger element after dialog closes
setTimeout(() => {
if (this.triggerRef.current && this.triggerRef.current.focus) {
this.triggerRef.current.focus();
}
}, 100);
try {
const response = await this.authService.apiCall(`/admin/konten/${konto.id}`, {
@@ -168,6 +193,13 @@ class KontoTable extends Component {
confirmDialogOpen: false,
itemToDelete: null,
});
// Restore focus to the trigger element after dialog closes
setTimeout(() => {
if (this.triggerRef.current && this.triggerRef.current.focus) {
this.triggerRef.current.focus();
}
}, 100);
};
render() {
@@ -235,11 +267,22 @@ class KontoTable extends Component {
</Table>
</TableContainer>
<Dialog open={dialogOpen} onClose={this.handleCloseDialog} maxWidth="sm" fullWidth>
<DialogTitle>
<Dialog
open={dialogOpen}
onClose={this.handleCloseDialog}
maxWidth="sm"
fullWidth
ref={this.dialogRef}
disableAutoFocus={false}
disableEnforceFocus={false}
disableRestoreFocus={true}
aria-labelledby="konto-dialog-title"
aria-describedby="konto-dialog-content"
>
<DialogTitle id="konto-dialog-title">
{editingKonto ? 'Konto bearbeiten' : 'Neues Konto'}
</DialogTitle>
<DialogContent>
<DialogContent id="konto-dialog-content">
<TextField
autoFocus
margin="dense"
@@ -279,9 +322,15 @@ class KontoTable extends Component {
onClose={this.handleDeleteCancel}
maxWidth="sm"
fullWidth
ref={this.confirmDialogRef}
disableAutoFocus={false}
disableEnforceFocus={false}
disableRestoreFocus={true}
aria-labelledby="konto-confirm-dialog-title"
aria-describedby="konto-confirm-dialog-content"
>
<DialogTitle>Löschen bestätigen</DialogTitle>
<DialogContent>
<DialogTitle id="konto-confirm-dialog-title">Löschen bestätigen</DialogTitle>
<DialogContent id="konto-confirm-dialog-content">
<Typography>
{this.state.itemToDelete &&
`Konto "${this.state.itemToDelete.konto} - ${this.state.itemToDelete.name}" wirklich löschen?`

View File

@@ -44,6 +44,11 @@ class KreditorTable extends Component {
},
};
this.authService = new AuthService();
// Focus management refs
this.triggerRef = React.createRef();
this.dialogRef = React.createRef();
this.confirmDialogRef = React.createRef();
}
componentDidMount() {
@@ -66,6 +71,9 @@ class KreditorTable extends Component {
};
handleOpenDialog = (kreditor = null) => {
// Store reference to the trigger element for focus restoration
this.triggerRef.current = document.activeElement;
this.setState({
dialogOpen: true,
editingKreditor: kreditor,
@@ -91,6 +99,13 @@ class KreditorTable extends Component {
kreditorId: '',
},
});
// Restore focus to the trigger element after dialog closes
setTimeout(() => {
if (this.triggerRef.current && this.triggerRef.current.focus) {
this.triggerRef.current.focus();
}
}, 100);
};
handleInputChange = (field) => (event) => {
@@ -139,6 +154,9 @@ class KreditorTable extends Component {
};
handleDeleteClick = (kreditor) => {
// Store reference to the trigger element for focus restoration
this.triggerRef.current = document.activeElement;
this.setState({
confirmDialogOpen: true,
itemToDelete: kreditor,
@@ -150,6 +168,13 @@ class KreditorTable extends Component {
if (!itemToDelete) return;
this.setState({ confirmDialogOpen: false, itemToDelete: null });
// Restore focus to the trigger element after dialog closes
setTimeout(() => {
if (this.triggerRef.current && this.triggerRef.current.focus) {
this.triggerRef.current.focus();
}
}, 100);
try {
const response = await this.authService.apiCall(`/admin/kreditoren/${kreditor.id}`, {
@@ -173,6 +198,13 @@ class KreditorTable extends Component {
confirmDialogOpen: false,
itemToDelete: null,
});
// Restore focus to the trigger element after dialog closes
setTimeout(() => {
if (this.triggerRef.current && this.triggerRef.current.focus) {
this.triggerRef.current.focus();
}
}, 100);
};
render() {
@@ -242,11 +274,22 @@ class KreditorTable extends Component {
</Table>
</TableContainer>
<Dialog open={dialogOpen} onClose={this.handleCloseDialog} maxWidth="sm" fullWidth>
<DialogTitle>
<Dialog
open={dialogOpen}
onClose={this.handleCloseDialog}
maxWidth="sm"
fullWidth
ref={this.dialogRef}
disableAutoFocus={false}
disableEnforceFocus={false}
disableRestoreFocus={true}
aria-labelledby="kreditor-dialog-title"
aria-describedby="kreditor-dialog-content"
>
<DialogTitle id="kreditor-dialog-title">
{editingKreditor ? 'Kreditor bearbeiten' : 'Neuer Kreditor'}
</DialogTitle>
<DialogContent>
<DialogContent id="kreditor-dialog-content">
<TextField
autoFocus
margin="dense"
@@ -293,9 +336,15 @@ class KreditorTable extends Component {
onClose={this.handleDeleteCancel}
maxWidth="sm"
fullWidth
ref={this.confirmDialogRef}
disableAutoFocus={false}
disableEnforceFocus={false}
disableRestoreFocus={true}
aria-labelledby="kreditor-confirm-dialog-title"
aria-describedby="kreditor-confirm-dialog-content"
>
<DialogTitle>Löschen bestätigen</DialogTitle>
<DialogContent>
<DialogTitle id="kreditor-confirm-dialog-title">Löschen bestätigen</DialogTitle>
<DialogContent id="kreditor-confirm-dialog-content">
<Typography>
{this.state.itemToDelete &&
`Kreditor "${this.state.itemToDelete.name}" wirklich löschen?`

View File

@@ -15,7 +15,9 @@ import {
Divider,
Tabs,
Tab,
Alert
Alert,
Chip,
Paper
} from '@mui/material';
import {
PictureAsPdf as PdfIcon,
@@ -35,6 +37,10 @@ const DocumentRenderer = (params) => {
const [tabValue, setTabValue] = useState(0);
const [error, setError] = useState(null);
// Focus management refs
const triggerRef = React.useRef(null);
const dialogRef = React.useRef(null);
// Always show something clickable, even if no documents
const hasDocuments = pdfs.length > 0 || links.length > 0;
@@ -47,6 +53,8 @@ const DocumentRenderer = (params) => {
const totalCount = allDocuments.length;
const handleClick = () => {
// Store reference to the trigger element for focus restoration
triggerRef.current = document.activeElement;
setDialogOpen(true);
};
@@ -54,6 +62,13 @@ const DocumentRenderer = (params) => {
setDialogOpen(false);
setTabValue(0); // Reset to first tab when closing
setError(null); // Clear any errors when closing
// Restore focus to the trigger element after dialog closes
setTimeout(() => {
if (triggerRef.current && triggerRef.current.focus) {
triggerRef.current.focus();
}
}, 100);
};
const handleTabChange = (event, newValue) => {
@@ -321,11 +336,22 @@ const DocumentRenderer = (params) => {
)}
</Box>
<Dialog open={dialogOpen} onClose={handleClose} maxWidth="lg" fullWidth>
<DialogTitle>
<Dialog
open={dialogOpen}
onClose={handleClose}
maxWidth="lg"
fullWidth
ref={dialogRef}
disableAutoFocus={false}
disableEnforceFocus={false}
disableRestoreFocus={true}
aria-labelledby="document-dialog-title"
aria-describedby="document-dialog-content"
>
<DialogTitle id="document-dialog-title">
{hasDocuments ? `Dokumente (${totalCount})` : 'Dokumentinformationen'}
</DialogTitle>
<DialogContent sx={{ p: 0 }}>
<DialogContent sx={{ p: 0 }} id="document-dialog-content">
{error && (
<Alert severity="error" sx={{ m: 2 }} onClose={() => setError(null)}>
{error}
@@ -335,6 +361,23 @@ const DocumentRenderer = (params) => {
<Tabs value={tabValue} onChange={handleTabChange}>
<Tab label="Dokumente" />
<Tab label={`Buchungen (${lineItems.length})`} />
<Tab
label="Kreditor"
sx={{
color: !params.data['Kontonummer/IBAN']
? 'text.secondary'
: params.data.hasKreditor
? 'success.main'
: 'warning.main',
'&.Mui-selected': {
color: !params.data['Kontonummer/IBAN']
? 'text.secondary'
: params.data.hasKreditor
? 'success.main'
: 'warning.main',
}
}}
/>
</Tabs>
</Box>
@@ -431,6 +474,108 @@ const DocumentRenderer = (params) => {
)}
</Box>
)}
{tabValue === 2 && (
<Box sx={{ p: 2 }}>
<Typography variant="h6" gutterBottom>
Kreditor Information
</Typography>
<Paper sx={{ p: 2, mb: 2 }}>
<Typography variant="subtitle2" gutterBottom>
IBAN
</Typography>
<Typography variant="body1" sx={{ mb: 2 }}>
{params.data['Kontonummer/IBAN'] || 'Keine IBAN verfügbar'}
</Typography>
{/* Show different content based on IBAN availability and Kreditor status */}
{!params.data['Kontonummer/IBAN'] ? (
<Box>
<Chip
label="Keine IBAN verfügbar"
color="default"
size="small"
sx={{ mb: 2 }}
/>
<Typography variant="body2" color="textSecondary">
Ohne IBAN kann kein Kreditor zugeordnet werden.
</Typography>
</Box>
) : params.data.hasKreditor ? (
<Box>
<Chip
label="Kreditor gefunden"
color="success"
size="small"
sx={{ mb: 2 }}
/>
<Box sx={{ mt: 2 }}>
<Typography variant="subtitle2" gutterBottom>
Kreditor Details
</Typography>
<Typography variant="body2">
<strong>Name:</strong> {params.data.kreditor.name}
</Typography>
<Typography variant="body2">
<strong>Kreditor ID:</strong> {params.data.kreditor.kreditorId}
</Typography>
</Box>
</Box>
) : (
<Box>
<Chip
label="Kein Kreditor gefunden"
color="warning"
size="small"
sx={{ mb: 2 }}
/>
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
Sie können einen neuen Kreditor für diese IBAN erstellen:
</Typography>
<KreditorSelector
selectedKreditorId=""
onKreditorChange={(kreditor) => {
console.log('Kreditor selected/created:', kreditor);
if (kreditor) {
// Update the transaction data to reflect the new kreditor
params.data.kreditor = kreditor;
params.data.hasKreditor = true;
// Update all transactions with the same IBAN in the grid
if (params.api && kreditor.iban) {
const nodesToRefresh = [];
params.api.forEachNode((node) => {
if (node.data && node.data['Kontonummer/IBAN'] === kreditor.iban) {
node.data.kreditor = kreditor;
node.data.hasKreditor = true;
nodesToRefresh.push(node);
}
});
// Refresh specific cells to show updated colors and data
if (nodesToRefresh.length > 0) {
params.api.refreshCells({
rowNodes: nodesToRefresh,
columns: ['Kontonummer/IBAN'],
force: true
});
}
}
// Close and reopen dialog to show updated status
setDialogOpen(false);
setTimeout(() => setDialogOpen(true), 100);
}
}}
prefilledIban={params.data['Kontonummer/IBAN']}
prefilledName={params.data['Beguenstigter/Zahlungspflichtiger']}
allowCreate={true}
/>
</Box>
)}
</Paper>
</Box>
)}
</DialogContent>
<DialogActions>
<Button onClick={handleClose}>Schließen</Button>

View File

@@ -21,17 +21,44 @@ const RecipientRenderer = (params) => {
}
};
// Determine color based on Kreditor status for IBAN column
const getIbanColor = () => {
if (!isIbanColumn || !value) return 'inherit';
// Check if this transaction has Kreditor information
if (params.data && params.data.hasKreditor) {
return '#2e7d32'; // Green for found Kreditor
} else if (params.data && value) {
return '#ed6c02'; // Orange for IBAN without Kreditor
}
return '#1976d2'; // Default blue for clickable IBAN
};
const getTitle = () => {
if (!isIbanColumn || !value) return undefined;
if (params.data && params.data.hasKreditor) {
return `IBAN "${value}" - Kreditor: ${params.data.kreditor?.name || 'Unbekannt'} (zum Filtern klicken)`;
} else if (params.data && value) {
return `IBAN "${value}" - Kein Kreditor gefunden (zum Filtern klicken)`;
}
return `Nach IBAN "${value}" filtern`;
};
return (
<span
style={{
fontSize: '0.7rem',
lineHeight: '1.2',
cursor: isIbanColumn && value ? 'pointer' : 'default',
color: isIbanColumn && value ? '#1976d2' : 'inherit',
textDecoration: isIbanColumn && value ? 'underline' : 'none'
color: getIbanColor(),
textDecoration: isIbanColumn && value ? 'underline' : 'none',
fontWeight: isIbanColumn && params.data && params.data.hasKreditor ? 'bold' : 'normal'
}}
onClick={isIbanColumn && value ? handleClick : undefined}
title={isIbanColumn && value ? `Nach IBAN "${value}" filtern` : undefined}
title={getTitle()}
>
{value}
</span>

View File

@@ -28,7 +28,7 @@ export const getColumnDefs = () => [
sortable: false,
filter: false,
resizable: false,
suppressMenu: true,
suppressHeaderMenuButton: true,
cellRenderer: SelectionRenderer,
headerComponent: SelectionHeader,
headerComponentParams: {
@@ -101,7 +101,6 @@ export const getColumnDefs = () => [
width: 70,
cellRenderer: TypeRenderer,
sortable: false,
suppressSorting: true,
filter: CheckboxFilter,
filterParams: {
filterOptions: [
@@ -138,7 +137,6 @@ export const getColumnDefs = () => [
width: 70,
cellRenderer: JtlRenderer,
sortable: false,
suppressSorting: true,
filter: CheckboxFilter,
filterParams: {
filterOptions: [
@@ -212,7 +210,10 @@ export const defaultColDef = {
export const gridOptions = {
animateRows: true,
rowSelection: false,
rowSelection: {
mode: 'multiRow',
enableClickSelection: false
},
rowBuffer: 10,
// Enable virtualization (default behavior)
suppressRowVirtualisation: false,
@@ -225,8 +226,7 @@ export const gridOptions = {
// Pagination (optional - can be removed for infinite scrolling)
pagination: false,
paginationPageSize: 100,
// Disable cell selection
suppressCellSelection: true,
suppressRowClickSelection: true,
// Disable cell selection and focus
cellSelection: false,
suppressCellFocus: true
};

View File

@@ -103,9 +103,13 @@ export default class CheckboxFilter {
};
destroy() {
if (this.reactRoot) {
this.reactRoot.unmount();
}
// Use setTimeout to avoid unmounting during render
setTimeout(() => {
if (this.reactRoot) {
this.reactRoot.unmount();
this.reactRoot = null;
}
}, 0);
}
renderReactComponent() {
@@ -172,10 +176,13 @@ export default class CheckboxFilter {
);
// Recreate React root every time to avoid state corruption
if (this.reactRoot) {
this.reactRoot.unmount();
}
this.reactRoot = createRoot(this.eGui);
this.reactRoot.render(<FilterComponent />);
// Use setTimeout to avoid unmounting during render
setTimeout(() => {
if (this.reactRoot) {
this.reactRoot.unmount();
}
this.reactRoot = createRoot(this.eGui);
this.reactRoot.render(<FilterComponent />);
}, 0);
}
}

View File

@@ -8,20 +8,27 @@ import {
ListItemText,
Box,
Chip,
Button
Button,
TextField,
Typography,
Divider
} from '@mui/material';
export default class IbanSelectionFilter {
constructor() {
this.state = {
selectedValues: [],
availableValues: []
availableValues: [],
partialIban: ''
};
// Create the DOM element that AG Grid expects
this.eGui = document.createElement('div');
this.eGui.style.minWidth = '250px';
this.eGui.style.padding = '8px';
// Create a ref for the text input
this.textInputRef = React.createRef();
}
init(params) {
@@ -35,10 +42,13 @@ export default class IbanSelectionFilter {
}
destroy() {
if (this.reactRoot) {
this.reactRoot.unmount();
this.reactRoot = null;
}
// Use setTimeout to avoid unmounting during render
setTimeout(() => {
if (this.reactRoot) {
this.reactRoot.unmount();
this.reactRoot = null;
}
}, 0);
}
updateAvailableValues() {
@@ -72,15 +82,27 @@ export default class IbanSelectionFilter {
}
isFilterActive() {
return this.state.selectedValues.length > 0;
return this.state.selectedValues.length > 0 || this.state.partialIban.trim() !== '';
}
doesFilterPass(params) {
const { selectedValues } = this.state;
if (selectedValues.length === 0) return true;
const { selectedValues, partialIban } = this.state;
const value = params.data['Kontonummer/IBAN'];
return selectedValues.includes(value);
// If no filters are active, show all rows
if (selectedValues.length === 0 && partialIban.trim() === '') {
return true;
}
// Check if row matches selected IBANs
const matchesSelected = selectedValues.length === 0 || selectedValues.includes(value);
// Check if row matches partial IBAN (case-insensitive)
const matchesPartial = partialIban.trim() === '' ||
(value && value.toLowerCase().includes(partialIban.toLowerCase()));
// Both conditions must be true (AND logic)
return matchesSelected && matchesPartial;
}
getModel() {
@@ -88,16 +110,28 @@ export default class IbanSelectionFilter {
return {
filterType: 'iban-selection',
values: this.state.selectedValues
values: this.state.selectedValues,
partialIban: this.state.partialIban
};
}
setModel(model) {
if (!model) {
this.state.selectedValues = [];
this.state.partialIban = '';
} else {
this.state.selectedValues = model.values || [];
this.state.partialIban = model.partialIban || '';
}
// Update the text field value directly if it exists
if (this.textInputRef.current) {
const inputElement = this.textInputRef.current.querySelector('input');
if (inputElement) {
inputElement.value = this.state.partialIban;
}
}
this.renderReactComponent();
}
@@ -111,8 +145,39 @@ export default class IbanSelectionFilter {
}
};
handlePartialIbanChange = (partialIban) => {
this.state.partialIban = partialIban;
// Update the clear button visibility without full re-render
this.updateClearButtonVisibility();
// Notify AG Grid that filter changed
if (this.params && this.params.filterChangedCallback) {
this.params.filterChangedCallback();
}
};
updateClearButtonVisibility = () => {
// Find the clear button container and update its visibility
const clearButtonContainer = this.eGui.querySelector('.clear-button-container');
if (clearButtonContainer) {
const shouldShow = this.state.selectedValues.length > 0 || this.state.partialIban.trim() !== '';
clearButtonContainer.style.display = shouldShow ? 'block' : 'none';
}
};
clearFilter = () => {
this.state.selectedValues = [];
this.state.partialIban = '';
// Clear the text field directly using the ref
if (this.textInputRef.current) {
const inputElement = this.textInputRef.current.querySelector('input');
if (inputElement) {
inputElement.value = '';
}
}
this.renderReactComponent();
if (this.params && this.params.filterChangedCallback) {
@@ -127,6 +192,27 @@ export default class IbanSelectionFilter {
const FilterComponent = () => (
<Box sx={{ minWidth: 250 }} className="ag-filter-custom">
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 'bold' }}>
IBAN Filter
</Typography>
<TextField
ref={this.textInputRef}
fullWidth
size="small"
label="IBAN eingeben"
placeholder="z.B. DE89, 1234..."
defaultValue={this.state.partialIban}
onChange={(event) => this.handlePartialIbanChange(event.target.value)}
sx={{ mb: 2 }}
/>
<Divider sx={{ mb: 2 }} />
<Typography variant="body2" sx={{ mb: 1, color: 'text.secondary' }}>
Oder aus der Liste auswählen:
</Typography>
<FormControl fullWidth size="small">
<Select
multiple
@@ -138,7 +224,7 @@ export default class IbanSelectionFilter {
return <em>Alle IBANs</em>;
}
return (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{selected.slice(0, 2).map((value) => {
const ibanData = this.availableValues.find(item => item.iban === value);
return (
@@ -170,7 +256,7 @@ export default class IbanSelectionFilter {
},
},
}}
>
>
{this.availableValues.map((item) => (
<MenuItem key={item.iban} value={item.iban}>
<Checkbox
@@ -188,19 +274,21 @@ export default class IbanSelectionFilter {
</Select>
</FormControl>
{this.state.selectedValues.length > 0 && (
<Box sx={{ mt: 1, textAlign: 'right' }}>
<Button
onClick={this.clearFilter}
size="small"
variant="text"
color="primary"
sx={{ fontSize: '0.75rem' }}
>
Filter löschen
</Button>
</Box>
)}
<Box
className="clear-button-container"
sx={{ mt: 1, textAlign: 'right' }}
style={{ display: (this.state.selectedValues.length > 0 || this.state.partialIban.trim() !== '') ? 'block' : 'none' }}
>
<Button
onClick={this.clearFilter}
size="small"
variant="text"
color="primary"
sx={{ fontSize: '0.75rem' }}
>
Alle Filter löschen
</Button>
</Box>
</Box>
);

View File

@@ -18,7 +18,10 @@ const SortHeader = (params) => {
if (params.api) {
params.api.addEventListener('sortChanged', updateSortState);
return () => {
params.api.removeEventListener('sortChanged', updateSortState);
// Check if grid API is still valid before removing listener
if (params.api && !params.api.isDestroyed()) {
params.api.removeEventListener('sortChanged', updateSortState);
}
};
}
}, [params.api, params.column]);

View File

@@ -36,8 +36,8 @@ class HeaderComponent extends Component {
}
componentWillUnmount() {
// Clean up event listener
if (this.props.params && this.props.params.api) {
// Clean up event listener - check if grid API is still valid
if (this.props.params && this.props.params.api && !this.props.params.api.isDestroyed()) {
this.props.params.api.removeEventListener('filterChanged', this.onFilterChanged);
}
}
@@ -147,7 +147,7 @@ class HeaderComponent extends Component {
const showTextFilter = isTextColumn;
// Check if sorting is disabled for this column
const isSortingDisabled = column.colDef.sortable === false || column.colDef.suppressSorting === true;
const isSortingDisabled = column.colDef.sortable === false;
return (
<Box sx={{
@@ -297,11 +297,12 @@ export default class TextHeaderWithFilter {
// Listen for menu close events to keep state in sync
if (params.api) {
params.api.addEventListener('popupMenuVisibleChanged', (event) => {
this.popupMenuListener = (event) => {
if (!event.visible && this.headerComponent) {
this.headerComponent.setState({ menuOpen: false });
}
});
};
params.api.addEventListener('popupMenuVisibleChanged', this.popupMenuListener);
}
// Render React component into the DOM element
@@ -313,6 +314,11 @@ export default class TextHeaderWithFilter {
}
destroy() {
// Clean up event listener if grid API is still valid
if (this.params && this.params.api && !this.params.api.isDestroyed() && this.popupMenuListener) {
this.params.api.removeEventListener('popupMenuVisibleChanged', this.popupMenuListener);
}
// Use setTimeout to avoid unmounting during render
setTimeout(() => {
if (this.reactRoot) {