Compare commits

...

3 Commits

20 changed files with 764 additions and 176 deletions

View File

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

View File

@@ -1,10 +1,11 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import { ThemeProvider, createTheme } from '@mui/material/styles'; import { ThemeProvider, createTheme } from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline'; import CssBaseline from '@mui/material/CssBaseline';
import { Container, AppBar, Toolbar, Typography, Button, Box } from '@mui/material'; import { Container, AppBar, Toolbar, Typography, Button, Box, Tabs, Tab } from '@mui/material';
import LoginIcon from '@mui/icons-material/Login'; import LoginIcon from '@mui/icons-material/Login';
import DashboardIcon from '@mui/icons-material/Dashboard'; import DashboardIcon from '@mui/icons-material/Dashboard';
import DownloadIcon from '@mui/icons-material/Download'; import DownloadIcon from '@mui/icons-material/Download';
import TableChart from '@mui/icons-material/TableChart';
import AuthService from './services/AuthService'; import AuthService from './services/AuthService';
import DataViewer from './components/DataViewer'; import DataViewer from './components/DataViewer';
import Login from './components/Login'; import Login from './components/Login';
@@ -29,6 +30,7 @@ class App extends Component {
user: null, user: null,
loading: true, loading: true,
exportData: null, // { selectedMonth, canExport, onExport } exportData: null, // { selectedMonth, canExport, onExport }
currentView: 'dashboard', // 'dashboard' or 'tables'
}; };
this.authService = new AuthService(); this.authService = new AuthService();
} }
@@ -77,8 +79,12 @@ class App extends Component {
this.setState({ exportData }); this.setState({ exportData });
}; };
handleViewChange = (event, newValue) => {
this.setState({ currentView: newValue });
};
render() { render() {
const { isAuthenticated, user, loading } = this.state; const { isAuthenticated, user, loading, currentView } = this.state;
if (loading) { if (loading) {
return ( return (
@@ -99,7 +105,7 @@ class App extends Component {
<AppBar position="static"> <AppBar position="static">
<Toolbar> <Toolbar>
<DashboardIcon sx={{ mr: { xs: 1, sm: 2 } }} /> <DashboardIcon sx={{ mr: { xs: 1, sm: 2 } }} />
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}> <Typography variant="h6" component="div" sx={{ mr: 3 }}>
FibDash FibDash
</Typography> </Typography>
{isAuthenticated && user && ( {isAuthenticated && user && (
@@ -107,12 +113,42 @@ class App extends Component {
<Typography <Typography
variant="body2" variant="body2"
sx={{ sx={{
mr: { xs: 1, sm: 2 }, mr: { xs: 2, sm: 3 },
display: { xs: 'none', sm: 'block' } display: { xs: 'none', sm: 'block' }
}} }}
> >
Willkommen, {user.name} Willkommen, {user.name}
</Typography> </Typography>
<Tabs
value={currentView}
onChange={this.handleViewChange}
sx={{
mr: 'auto',
'& .MuiTab-root': {
color: 'rgba(255, 255, 255, 0.7)',
minHeight: 48,
'&.Mui-selected': {
color: 'white'
}
},
'& .MuiTabs-indicator': {
backgroundColor: 'white'
}
}}
>
<Tab
icon={<DashboardIcon />}
label="Dashboard"
value="dashboard"
sx={{ minHeight: 48 }}
/>
<Tab
icon={<TableChart />}
label="Stammdaten"
value="tables"
sx={{ minHeight: 48 }}
/>
</Tabs>
{this.state.exportData && ( {this.state.exportData && (
<Button <Button
color="inherit" color="inherit"
@@ -153,7 +189,12 @@ class App extends Component {
<Box sx={{ height: 'calc(100vh - 64px)', display: 'flex', flexDirection: 'column' }}> <Box sx={{ height: 'calc(100vh - 64px)', display: 'flex', flexDirection: 'column' }}>
<Container maxWidth={false} sx={{ mt: 4, flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', width: '100%' }}> <Container maxWidth={false} sx={{ mt: 4, flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', width: '100%' }}>
{isAuthenticated ? ( {isAuthenticated ? (
<DataViewer user={user} onUpdateExportData={this.updateExportData} /> <DataViewer
user={user}
onUpdateExportData={this.updateExportData}
currentView={currentView}
onViewChange={this.handleViewChange}
/>
) : ( ) : (
<Login onLogin={this.handleLogin} /> <Login onLogin={this.handleLogin} />
)} )}

View File

@@ -7,7 +7,6 @@ import {
import AuthService from '../services/AuthService'; import AuthService from '../services/AuthService';
import SummaryHeader from './SummaryHeader'; import SummaryHeader from './SummaryHeader';
import TransactionsTable from './TransactionsTable'; import TransactionsTable from './TransactionsTable';
import Navigation from './Navigation';
import Dashboard from './Dashboard'; import Dashboard from './Dashboard';
import TableManagement from './TableManagement'; import TableManagement from './TableManagement';
@@ -21,7 +20,7 @@ class DataViewer extends Component {
summary: null, summary: null,
loading: true, loading: true,
error: null, error: null,
currentView: 'dashboard', // 'dashboard' or 'tables'
}; };
this.authService = new AuthService(); this.authService = new AuthService();
} }
@@ -114,21 +113,17 @@ class DataViewer extends Component {
if (this.props.onUpdateExportData) { if (this.props.onUpdateExportData) {
this.props.onUpdateExportData({ this.props.onUpdateExportData({
selectedMonth, selectedMonth,
canExport: !!selectedMonth && !this.state.loading && this.state.currentView === 'dashboard', canExport: !!selectedMonth && !this.state.loading && this.props.currentView === 'dashboard',
onExport: this.downloadDatev onExport: this.downloadDatev
}); });
} }
}; };
handleViewChange = (event, newView) => {
this.setState({ currentView: newView });
// Update export data when view changes
this.updateExportData();
};
render() { render() {
const { months, selectedMonth, transactions, summary, loading, error, currentView } = this.state; const { months, selectedMonth, transactions, summary, loading, error } = this.state;
const { user } = this.props; const { user, currentView } = this.props;
if (loading && !transactions.length) { if (loading && !transactions.length) {
return ( return (
@@ -148,11 +143,6 @@ class DataViewer extends Component {
return ( return (
<Box sx={{ height: '100vh', display: 'flex', flexDirection: 'column' }}> <Box sx={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>
<Navigation
currentView={currentView}
onViewChange={this.handleViewChange}
/>
{currentView === 'dashboard' ? ( {currentView === 'dashboard' ? (
<> <>
<Box sx={{ flexShrink: 0 }}> <Box sx={{ flexShrink: 0 }}>

View File

@@ -41,12 +41,48 @@ class KreditorSelector extends Component {
componentDidMount() { componentDidMount() {
this.loadKreditors(); 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) { componentDidUpdate(prevProps) {
if (prevProps.selectedKreditorId !== this.props.selectedKreditorId) { if (prevProps.selectedKreditorId !== this.props.selectedKreditorId) {
this.setState({ 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 () => { loadKreditors = async () => {
@@ -83,7 +119,11 @@ class KreditorSelector extends Component {
handleCreateDialogClose = () => { handleCreateDialogClose = () => {
this.setState({ this.setState({
createDialogOpen: false, createDialogOpen: false,
newKreditor: { iban: '', name: '', kreditorId: '' }, newKreditor: {
iban: this.props.prefilledIban || '',
name: this.props.prefilledName || '',
kreditorId: ''
},
validationErrors: [], validationErrors: [],
error: null error: null
}); });
@@ -181,10 +221,12 @@ class KreditorSelector extends Component {
{kreditor.name} ({kreditor.kreditorId}) - {kreditor.iban} {kreditor.name} ({kreditor.kreditorId}) - {kreditor.iban}
</MenuItem> </MenuItem>
))} ))}
<MenuItem value="create_new" sx={{ color: 'primary.main', fontWeight: 'bold' }}> {(this.props.allowCreate !== false) && (
<AddIcon sx={{ mr: 1 }} /> <MenuItem value="create_new" sx={{ color: 'primary.main', fontWeight: 'bold' }}>
Neuen Kreditor erstellen <AddIcon sx={{ mr: 1 }} />
</MenuItem> Neuen Kreditor erstellen
</MenuItem>
)}
</Select> </Select>
</FormControl> </FormControl>

View File

@@ -51,6 +51,31 @@ class TransactionsTable extends Component {
}; };
window.addEventListener('resize', this.handleResize); 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() { componentWillUnmount() {
@@ -59,7 +84,13 @@ class TransactionsTable extends Component {
window.removeEventListener('resize', this.handleResize); 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('modelUpdated', this.onModelUpdated);
this.gridApi.removeEventListener('filterChanged', this.onFilterChanged); this.gridApi.removeEventListener('filterChanged', this.onFilterChanged);
} }
@@ -317,7 +348,6 @@ class TransactionsTable extends Component {
animateRows={true} animateRows={true}
// Maintain state across data updates // Maintain state across data updates
maintainColumnOrder={true} maintainColumnOrder={true}
suppressColumnStateEvents={false}
/> />
</div> </div>
</Box> </Box>

View File

@@ -44,6 +44,11 @@ class BUTable extends Component {
}, },
}; };
this.authService = new AuthService(); this.authService = new AuthService();
// Focus management refs
this.triggerRef = React.createRef();
this.dialogRef = React.createRef();
this.confirmDialogRef = React.createRef();
} }
componentDidMount() { componentDidMount() {
@@ -66,13 +71,16 @@ class BUTable extends Component {
}; };
handleOpenDialog = (bu = null) => { handleOpenDialog = (bu = null) => {
// Store reference to the trigger element for focus restoration
this.triggerRef.current = document.activeElement;
this.setState({ this.setState({
dialogOpen: true, dialogOpen: true,
editingBU: bu, editingBU: bu,
formData: bu ? { formData: bu ? {
bu: bu.bu, bu: bu.bu,
name: bu.name, name: bu.name,
vst: bu.vst || '', vst: bu.vst !== null && bu.vst !== undefined ? bu.vst.toString() : '',
} : { } : {
bu: '', bu: '',
name: '', name: '',
@@ -91,6 +99,13 @@ class BUTable extends Component {
vst: '', 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) => { handleInputChange = (field) => (event) => {
@@ -102,13 +117,20 @@ class BUTable extends Component {
}); });
}; };
isFormValid = () => {
const { formData } = this.state;
return formData.bu.trim() !== '' &&
formData.name.trim() !== '' &&
formData.vst !== '';
};
handleSave = async () => { handleSave = async () => {
const { editingBU, formData } = this.state; const { editingBU, formData } = this.state;
// Convert vst to number or null // Convert vst to number or null
const payload = { const payload = {
...formData, ...formData,
vst: formData.vst ? parseFloat(formData.vst) : null, vst: formData.vst !== '' ? parseFloat(formData.vst) : null,
}; };
try { try {
@@ -138,6 +160,9 @@ class BUTable extends Component {
}; };
handleDeleteClick = (bu) => { handleDeleteClick = (bu) => {
// Store reference to the trigger element for focus restoration
this.triggerRef.current = document.activeElement;
this.setState({ this.setState({
confirmDialogOpen: true, confirmDialogOpen: true,
itemToDelete: bu, itemToDelete: bu,
@@ -150,6 +175,13 @@ class BUTable extends Component {
this.setState({ confirmDialogOpen: false, itemToDelete: null }); 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 { try {
const response = await this.authService.apiCall(`/admin/buchungsschluessel/${itemToDelete.id}`, { const response = await this.authService.apiCall(`/admin/buchungsschluessel/${itemToDelete.id}`, {
method: 'DELETE', method: 'DELETE',
@@ -172,6 +204,13 @@ class BUTable extends Component {
confirmDialogOpen: false, confirmDialogOpen: false,
itemToDelete: null, 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() { render() {
@@ -220,7 +259,7 @@ class BUTable extends Component {
<TableCell>{bu.bu}</TableCell> <TableCell>{bu.bu}</TableCell>
<TableCell>{bu.name}</TableCell> <TableCell>{bu.name}</TableCell>
<TableCell align="right"> <TableCell align="right">
{bu.vst ? `${bu.vst}%` : '-'} {bu.vst !== null && bu.vst !== undefined ? `${bu.vst}%` : '-'}
</TableCell> </TableCell>
<TableCell align="right"> <TableCell align="right">
<IconButton <IconButton
@@ -243,11 +282,22 @@ class BUTable extends Component {
</Table> </Table>
</TableContainer> </TableContainer>
<Dialog open={dialogOpen} onClose={this.handleCloseDialog} maxWidth="sm" fullWidth> <Dialog
<DialogTitle> 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'} {editingBU ? 'Buchungsschlüssel bearbeiten' : 'Neuer Buchungsschlüssel'}
</DialogTitle> </DialogTitle>
<DialogContent> <DialogContent id="bu-dialog-content">
<TextField <TextField
autoFocus autoFocus
margin="dense" margin="dense"
@@ -287,7 +337,11 @@ class BUTable extends Component {
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={this.handleCloseDialog}>Abbrechen</Button> <Button onClick={this.handleCloseDialog}>Abbrechen</Button>
<Button onClick={this.handleSave} variant="contained"> <Button
onClick={this.handleSave}
variant="contained"
disabled={!this.isFormValid()}
>
Speichern Speichern
</Button> </Button>
</DialogActions> </DialogActions>
@@ -299,9 +353,15 @@ class BUTable extends Component {
onClose={this.handleDeleteCancel} onClose={this.handleDeleteCancel}
maxWidth="sm" maxWidth="sm"
fullWidth 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> <DialogTitle id="confirm-dialog-title">Löschen bestätigen</DialogTitle>
<DialogContent> <DialogContent id="confirm-dialog-content">
<Typography> <Typography>
{this.state.itemToDelete && {this.state.itemToDelete &&
`Buchungsschlüssel "${this.state.itemToDelete.bu} - ${this.state.itemToDelete.name}" wirklich löschen?` `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(); this.authService = new AuthService();
// Focus management refs
this.triggerRef = React.createRef();
this.dialogRef = React.createRef();
this.confirmDialogRef = React.createRef();
} }
componentDidMount() { componentDidMount() {
@@ -65,6 +70,9 @@ class KontoTable extends Component {
}; };
handleOpenDialog = (konto = null) => { handleOpenDialog = (konto = null) => {
// Store reference to the trigger element for focus restoration
this.triggerRef.current = document.activeElement;
this.setState({ this.setState({
dialogOpen: true, dialogOpen: true,
editingKonto: konto, editingKonto: konto,
@@ -87,6 +95,13 @@ class KontoTable extends Component {
name: '', 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) => { handleInputChange = (field) => (event) => {
@@ -98,6 +113,12 @@ class KontoTable extends Component {
}); });
}; };
isFormValid = () => {
const { formData } = this.state;
return formData.konto.trim() !== '' &&
formData.name.trim() !== '';
};
handleSave = async () => { handleSave = async () => {
const { editingKonto, formData } = this.state; const { editingKonto, formData } = this.state;
@@ -128,6 +149,9 @@ class KontoTable extends Component {
}; };
handleDeleteClick = (konto) => { handleDeleteClick = (konto) => {
// Store reference to the trigger element for focus restoration
this.triggerRef.current = document.activeElement;
this.setState({ this.setState({
confirmDialogOpen: true, confirmDialogOpen: true,
itemToDelete: konto, itemToDelete: konto,
@@ -140,6 +164,13 @@ class KontoTable extends Component {
this.setState({ confirmDialogOpen: false, itemToDelete: null }); 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 { try {
const response = await this.authService.apiCall(`/admin/konten/${konto.id}`, { const response = await this.authService.apiCall(`/admin/konten/${konto.id}`, {
method: 'DELETE', method: 'DELETE',
@@ -162,6 +193,13 @@ class KontoTable extends Component {
confirmDialogOpen: false, confirmDialogOpen: false,
itemToDelete: null, 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() { render() {
@@ -229,11 +267,22 @@ class KontoTable extends Component {
</Table> </Table>
</TableContainer> </TableContainer>
<Dialog open={dialogOpen} onClose={this.handleCloseDialog} maxWidth="sm" fullWidth> <Dialog
<DialogTitle> 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'} {editingKonto ? 'Konto bearbeiten' : 'Neues Konto'}
</DialogTitle> </DialogTitle>
<DialogContent> <DialogContent id="konto-dialog-content">
<TextField <TextField
autoFocus autoFocus
margin="dense" margin="dense"
@@ -257,7 +306,11 @@ class KontoTable extends Component {
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={this.handleCloseDialog}>Abbrechen</Button> <Button onClick={this.handleCloseDialog}>Abbrechen</Button>
<Button onClick={this.handleSave} variant="contained"> <Button
onClick={this.handleSave}
variant="contained"
disabled={!this.isFormValid()}
>
Speichern Speichern
</Button> </Button>
</DialogActions> </DialogActions>
@@ -269,9 +322,15 @@ class KontoTable extends Component {
onClose={this.handleDeleteCancel} onClose={this.handleDeleteCancel}
maxWidth="sm" maxWidth="sm"
fullWidth 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> <DialogTitle id="konto-confirm-dialog-title">Löschen bestätigen</DialogTitle>
<DialogContent> <DialogContent id="konto-confirm-dialog-content">
<Typography> <Typography>
{this.state.itemToDelete && {this.state.itemToDelete &&
`Konto "${this.state.itemToDelete.konto} - ${this.state.itemToDelete.name}" wirklich löschen?` `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(); this.authService = new AuthService();
// Focus management refs
this.triggerRef = React.createRef();
this.dialogRef = React.createRef();
this.confirmDialogRef = React.createRef();
} }
componentDidMount() { componentDidMount() {
@@ -66,6 +71,9 @@ class KreditorTable extends Component {
}; };
handleOpenDialog = (kreditor = null) => { handleOpenDialog = (kreditor = null) => {
// Store reference to the trigger element for focus restoration
this.triggerRef.current = document.activeElement;
this.setState({ this.setState({
dialogOpen: true, dialogOpen: true,
editingKreditor: kreditor, editingKreditor: kreditor,
@@ -91,6 +99,13 @@ class KreditorTable extends Component {
kreditorId: '', 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) => { handleInputChange = (field) => (event) => {
@@ -102,6 +117,13 @@ class KreditorTable extends Component {
}); });
}; };
isFormValid = () => {
const { formData } = this.state;
return formData.iban.trim() !== '' &&
formData.name.trim() !== '' &&
formData.kreditorId.trim() !== '';
};
handleSave = async () => { handleSave = async () => {
const { editingKreditor, formData } = this.state; const { editingKreditor, formData } = this.state;
@@ -132,6 +154,9 @@ class KreditorTable extends Component {
}; };
handleDeleteClick = (kreditor) => { handleDeleteClick = (kreditor) => {
// Store reference to the trigger element for focus restoration
this.triggerRef.current = document.activeElement;
this.setState({ this.setState({
confirmDialogOpen: true, confirmDialogOpen: true,
itemToDelete: kreditor, itemToDelete: kreditor,
@@ -144,6 +169,13 @@ class KreditorTable extends Component {
this.setState({ confirmDialogOpen: false, itemToDelete: null }); 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 { try {
const response = await this.authService.apiCall(`/admin/kreditoren/${kreditor.id}`, { const response = await this.authService.apiCall(`/admin/kreditoren/${kreditor.id}`, {
method: 'DELETE', method: 'DELETE',
@@ -166,6 +198,13 @@ class KreditorTable extends Component {
confirmDialogOpen: false, confirmDialogOpen: false,
itemToDelete: null, 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() { render() {
@@ -235,11 +274,22 @@ class KreditorTable extends Component {
</Table> </Table>
</TableContainer> </TableContainer>
<Dialog open={dialogOpen} onClose={this.handleCloseDialog} maxWidth="sm" fullWidth> <Dialog
<DialogTitle> 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'} {editingKreditor ? 'Kreditor bearbeiten' : 'Neuer Kreditor'}
</DialogTitle> </DialogTitle>
<DialogContent> <DialogContent id="kreditor-dialog-content">
<TextField <TextField
autoFocus autoFocus
margin="dense" margin="dense"
@@ -270,7 +320,11 @@ class KreditorTable extends Component {
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={this.handleCloseDialog}>Abbrechen</Button> <Button onClick={this.handleCloseDialog}>Abbrechen</Button>
<Button onClick={this.handleSave} variant="contained"> <Button
onClick={this.handleSave}
variant="contained"
disabled={!this.isFormValid()}
>
Speichern Speichern
</Button> </Button>
</DialogActions> </DialogActions>
@@ -282,9 +336,15 @@ class KreditorTable extends Component {
onClose={this.handleDeleteCancel} onClose={this.handleDeleteCancel}
maxWidth="sm" maxWidth="sm"
fullWidth 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> <DialogTitle id="kreditor-confirm-dialog-title">Löschen bestätigen</DialogTitle>
<DialogContent> <DialogContent id="kreditor-confirm-dialog-content">
<Typography> <Typography>
{this.state.itemToDelete && {this.state.itemToDelete &&
`Kreditor "${this.state.itemToDelete.name}" wirklich löschen?` `Kreditor "${this.state.itemToDelete.name}" wirklich löschen?`

View File

@@ -15,7 +15,9 @@ import {
Divider, Divider,
Tabs, Tabs,
Tab, Tab,
Alert Alert,
Chip,
Paper
} from '@mui/material'; } from '@mui/material';
import { import {
PictureAsPdf as PdfIcon, PictureAsPdf as PdfIcon,
@@ -35,6 +37,10 @@ const DocumentRenderer = (params) => {
const [tabValue, setTabValue] = useState(0); const [tabValue, setTabValue] = useState(0);
const [error, setError] = useState(null); 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 // Always show something clickable, even if no documents
const hasDocuments = pdfs.length > 0 || links.length > 0; const hasDocuments = pdfs.length > 0 || links.length > 0;
@@ -47,6 +53,8 @@ const DocumentRenderer = (params) => {
const totalCount = allDocuments.length; const totalCount = allDocuments.length;
const handleClick = () => { const handleClick = () => {
// Store reference to the trigger element for focus restoration
triggerRef.current = document.activeElement;
setDialogOpen(true); setDialogOpen(true);
}; };
@@ -54,6 +62,13 @@ const DocumentRenderer = (params) => {
setDialogOpen(false); setDialogOpen(false);
setTabValue(0); // Reset to first tab when closing setTabValue(0); // Reset to first tab when closing
setError(null); // Clear any errors 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) => { const handleTabChange = (event, newValue) => {
@@ -321,11 +336,22 @@ const DocumentRenderer = (params) => {
)} )}
</Box> </Box>
<Dialog open={dialogOpen} onClose={handleClose} maxWidth="lg" fullWidth> <Dialog
<DialogTitle> 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'} {hasDocuments ? `Dokumente (${totalCount})` : 'Dokumentinformationen'}
</DialogTitle> </DialogTitle>
<DialogContent sx={{ p: 0 }}> <DialogContent sx={{ p: 0 }} id="document-dialog-content">
{error && ( {error && (
<Alert severity="error" sx={{ m: 2 }} onClose={() => setError(null)}> <Alert severity="error" sx={{ m: 2 }} onClose={() => setError(null)}>
{error} {error}
@@ -335,6 +361,23 @@ const DocumentRenderer = (params) => {
<Tabs value={tabValue} onChange={handleTabChange}> <Tabs value={tabValue} onChange={handleTabChange}>
<Tab label="Dokumente" /> <Tab label="Dokumente" />
<Tab label={`Buchungen (${lineItems.length})`} /> <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> </Tabs>
</Box> </Box>
@@ -431,6 +474,108 @@ const DocumentRenderer = (params) => {
)} )}
</Box> </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> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={handleClose}>Schließen</Button> <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 ( return (
<span <span
style={{ style={{
fontSize: '0.7rem', fontSize: '0.7rem',
lineHeight: '1.2', lineHeight: '1.2',
cursor: isIbanColumn && value ? 'pointer' : 'default', cursor: isIbanColumn && value ? 'pointer' : 'default',
color: isIbanColumn && value ? '#1976d2' : 'inherit', color: getIbanColor(),
textDecoration: isIbanColumn && value ? 'underline' : 'none' textDecoration: isIbanColumn && value ? 'underline' : 'none',
fontWeight: isIbanColumn && params.data && params.data.hasKreditor ? 'bold' : 'normal'
}} }}
onClick={isIbanColumn && value ? handleClick : undefined} onClick={isIbanColumn && value ? handleClick : undefined}
title={isIbanColumn && value ? `Nach IBAN "${value}" filtern` : undefined} title={getTitle()}
> >
{value} {value}
</span> </span>

View File

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

View File

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

View File

@@ -8,20 +8,27 @@ import {
ListItemText, ListItemText,
Box, Box,
Chip, Chip,
Button Button,
TextField,
Typography,
Divider
} from '@mui/material'; } from '@mui/material';
export default class IbanSelectionFilter { export default class IbanSelectionFilter {
constructor() { constructor() {
this.state = { this.state = {
selectedValues: [], selectedValues: [],
availableValues: [] availableValues: [],
partialIban: ''
}; };
// Create the DOM element that AG Grid expects // Create the DOM element that AG Grid expects
this.eGui = document.createElement('div'); this.eGui = document.createElement('div');
this.eGui.style.minWidth = '250px'; this.eGui.style.minWidth = '250px';
this.eGui.style.padding = '8px'; this.eGui.style.padding = '8px';
// Create a ref for the text input
this.textInputRef = React.createRef();
} }
init(params) { init(params) {
@@ -35,10 +42,13 @@ export default class IbanSelectionFilter {
} }
destroy() { destroy() {
if (this.reactRoot) { // Use setTimeout to avoid unmounting during render
this.reactRoot.unmount(); setTimeout(() => {
this.reactRoot = null; if (this.reactRoot) {
} this.reactRoot.unmount();
this.reactRoot = null;
}
}, 0);
} }
updateAvailableValues() { updateAvailableValues() {
@@ -72,15 +82,27 @@ export default class IbanSelectionFilter {
} }
isFilterActive() { isFilterActive() {
return this.state.selectedValues.length > 0; return this.state.selectedValues.length > 0 || this.state.partialIban.trim() !== '';
} }
doesFilterPass(params) { doesFilterPass(params) {
const { selectedValues } = this.state; const { selectedValues, partialIban } = this.state;
if (selectedValues.length === 0) return true;
const value = params.data['Kontonummer/IBAN']; 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() { getModel() {
@@ -88,16 +110,28 @@ export default class IbanSelectionFilter {
return { return {
filterType: 'iban-selection', filterType: 'iban-selection',
values: this.state.selectedValues values: this.state.selectedValues,
partialIban: this.state.partialIban
}; };
} }
setModel(model) { setModel(model) {
if (!model) { if (!model) {
this.state.selectedValues = []; this.state.selectedValues = [];
this.state.partialIban = '';
} else { } else {
this.state.selectedValues = model.values || []; 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(); 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 = () => { clearFilter = () => {
this.state.selectedValues = []; 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(); this.renderReactComponent();
if (this.params && this.params.filterChangedCallback) { if (this.params && this.params.filterChangedCallback) {
@@ -127,6 +192,27 @@ export default class IbanSelectionFilter {
const FilterComponent = () => ( const FilterComponent = () => (
<Box sx={{ minWidth: 250 }} className="ag-filter-custom"> <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"> <FormControl fullWidth size="small">
<Select <Select
multiple multiple
@@ -138,7 +224,7 @@ export default class IbanSelectionFilter {
return <em>Alle IBANs</em>; return <em>Alle IBANs</em>;
} }
return ( 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) => { {selected.slice(0, 2).map((value) => {
const ibanData = this.availableValues.find(item => item.iban === value); const ibanData = this.availableValues.find(item => item.iban === value);
return ( return (
@@ -170,7 +256,7 @@ export default class IbanSelectionFilter {
}, },
}, },
}} }}
> >
{this.availableValues.map((item) => ( {this.availableValues.map((item) => (
<MenuItem key={item.iban} value={item.iban}> <MenuItem key={item.iban} value={item.iban}>
<Checkbox <Checkbox
@@ -188,19 +274,21 @@ export default class IbanSelectionFilter {
</Select> </Select>
</FormControl> </FormControl>
{this.state.selectedValues.length > 0 && ( <Box
<Box sx={{ mt: 1, textAlign: 'right' }}> className="clear-button-container"
<Button sx={{ mt: 1, textAlign: 'right' }}
onClick={this.clearFilter} style={{ display: (this.state.selectedValues.length > 0 || this.state.partialIban.trim() !== '') ? 'block' : 'none' }}
size="small" >
variant="text" <Button
color="primary" onClick={this.clearFilter}
sx={{ fontSize: '0.75rem' }} size="small"
> variant="text"
Filter löschen color="primary"
</Button> sx={{ fontSize: '0.75rem' }}
</Box> >
)} Alle Filter löschen
</Button>
</Box>
</Box> </Box>
); );

View File

@@ -18,7 +18,10 @@ const SortHeader = (params) => {
if (params.api) { if (params.api) {
params.api.addEventListener('sortChanged', updateSortState); params.api.addEventListener('sortChanged', updateSortState);
return () => { 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]); }, [params.api, params.column]);

View File

@@ -36,8 +36,8 @@ class HeaderComponent extends Component {
} }
componentWillUnmount() { componentWillUnmount() {
// Clean up event listener // Clean up event listener - check if grid API is still valid
if (this.props.params && this.props.params.api) { if (this.props.params && this.props.params.api && !this.props.params.api.isDestroyed()) {
this.props.params.api.removeEventListener('filterChanged', this.onFilterChanged); this.props.params.api.removeEventListener('filterChanged', this.onFilterChanged);
} }
} }
@@ -147,7 +147,7 @@ class HeaderComponent extends Component {
const showTextFilter = isTextColumn; const showTextFilter = isTextColumn;
// Check if sorting is disabled for this column // 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 ( return (
<Box sx={{ <Box sx={{
@@ -290,21 +290,19 @@ export default class TextHeaderWithFilter {
this.eGui.style.height = '100%'; this.eGui.style.height = '100%';
this.eGui.style.display = 'flex'; this.eGui.style.display = 'flex';
this.eGui.style.flexDirection = 'column'; this.eGui.style.flexDirection = 'column';
console.log('TextHeaderWithFilter constructor');
} }
init(params) { init(params) {
this.params = params; this.params = params;
console.log('TextHeaderWithFilter init params:', params);
// Listen for menu close events to keep state in sync // Listen for menu close events to keep state in sync
if (params.api) { if (params.api) {
params.api.addEventListener('popupMenuVisibleChanged', (event) => { this.popupMenuListener = (event) => {
if (!event.visible && this.headerComponent) { if (!event.visible && this.headerComponent) {
this.headerComponent.setState({ menuOpen: false }); this.headerComponent.setState({ menuOpen: false });
} }
}); };
params.api.addEventListener('popupMenuVisibleChanged', this.popupMenuListener);
} }
// Render React component into the DOM element // Render React component into the DOM element
@@ -312,11 +310,15 @@ export default class TextHeaderWithFilter {
} }
getGui() { getGui() {
console.log('TextHeaderWithFilter getGui called');
return this.eGui; return this.eGui;
} }
destroy() { 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 // Use setTimeout to avoid unmounting during render
setTimeout(() => { setTimeout(() => {
if (this.reactRoot) { if (this.reactRoot) {

View File

@@ -13,8 +13,6 @@ export const getRowStyle = (params, selectedRows) => {
const rowId = params.data?.id || params.rowIndex; const rowId = params.data?.id || params.rowIndex;
const isSelected = selectedRows && selectedRows.has && selectedRows.has(rowId); const isSelected = selectedRows && selectedRows.has && selectedRows.has(rowId);
console.log('getRowStyle called for row:', rowId, 'isSelected:', isSelected, 'selectedRows size:', selectedRows?.size);
if (params.data.isJTLOnly) { if (params.data.isJTLOnly) {
return { return {
backgroundColor: isSelected ? '#e3f2fd' : '#ffebee', backgroundColor: isSelected ? '#e3f2fd' : '#ffebee',

View File

@@ -8,6 +8,7 @@ GO
-- Create Kreditor table -- Create Kreditor table
-- Multiple IBANs can have the same kreditor name and kreditorId
CREATE TABLE fibdash.Kreditor ( CREATE TABLE fibdash.Kreditor (
id INT IDENTITY(1,1) PRIMARY KEY, id INT IDENTITY(1,1) PRIMARY KEY,
iban NVARCHAR(34) NOT NULL, iban NVARCHAR(34) NOT NULL,
@@ -15,9 +16,10 @@ CREATE TABLE fibdash.Kreditor (
kreditorId NVARCHAR(50) NOT NULL kreditorId NVARCHAR(50) NOT NULL
); );
-- Ensure kreditorId is unique to support FK references -- Create unique index on IBAN to prevent duplicate IBANs
-- but allow same kreditorId and name for multiple IBANs
ALTER TABLE fibdash.Kreditor ALTER TABLE fibdash.Kreditor
ADD CONSTRAINT UQ_Kreditor_kreditorId UNIQUE (kreditorId); ADD CONSTRAINT UQ_Kreditor_IBAN UNIQUE (iban);
-- Create AccountingItems table -- Create AccountingItems table
-- Based on CSV structure: umsatz brutto, soll/haben kz, konto, gegenkonto, bu, buchungsdatum, rechnungsnummer, buchungstext, beleglink -- Based on CSV structure: umsatz brutto, soll/haben kz, konto, gegenkonto, bu, buchungsdatum, rechnungsnummer, buchungstext, beleglink

View File

@@ -22,7 +22,7 @@ router.get('/system-info', authenticateToken, (req, res) => {
// Get all kreditoren // Get all kreditoren
router.get('/kreditoren', authenticateToken, async (req, res) => { router.get('/kreditoren', authenticateToken, async (req, res) => {
try { try {
const result = await executeQuery('SELECT * FROM fibdash.Kreditor ORDER BY name'); const result = await executeQuery('SELECT id, iban, name, kreditorId FROM fibdash.Kreditor ORDER BY name, iban');
res.json({ kreditoren: result.recordset }); res.json({ kreditoren: result.recordset });
} catch (error) { } catch (error) {
console.error('Error fetching kreditoren:', error); console.error('Error fetching kreditoren:', error);
@@ -199,7 +199,7 @@ router.post('/buchungsschluessel', authenticateToken, async (req, res) => {
try { try {
await executeQuery( await executeQuery(
'INSERT INTO fibdash.BU (bu, name, vst) VALUES (@bu, @name, @vst)', 'INSERT INTO fibdash.BU (bu, name, vst) VALUES (@bu, @name, @vst)',
{ bu, name, vst: vst || null } { bu, name, vst: vst !== undefined && vst !== '' ? vst : null }
); );
res.json({ message: 'Buchungsschlüssel erfolgreich erstellt' }); res.json({ message: 'Buchungsschlüssel erfolgreich erstellt' });
} catch (error) { } catch (error) {
@@ -224,7 +224,7 @@ router.put('/buchungsschluessel/:id', authenticateToken, async (req, res) => {
try { try {
await executeQuery( await executeQuery(
'UPDATE fibdash.BU SET bu = @bu, name = @name, vst = @vst WHERE id = @id', 'UPDATE fibdash.BU SET bu = @bu, name = @name, vst = @vst WHERE id = @id',
{ bu, name, vst: vst || null, id } { bu, name, vst: vst !== undefined && vst !== '' ? vst : null, id }
); );
res.json({ message: 'Buchungsschlüssel erfolgreich aktualisiert' }); res.json({ message: 'Buchungsschlüssel erfolgreich aktualisiert' });
} catch (error) { } catch (error) {

View File

@@ -235,7 +235,18 @@ router.get('/transactions/:timeRange', authenticateToken, async (req, res) => {
}); });
} }
// Add JTL status to each CSV transaction // Get Kreditor information for IBAN lookup
let kreditorData = [];
try {
const { executeQuery } = require('../config/database');
const kreditorQuery = `SELECT id, iban, name, kreditorId FROM fibdash.Kreditor`;
const kreditorResult = await executeQuery(kreditorQuery);
kreditorData = kreditorResult.recordset || [];
} catch (error) {
console.log('Kreditor database not available, continuing without Kreditor data');
}
// Add JTL status and Kreditor information to each CSV transaction
const transactionsWithJTL = monthTransactions.map(transaction => { const transactionsWithJTL = monthTransactions.map(transaction => {
// Try to match by amount and date (approximate matching) // Try to match by amount and date (approximate matching)
const amount = transaction.numericAmount; const amount = transaction.numericAmount;
@@ -255,6 +266,10 @@ router.get('/transactions/:timeRange', authenticateToken, async (req, res) => {
return amountMatch && dateMatch; return amountMatch && dateMatch;
}); });
// Look up Kreditor by IBAN
const transactionIban = transaction['Kontonummer/IBAN'];
const kreditorMatch = transactionIban ? kreditorData.find(k => k.iban === transactionIban) : null;
return { return {
...transaction, ...transaction,
hasJTL: jtlDatabaseAvailable ? !!jtlMatch : undefined, hasJTL: jtlDatabaseAvailable ? !!jtlMatch : undefined,
@@ -263,7 +278,15 @@ router.get('/transactions/:timeRange', authenticateToken, async (req, res) => {
jtlDatabaseAvailable, jtlDatabaseAvailable,
// Include document data from JTL match // Include document data from JTL match
pdfs: jtlMatch ? jtlMatch.pdfs || [] : [], pdfs: jtlMatch ? jtlMatch.pdfs || [] : [],
links: jtlMatch ? jtlMatch.links || [] : [] links: jtlMatch ? jtlMatch.links || [] : [],
// Include Kreditor information
kreditor: kreditorMatch ? {
id: kreditorMatch.id,
name: kreditorMatch.name,
kreditorId: kreditorMatch.kreditorId,
iban: kreditorMatch.iban
} : null,
hasKreditor: !!kreditorMatch
}; };
}); });
@@ -299,6 +322,7 @@ router.get('/transactions/:timeRange', authenticateToken, async (req, res) => {
'Verwendungszweck': jtl.cVerwendungszweck || '', 'Verwendungszweck': jtl.cVerwendungszweck || '',
'Buchungstext': 'JTL Transaction', 'Buchungstext': 'JTL Transaction',
'Beguenstigter/Zahlungspflichtiger': jtl.cName || '', 'Beguenstigter/Zahlungspflichtiger': jtl.cName || '',
'Kontonummer/IBAN': '', // JTL transactions don't have IBAN data
'Betrag': jtl.fBetrag ? jtl.fBetrag.toString().replace('.', ',') : '0,00', 'Betrag': jtl.fBetrag ? jtl.fBetrag.toString().replace('.', ',') : '0,00',
numericAmount: parseFloat(jtl.fBetrag) || 0, numericAmount: parseFloat(jtl.fBetrag) || 0,
parsedDate: new Date(jtl.dBuchungsdatum), parsedDate: new Date(jtl.dBuchungsdatum),
@@ -309,7 +333,10 @@ router.get('/transactions/:timeRange', authenticateToken, async (req, res) => {
isJTLOnly: true, isJTLOnly: true,
// Include document data from JTL transaction // Include document data from JTL transaction
pdfs: jtl.pdfs || [], pdfs: jtl.pdfs || [],
links: jtl.links || [] links: jtl.links || [],
// JTL transactions don't have IBAN data, so no Kreditor match
kreditor: null,
hasKreditor: false
})); }));
// Combine CSV and JTL-only transactions // Combine CSV and JTL-only transactions
@@ -565,10 +592,9 @@ router.get('/kreditors', authenticateToken, async (req, res) => {
try { try {
const { executeQuery } = require('../config/database'); const { executeQuery } = require('../config/database');
const query = ` const query = `
SELECT id, iban, name, kreditorId, created_at, updated_at SELECT id, iban, name, kreditorId
FROM Kreditor FROM fibdash.Kreditor
WHERE is_active = 1 ORDER BY name ASC, iban ASC
ORDER BY name ASC
`; `;
const result = await executeQuery(query); const result = await executeQuery(query);
@@ -586,14 +612,12 @@ router.get('/kreditors/:id', authenticateToken, async (req, res) => {
const { id } = req.params; const { id } = req.params;
const query = ` const query = `
SELECT id, iban, name, kreditorId, created_at, updated_at SELECT id, iban, name, kreditorId
FROM Kreditor FROM fibdash.Kreditor
WHERE id = @id AND is_active = 1 WHERE id = @id
`; `;
const result = await executeQuery(query, [ const result = await executeQuery(query, { id: parseInt(id) });
{ name: 'id', type: 'int', value: parseInt(id) }
]);
if (result.recordset.length === 0) { if (result.recordset.length === 0) {
return res.status(404).json({ error: 'Kreditor not found' }); return res.status(404).json({ error: 'Kreditor not found' });
@@ -617,32 +641,25 @@ router.post('/kreditors', authenticateToken, async (req, res) => {
return res.status(400).json({ error: 'IBAN, name, and kreditorId are required' }); return res.status(400).json({ error: 'IBAN, name, and kreditorId are required' });
} }
// Check if kreditor with same IBAN or kreditorId already exists // Check if IBAN already exists (only IBAN needs to be unique)
const checkQuery = ` const checkQuery = `
SELECT id FROM Kreditor SELECT id FROM fibdash.Kreditor
WHERE (iban = @iban OR kreditorId = @kreditorId) AND is_active = 1 WHERE iban = @iban
`; `;
const checkResult = await executeQuery(checkQuery, [ const checkResult = await executeQuery(checkQuery, { iban });
{ name: 'iban', type: 'nvarchar', value: iban },
{ name: 'kreditorId', type: 'nvarchar', value: kreditorId }
]);
if (checkResult.recordset.length > 0) { if (checkResult.recordset.length > 0) {
return res.status(409).json({ error: 'Kreditor with this IBAN or kreditorId already exists' }); return res.status(409).json({ error: 'Kreditor with this IBAN already exists' });
} }
const insertQuery = ` const insertQuery = `
INSERT INTO Kreditor (iban, name, kreditorId, created_at, updated_at) INSERT INTO fibdash.Kreditor (iban, name, kreditorId)
OUTPUT INSERTED.id, INSERTED.iban, INSERTED.name, INSERTED.kreditorId, INSERTED.created_at, INSERTED.updated_at OUTPUT INSERTED.id, INSERTED.iban, INSERTED.name, INSERTED.kreditorId
VALUES (@iban, @name, @kreditorId, GETDATE(), GETDATE()) VALUES (@iban, @name, @kreditorId)
`; `;
const result = await executeQuery(insertQuery, [ const result = await executeQuery(insertQuery, { iban, name, kreditorId });
{ name: 'iban', type: 'nvarchar', value: iban },
{ name: 'name', type: 'nvarchar', value: name },
{ name: 'kreditorId', type: 'nvarchar', value: kreditorId }
]);
res.status(201).json(result.recordset[0]); res.status(201).json(result.recordset[0]);
} catch (error) { } catch (error) {
@@ -664,44 +681,33 @@ router.put('/kreditors/:id', authenticateToken, async (req, res) => {
} }
// Check if kreditor exists // Check if kreditor exists
const checkQuery = `SELECT id FROM Kreditor WHERE id = @id AND is_active = 1`; const checkQuery = `SELECT id FROM fibdash.Kreditor WHERE id = @id`;
const checkResult = await executeQuery(checkQuery, [ const checkResult = await executeQuery(checkQuery, { id: parseInt(id) });
{ name: 'id', type: 'int', value: parseInt(id) }
]);
if (checkResult.recordset.length === 0) { if (checkResult.recordset.length === 0) {
return res.status(404).json({ error: 'Kreditor not found' }); return res.status(404).json({ error: 'Kreditor not found' });
} }
// Check for conflicts with other kreditors // Check for conflicts with other kreditors (only IBAN needs to be unique)
const conflictQuery = ` const conflictQuery = `
SELECT id FROM Kreditor SELECT id FROM fibdash.Kreditor
WHERE (iban = @iban OR kreditorId = @kreditorId) AND id != @id AND is_active = 1 WHERE iban = @iban AND id != @id
`; `;
const conflictResult = await executeQuery(conflictQuery, [ const conflictResult = await executeQuery(conflictQuery, { iban, id: parseInt(id) });
{ name: 'iban', type: 'nvarchar', value: iban },
{ name: 'kreditorId', type: 'nvarchar', value: kreditorId },
{ name: 'id', type: 'int', value: parseInt(id) }
]);
if (conflictResult.recordset.length > 0) { if (conflictResult.recordset.length > 0) {
return res.status(409).json({ error: 'Another kreditor with this IBAN or kreditorId already exists' }); return res.status(409).json({ error: 'Another kreditor with this IBAN already exists' });
} }
const updateQuery = ` const updateQuery = `
UPDATE Kreditor UPDATE fibdash.Kreditor
SET iban = @iban, name = @name, kreditorId = @kreditorId, updated_at = GETDATE() SET iban = @iban, name = @name, kreditorId = @kreditorId
OUTPUT INSERTED.id, INSERTED.iban, INSERTED.name, INSERTED.kreditorId, INSERTED.created_at, INSERTED.updated_at OUTPUT INSERTED.id, INSERTED.iban, INSERTED.name, INSERTED.kreditorId
WHERE id = @id WHERE id = @id
`; `;
const result = await executeQuery(updateQuery, [ const result = await executeQuery(updateQuery, { iban, name, kreditorId, id: parseInt(id) });
{ name: 'iban', type: 'nvarchar', value: iban },
{ name: 'name', type: 'nvarchar', value: name },
{ name: 'kreditorId', type: 'nvarchar', value: kreditorId },
{ name: 'id', type: 'int', value: parseInt(id) }
]);
res.json(result.recordset[0]); res.json(result.recordset[0]);
} catch (error) { } catch (error) {
@@ -710,21 +716,18 @@ router.put('/kreditors/:id', authenticateToken, async (req, res) => {
} }
}); });
// Delete kreditor (soft delete) // Delete kreditor (hard delete)
router.delete('/kreditors/:id', authenticateToken, async (req, res) => { router.delete('/kreditors/:id', authenticateToken, async (req, res) => {
try { try {
const { executeQuery } = require('../config/database'); const { executeQuery } = require('../config/database');
const { id } = req.params; const { id } = req.params;
const query = ` const query = `
UPDATE Kreditor DELETE FROM fibdash.Kreditor
SET is_active = 0, updated_at = GETDATE() WHERE id = @id
WHERE id = @id AND is_active = 1
`; `;
const result = await executeQuery(query, [ const result = await executeQuery(query, { id: parseInt(id) });
{ name: 'id', type: 'int', value: parseInt(id) }
]);
if (result.rowsAffected[0] === 0) { if (result.rowsAffected[0] === 0) {
return res.status(404).json({ error: 'Kreditor not found' }); return res.status(404).json({ error: 'Kreditor not found' });

View File

@@ -5,12 +5,20 @@ const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin'
require('dotenv').config(); require('dotenv').config();
module.exports = { module.exports = {
mode: process.env.NODE_ENV || 'development',
entry: './client/src/index.js', entry: './client/src/index.js',
output: { output: {
path: path.resolve(__dirname, 'dist'), path: path.resolve(__dirname, 'dist'),
filename: 'bundle.js', filename: 'bundle.js',
publicPath: '/', publicPath: '/',
}, },
cache: {
type: 'filesystem',
buildDependencies: {
config: [__filename],
},
cacheDirectory: path.resolve(__dirname, 'node_modules/.cache/webpack'),
},
module: { module: {
rules: [ rules: [
{ {
@@ -72,6 +80,31 @@ module.exports = {
}, },
}, },
}, },
watchOptions: {
ignored: /node_modules/,
aggregateTimeout: 300,
poll: false,
},
snapshot: {
managedPaths: [path.resolve(__dirname, 'node_modules')],
immutablePaths: [],
buildDependencies: {
hash: true,
timestamp: true,
},
module: {
timestamp: true,
hash: true,
},
resolve: {
timestamp: true,
hash: true,
},
resolveBuildDependencies: {
timestamp: true,
hash: true,
},
},
resolve: { resolve: {
extensions: ['.js', '.jsx'], extensions: ['.js', '.jsx'],
}, },