diff --git a/client/public/index.html b/client/public/index.html index 075fc78..32879a4 100644 --- a/client/public/index.html +++ b/client/public/index.html @@ -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 diff --git a/client/src/components/KreditorSelector.js b/client/src/components/KreditorSelector.js index 5f635b2..5b6a37d 100644 --- a/client/src/components/KreditorSelector.js +++ b/client/src/components/KreditorSelector.js @@ -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} ))} - - - Neuen Kreditor erstellen - + {(this.props.allowCreate !== false) && ( + + + Neuen Kreditor erstellen + + )} diff --git a/client/src/components/TransactionsTable.js b/client/src/components/TransactionsTable.js index dd3ba74..e2bb612 100644 --- a/client/src/components/TransactionsTable.js +++ b/client/src/components/TransactionsTable.js @@ -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} /> diff --git a/client/src/components/admin/BUTable.js b/client/src/components/admin/BUTable.js index aaf91e3..01924cb 100644 --- a/client/src/components/admin/BUTable.js +++ b/client/src/components/admin/BUTable.js @@ -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 { - - + + {editingBU ? 'Buchungsschlüssel bearbeiten' : 'Neuer Buchungsschlüssel'} - + - Löschen bestätigen - + Löschen bestätigen + {this.state.itemToDelete && `Buchungsschlüssel "${this.state.itemToDelete.bu} - ${this.state.itemToDelete.name}" wirklich löschen?` diff --git a/client/src/components/admin/KontoTable.js b/client/src/components/admin/KontoTable.js index 176c1c1..074b276 100644 --- a/client/src/components/admin/KontoTable.js +++ b/client/src/components/admin/KontoTable.js @@ -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 { - - + + {editingKonto ? 'Konto bearbeiten' : 'Neues Konto'} - + - Löschen bestätigen - + Löschen bestätigen + {this.state.itemToDelete && `Konto "${this.state.itemToDelete.konto} - ${this.state.itemToDelete.name}" wirklich löschen?` diff --git a/client/src/components/admin/KreditorTable.js b/client/src/components/admin/KreditorTable.js index 2d19722..ad5225e 100644 --- a/client/src/components/admin/KreditorTable.js +++ b/client/src/components/admin/KreditorTable.js @@ -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 { - - + + {editingKreditor ? 'Kreditor bearbeiten' : 'Neuer Kreditor'} - + - Löschen bestätigen - + Löschen bestätigen + {this.state.itemToDelete && `Kreditor "${this.state.itemToDelete.name}" wirklich löschen?` diff --git a/client/src/components/cellRenderers/DocumentRenderer.js b/client/src/components/cellRenderers/DocumentRenderer.js index 64e58ef..8da44ee 100644 --- a/client/src/components/cellRenderers/DocumentRenderer.js +++ b/client/src/components/cellRenderers/DocumentRenderer.js @@ -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) => { )} - - + + {hasDocuments ? `Dokumente (${totalCount})` : 'Dokumentinformationen'} - + {error && ( setError(null)}> {error} @@ -335,6 +361,23 @@ const DocumentRenderer = (params) => { + @@ -431,6 +474,108 @@ const DocumentRenderer = (params) => { )} )} + + {tabValue === 2 && ( + + + Kreditor Information + + + + + IBAN + + + {params.data['Kontonummer/IBAN'] || 'Keine IBAN verfügbar'} + + + {/* Show different content based on IBAN availability and Kreditor status */} + {!params.data['Kontonummer/IBAN'] ? ( + + + + Ohne IBAN kann kein Kreditor zugeordnet werden. + + + ) : params.data.hasKreditor ? ( + + + + + Kreditor Details + + + Name: {params.data.kreditor.name} + + + Kreditor ID: {params.data.kreditor.kreditorId} + + + + ) : ( + + + + Sie können einen neuen Kreditor für diese IBAN erstellen: + + { + 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} + /> + + )} + + + )} diff --git a/client/src/components/cellRenderers/RecipientRenderer.js b/client/src/components/cellRenderers/RecipientRenderer.js index 9227860..e9f53f5 100644 --- a/client/src/components/cellRenderers/RecipientRenderer.js +++ b/client/src/components/cellRenderers/RecipientRenderer.js @@ -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 ( {value} diff --git a/client/src/components/config/gridConfig.js b/client/src/components/config/gridConfig.js index 6d76a87..78c593d 100644 --- a/client/src/components/config/gridConfig.js +++ b/client/src/components/config/gridConfig.js @@ -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 }; \ No newline at end of file diff --git a/client/src/components/filters/CheckboxFilter.js b/client/src/components/filters/CheckboxFilter.js index 1dc47cd..2c11b48 100644 --- a/client/src/components/filters/CheckboxFilter.js +++ b/client/src/components/filters/CheckboxFilter.js @@ -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(); + // Use setTimeout to avoid unmounting during render + setTimeout(() => { + if (this.reactRoot) { + this.reactRoot.unmount(); + } + this.reactRoot = createRoot(this.eGui); + this.reactRoot.render(); + }, 0); } } \ No newline at end of file diff --git a/client/src/components/filters/IbanSelectionFilter.js b/client/src/components/filters/IbanSelectionFilter.js index c1138bb..ed475ad 100644 --- a/client/src/components/filters/IbanSelectionFilter.js +++ b/client/src/components/filters/IbanSelectionFilter.js @@ -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 = () => ( + + IBAN Filter + + + this.handlePartialIbanChange(event.target.value)} + sx={{ mb: 2 }} + /> + + + + + Oder aus der Liste auswählen: + +