Implement DATEV export functionality in DataViewer and enhance TransactionsTable with selection features and improved row styling. Update environment variables and add devServer configuration in webpack for better development experience.

This commit is contained in:
sebseb7
2025-07-20 07:47:18 +02:00
parent 2a43b7106d
commit 429fd70497
18 changed files with 1542 additions and 149 deletions

View File

@@ -34,6 +34,34 @@
min-width: 200px;
}
/* Alternating row colors for better readability */
.ag-row-odd {
background-color: #f8f9fa !important;
}
.ag-row-even {
background-color: #ffffff !important;
}
/* Maintain alternating colors on hover */
.ag-row-odd:hover {
background-color: #e9ecef !important;
}
.ag-row-even:hover {
background-color: #f1f3f4 !important;
}
/* Ensure JTL-only rows (red rows) override alternating colors */
.ag-row[style*="background-color: rgb(255, 235, 238)"] {
background-color: #ffebee !important;
}
/* Selected rows */
.ag-row.selected-row {
background-color: #e3f2fd !important;
color: #1976d2 !important;
}
</style>
<script>

View File

@@ -4,6 +4,7 @@ import CssBaseline from '@mui/material/CssBaseline';
import { Container, AppBar, Toolbar, Typography, Button, Box } from '@mui/material';
import LoginIcon from '@mui/icons-material/Login';
import DashboardIcon from '@mui/icons-material/Dashboard';
import DownloadIcon from '@mui/icons-material/Download';
import AuthService from './services/AuthService';
import DataViewer from './components/DataViewer';
import Login from './components/Login';
@@ -27,6 +28,7 @@ class App extends Component {
isAuthenticated: false,
user: null,
loading: true,
exportData: null, // { selectedMonth, canExport, onExport }
};
this.authService = new AuthService();
}
@@ -68,7 +70,11 @@ class App extends Component {
handleLogout = () => {
localStorage.removeItem('token');
this.setState({ isAuthenticated: false, user: null });
this.setState({ isAuthenticated: false, user: null, exportData: null });
};
updateExportData = (exportData) => {
this.setState({ exportData });
};
render() {
@@ -107,6 +113,24 @@ class App extends Component {
>
Willkommen, {user.name}
</Typography>
{this.state.exportData && (
<Button
color="inherit"
onClick={this.state.exportData.onExport}
disabled={!this.state.exportData.canExport}
size="small"
sx={{
mr: { xs: 1, sm: 2 },
minWidth: { xs: 'auto', sm: 'auto' },
px: { xs: 1, sm: 2 }
}}
>
<DownloadIcon sx={{ mr: { xs: 0, sm: 1 } }} />
<Box component="span" sx={{ display: { xs: 'none', sm: 'inline' } }}>
DATEV Export
</Box>
</Button>
)}
<Button
color="inherit"
onClick={this.handleLogout}
@@ -127,9 +151,9 @@ class App extends Component {
</AppBar>
<Box sx={{ height: 'calc(100vh - 64px)', display: 'flex', flexDirection: 'column' }}>
<Container maxWidth="xl" sx={{ mt: 4, flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
<Container maxWidth={false} sx={{ mt: 4, flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', width: '100%' }}>
{isAuthenticated ? (
<DataViewer user={user} />
<DataViewer user={user} onUpdateExportData={this.updateExportData} />
) : (
<Login onLogin={this.handleLogin} />
)}

View File

@@ -24,6 +24,13 @@ class DataViewer extends Component {
componentDidMount() {
this.loadMonths();
this.updateExportData();
}
componentDidUpdate(prevProps, prevState) {
if (prevState.loading !== this.state.loading || prevState.selectedMonth !== this.state.selectedMonth) {
this.updateExportData();
}
}
loadMonths = async () => {
@@ -70,6 +77,7 @@ class DataViewer extends Component {
const monthYear = event.target.value;
this.setState({ selectedMonth: monthYear });
this.loadTransactions(monthYear);
this.updateExportData(monthYear);
};
@@ -98,6 +106,16 @@ class DataViewer extends Component {
}
};
updateExportData = (selectedMonth = this.state.selectedMonth) => {
if (this.props.onUpdateExportData) {
this.props.onUpdateExportData({
selectedMonth,
canExport: !!selectedMonth && !this.state.loading,
onExport: this.downloadDatev
});
}
};
render() {
const { months, selectedMonth, transactions, summary, loading, error } = this.state;
@@ -126,7 +144,6 @@ class DataViewer extends Component {
summary={summary}
loading={loading}
onMonthChange={this.handleMonthChange}
onDownloadDatev={this.downloadDatev}
/>
</Box>

View File

@@ -95,8 +95,7 @@ class SummaryHeader extends Component {
selectedMonth,
summary,
loading,
onMonthChange,
onDownloadDatev
onMonthChange
} = this.props;
if (!summary) return null;
@@ -249,23 +248,19 @@ class SummaryHeader extends Component {
<Grid item xs={6} sm={4} md={1}>
<Box textAlign="center">
<Typography variant="caption" color="textSecondary">JTL </Typography>
<Typography variant="h6" sx={{ fontWeight: 'bold', color: '#388e3c', fontSize: { xs: '0.9rem', sm: '1.25rem' } }}>
{summary.jtlMatches || 0}
<Typography
variant="h6"
sx={{
fontWeight: 'bold',
color: summary.jtlMatches === undefined ? '#856404' : '#388e3c',
fontSize: { xs: '0.9rem', sm: '1.25rem' }
}}
>
{summary.jtlMatches === undefined ? '?' : summary.jtlMatches}
</Typography>
</Box>
</Grid>
<Grid item xs={12} sm={4} md={2}>
<Button
variant="contained"
startIcon={<DownloadIcon />}
onClick={onDownloadDatev}
disabled={!selectedMonth || loading}
size="small"
sx={{ height: 'fit-content' }}
>
DATEV Export
</Button>
</Grid>
</Grid>
</Paper>
);

View File

@@ -9,9 +9,12 @@ import {
Paper,
Typography,
CircularProgress,
IconButton,
Tooltip,
} from '@mui/material';
import { Clear as ClearIcon } from '@mui/icons-material';
import { getColumnDefs, defaultColDef, gridOptions } from './config/gridConfig';
import { processTransactionData, getRowStyle, getSelectedDisplayName } from './utils/dataUtils';
import { processTransactionData, getRowStyle, getRowClass, getSelectedDisplayName } from './utils/dataUtils';
class TransactionsTable extends Component {
constructor(props) {
@@ -20,20 +23,59 @@ class TransactionsTable extends Component {
this.state = {
columnDefs: getColumnDefs(),
defaultColDef: defaultColDef,
gridOptions: gridOptions
gridOptions: gridOptions,
totalRows: 0,
displayedRows: 0,
selectedRows: new Set() // Track selected row IDs
};
// Ref for header checkbox to update it directly
this.headerCheckboxRef = null;
}
componentDidMount() {
// Add window resize listener for auto-resizing columns
this.handleResize = () => {
if (this.gridApi) {
// Small delay to ensure DOM has updated
setTimeout(() => {
this.gridApi.sizeColumnsToFit();
}, 100);
}
};
window.addEventListener('resize', this.handleResize);
}
componentWillUnmount() {
// Clean up event listeners
if (this.handleResize) {
window.removeEventListener('resize', this.handleResize);
}
if (this.gridApi) {
this.gridApi.removeEventListener('modelUpdated', this.onModelUpdated);
this.gridApi.removeEventListener('filterChanged', this.onFilterChanged);
}
}
componentDidUpdate(prevProps) {
// Update data without recreating grid when transactions change
if (prevProps.transactions !== this.props.transactions && this.gridApi) {
const processedTransactions = this.props.transactions ? processTransactionData(this.props.transactions) : [];
// Use setGridOption to update data while preserving grid state
this.gridApi.setGridOption('rowData', processedTransactions);
// Update total rows count and displayed rows
this.setState({
totalRows: processedTransactions.length,
displayedRows: processedTransactions.length
});
}
}
@@ -51,12 +93,26 @@ class TransactionsTable extends Component {
this.gridApi = params.api;
this.gridColumnApi = params.columnApi;
// Store reference to this component for header access
params.api.fibdashComponent = this;
params.api.setHeaderCheckboxRef = (ref) => {
this.headerCheckboxRef = ref;
};
// Set initial data if available
if (this.props.transactions) {
const processedTransactions = processTransactionData(this.props.transactions);
this.gridApi.setGridOption('rowData', processedTransactions);
this.setState({
totalRows: processedTransactions.length,
displayedRows: processedTransactions.length
});
}
// Add event listeners for row count updates
params.api.addEventListener('modelUpdated', this.onModelUpdated);
params.api.addEventListener('filterChanged', this.onFilterChanged);
// Auto-size columns to fit content
params.api.sizeColumnsToFit();
@@ -64,23 +120,143 @@ class TransactionsTable extends Component {
this.gridInitialized = true;
};
onModelUpdated = () => {
if (this.gridApi) {
const displayedRows = this.gridApi.getDisplayedRowCount();
this.setState({ displayedRows });
}
};
onFilterChanged = () => {
if (this.gridApi) {
const displayedRows = this.gridApi.getDisplayedRowCount();
this.setState({ displayedRows });
}
};
hasActiveFilters = () => {
if (!this.gridApi) return false;
const filterModel = this.gridApi.getFilterModel();
return filterModel && Object.keys(filterModel).length > 0;
};
clearAllFilters = () => {
if (this.gridApi) {
this.gridApi.setFilterModel(null);
}
};
onCellClicked = (event) => {
// Skip selection for specific cells
const field = event.colDef.field;
// Don't select on selection column or document column (last cell)
// Allow IBAN column to trigger selection
if (field === 'selection' || field === 'documents') {
return;
}
// Toggle row selection
const rowData = event.data;
if (!rowData) return;
const rowId = rowData.id || event.rowIndex;
const isCurrentlySelected = this.state.selectedRows.has(rowId);
// Toggle selection
this.onSelectionChange(rowId, rowData, !isCurrentlySelected);
};
// Custom selection methods
onSelectionChange = (rowId, rowData, isSelected) => {
const selectedRows = new Set(this.state.selectedRows);
if (isSelected) {
selectedRows.add(rowId);
} else {
selectedRows.delete(rowId);
}
this.setState({ selectedRows }, () => {
// Update only grid context, avoid column definition changes that cause resizing
if (this.gridApi) {
this.gridApi.setGridOption('context', {
selectedRows: selectedRows,
onSelectionChange: this.onSelectionChange,
onSelectAll: this.onSelectAll,
totalRows: this.state.totalRows,
displayedRows: this.state.displayedRows
});
// Refresh only the selection column cells
this.gridApi.refreshCells({ columns: ['selection'], force: true });
// Refresh row styles to show selection background
this.gridApi.redrawRows();
// Update header checkbox directly via ref
if (this.headerCheckboxRef) {
this.headerCheckboxRef.updateState(selectedRows, this.state.displayedRows);
}
}
});
console.log('Selected rows:', Array.from(selectedRows));
};
onSelectAll = (selectAll) => {
const selectedRows = new Set();
if (selectAll && this.gridApi) {
// Select all displayed rows
this.gridApi.forEachNodeAfterFilter((node) => {
if (node.data) {
const rowId = node.data.id || node.rowIndex;
selectedRows.add(rowId);
}
});
}
// If selectAll is false, selectedRows stays empty (clears all)
this.setState({ selectedRows }, () => {
// Update only grid context, avoid column definition changes that cause resizing
if (this.gridApi) {
this.gridApi.setGridOption('context', {
selectedRows: selectedRows,
onSelectionChange: this.onSelectionChange,
onSelectAll: this.onSelectAll,
totalRows: this.state.totalRows,
displayedRows: this.state.displayedRows
});
// Refresh only the selection column cells
this.gridApi.refreshCells({ columns: ['selection'], force: true });
// Refresh row styles to show selection background
this.gridApi.redrawRows();
// Update header checkbox directly via ref
if (this.headerCheckboxRef) {
this.headerCheckboxRef.updateState(selectedRows, this.state.displayedRows);
}
}
});
console.log('Selected rows:', Array.from(selectedRows));
};
render() {
const { selectedMonth, loading } = this.props;
const { totalRows, displayedRows, selectedRows } = this.state;
return (
<Paper elevation={2} sx={{
m: 0,
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column'
}}>
<Box sx={{ p: 1, flexShrink: 0 }}>
<Typography variant="h6" component="h2" gutterBottom sx={{ m: 0 }}>
Transaktionen für {getSelectedDisplayName(selectedMonth)}
</Typography>
</Box>
<Box sx={{ flex: 1, width: '100%', minHeight: 0, position: 'relative' }}>
{loading && (
@@ -103,16 +279,27 @@ class TransactionsTable extends Component {
)}
<div style={{ height: '100%', width: '100%' }}>
<AgGridReact
key="transactions-grid" // Stable key prevents recreation
key="transactions-grid"
columnDefs={this.state.columnDefs}
// Remove rowData prop - data is set via API in componentDidUpdate
defaultColDef={this.state.defaultColDef}
gridOptions={this.state.gridOptions}
onGridReady={this.onGridReady}
getRowStyle={getRowStyle}
getRowStyle={(params) => getRowStyle(params, this.state.selectedRows)}
getRowClass={(params) => getRowClass(params, this.state.selectedRows)}
suppressRowTransform={true}
// Use new theming system
theme={themeQuartz}
// Pass selection state through context
context={{
selectedRows: this.state.selectedRows,
onSelectionChange: this.onSelectionChange,
onSelectAll: this.onSelectAll,
totalRows: this.state.totalRows,
displayedRows: this.state.displayedRows
}}
// Add row click selection
onCellClicked={this.onCellClicked}
// Virtualization settings for performance
rowBuffer={10}
suppressRowVirtualisation={false}
@@ -126,6 +313,134 @@ class TransactionsTable extends Component {
/>
</div>
</Box>
{/* Status Bar */}
<Box sx={{
flexShrink: 0,
px: 2,
py: 1,
borderTop: '1px solid #e0e0e0',
backgroundColor: '#f8f9fa',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
<Typography variant="body2" color="textSecondary">
{displayedRows === totalRows
? `${totalRows} Zeilen angezeigt`
: `${displayedRows} von ${totalRows} Zeilen angezeigt`
}
</Typography>
{selectedRows.size > 0 && (
<Tooltip title="Auswahl löschen">
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
cursor: 'pointer',
px: 1,
py: 0.5,
borderRadius: 1,
'&:hover': {
backgroundColor: 'primary.light',
'& .selection-text': {
color: 'white'
},
'& .clear-selection-icon': {
color: 'white'
}
}
}}
onClick={() => {
const emptySelection = new Set();
this.setState({ selectedRows: emptySelection }, () => {
if (this.gridApi) {
// Update grid context
this.gridApi.setGridOption('context', {
selectedRows: emptySelection,
onSelectionChange: this.onSelectionChange,
onSelectAll: this.onSelectAll,
totalRows: this.state.totalRows,
displayedRows: this.state.displayedRows
});
// Refresh cell checkboxes
this.gridApi.refreshCells({ columns: ['selection'], force: true });
// Refresh row styles to clear selection backgrounds
this.gridApi.redrawRows();
// Update header checkbox via ref
if (this.headerCheckboxRef) {
this.headerCheckboxRef.updateState(emptySelection, this.state.displayedRows);
}
}
});
}}
>
<Typography
variant="body2"
color="primary"
sx={{ fontWeight: 'medium' }}
className="selection-text"
>
{selectedRows.size} ausgewählt
</Typography>
<ClearIcon
sx={{
fontSize: 16,
color: 'primary.main'
}}
className="clear-selection-icon"
/>
</Box>
</Tooltip>
)}
</Box>
{this.hasActiveFilters() && (
<Tooltip title="Alle Filter löschen">
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
cursor: 'pointer',
px: 1,
py: 0.5,
borderRadius: 1,
'&:hover': {
backgroundColor: 'primary.light',
'& .filter-text': {
color: 'white'
},
'& .clear-icon': {
color: 'white'
}
}
}}
onClick={this.clearAllFilters}
>
<Typography
variant="body2"
color="primary"
sx={{ fontWeight: 'medium' }}
className="filter-text"
>
Filter aktiv
</Typography>
<ClearIcon
sx={{
fontSize: 16,
color: 'primary.main'
}}
className="clear-icon"
/>
</Box>
</Tooltip>
)}
</Box>
</Paper>
);
}

View File

@@ -0,0 +1,411 @@
import React, { useState } from 'react';
import {
Box,
IconButton,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
List,
ListItem,
ListItemText,
ListItemIcon,
Typography,
Divider,
Tabs,
Tab,
Alert
} from '@mui/material';
import {
PictureAsPdf as PdfIcon,
Link as LinkIcon,
Description as DocumentIcon,
OpenInNew as OpenIcon,
ContentCopy as CopyIcon
} from '@mui/icons-material';
import { AgGridReact } from 'ag-grid-react';
const DocumentRenderer = (params) => {
// Check for pdfs and links regardless of transaction source
const pdfs = params.data.pdfs || [];
const links = params.data.links || [];
const [dialogOpen, setDialogOpen] = useState(false);
const [tabValue, setTabValue] = useState(0);
const [error, setError] = useState(null);
// Always show something clickable, even if no documents
const hasDocuments = pdfs.length > 0 || links.length > 0;
// Combine PDFs and links since links are also PDFs
const allDocuments = [
...pdfs.map(pdf => ({ ...pdf, type: 'pdf' })),
...links.map(link => ({ ...link, type: 'link' }))
];
const totalCount = allDocuments.length;
const handleClick = () => {
setDialogOpen(true);
};
const handleClose = () => {
setDialogOpen(false);
setTabValue(0); // Reset to first tab when closing
setError(null); // Clear any errors when closing
};
const handleTabChange = (event, newValue) => {
setTabValue(newValue);
};
const handleCopyDatevLink = (datevlink) => {
if (datevlink) {
navigator.clipboard.writeText(datevlink);
}
};
const handleOpenPdf = async (document) => {
let endpoint = '';
if (document.type === 'pdf' && document.kUmsatzBeleg) {
// PDF from tUmsatzBeleg table
endpoint = `/data/pdf/umsatzbeleg/${document.kUmsatzBeleg}`;
} else if (document.type === 'link' && document.kPdfObjekt) {
// PDF from tPdfObjekt table (linked documents)
endpoint = `/data/pdf/pdfobject/${document.kPdfObjekt}`;
} else {
console.error('Unable to determine PDF URL for document:', document);
return;
}
try {
// Create an authenticated API call
const token = localStorage.getItem('token');
const response = await fetch(`/api${endpoint}`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
// Get the PDF blob
const blob = await response.blob();
// Create a blob URL and open it in a new tab
const blobUrl = URL.createObjectURL(blob);
const newWindow = window.open(blobUrl, '_blank');
// Clean up the blob URL after a delay to allow the browser to load it
setTimeout(() => {
URL.revokeObjectURL(blobUrl);
}, 1000);
// If window.open was blocked, try alternative approach
if (!newWindow) {
// Create a temporary download link
const a = document.createElement('a');
a.href = blobUrl;
a.target = '_blank';
a.download = `document_${document.kUmsatzBeleg || document.kPdfObjekt}.pdf`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(blobUrl), 1000);
}
} catch (error) {
console.error('Error opening PDF:', error);
setError('Fehler beim Öffnen des PDFs. Bitte versuchen Sie es erneut.');
}
};
// Extract line items from document extraction data
const extractLineItems = () => {
const lineItems = [];
allDocuments.forEach((doc, docIndex) => {
if (doc.extraction) {
try {
const extractionData = JSON.parse(doc.extraction);
if (extractionData.net_amounts_and_tax) {
extractionData.net_amounts_and_tax.forEach((item, itemIndex) => {
lineItems.push({
id: `${docIndex}-${itemIndex}`,
document: doc.type === 'pdf' ? 'PDF Dokument' : 'Verknüpftes Dokument',
documentIndex: docIndex + 1,
netAmount: item.net_amount || 0,
taxRate: item.tax_rate || 0,
taxAmount: item.tax_amount || 0,
grossAmount: (item.net_amount || 0) + (item.tax_amount || 0),
currency: extractionData.currency || 'EUR',
invoiceNumber: extractionData.invoice_number || '',
date: extractionData.date || '',
sender: extractionData.sender || ''
});
});
}
} catch (error) {
console.error('Error parsing extraction data:', error);
}
}
});
return lineItems;
};
const lineItems = extractLineItems();
// AG Grid column definitions for line items
const columnDefs = [
{
headerName: 'Dok',
field: 'documentIndex',
width: 50,
cellStyle: { textAlign: 'center' }
},
{
headerName: 'Rechnungsnr.',
field: 'invoiceNumber',
width: 120
},
{
headerName: 'Datum',
field: 'date',
width: 100
},
{
headerName: 'Absender',
field: 'sender',
width: 150,
tooltipField: 'sender'
},
{
headerName: 'Netto',
field: 'netAmount',
width: 80,
type: 'rightAligned',
valueFormatter: (params) => {
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: params.data.currency || 'EUR'
}).format(params.value);
}
},
{
headerName: 'MwSt %',
field: 'taxRate',
width: 70,
type: 'rightAligned',
valueFormatter: (params) => `${params.value}%`
},
{
headerName: 'MwSt',
field: 'taxAmount',
width: 80,
type: 'rightAligned',
valueFormatter: (params) => {
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: params.data.currency || 'EUR'
}).format(params.value);
}
},
{
headerName: 'Brutto',
field: 'grossAmount',
width: 90,
type: 'rightAligned',
valueFormatter: (params) => {
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: params.data.currency || 'EUR'
}).format(params.value);
}
}
];
const defaultColDef = {
sortable: true,
filter: false,
resizable: true,
suppressSizeToFit: false
};
return (
<>
<Box
sx={{
display: 'flex',
gap: 0.5,
alignItems: 'center',
height: '100%',
width: '100%',
cursor: 'pointer',
'&:hover': {
backgroundColor: 'rgba(0, 0, 0, 0.04)'
}
}}
onClick={handleClick}
>
{hasDocuments ? (
<>
<PdfIcon sx={{ fontSize: 14, color: '#d32f2f' }} />
{totalCount > 1 && (
<Box sx={{
fontSize: '8px',
color: '#d32f2f',
fontWeight: 'bold',
position: 'relative',
backgroundColor: 'white',
borderRadius: '50%',
width: 12,
height: 12,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: '1px solid #d32f2f',
marginLeft: '-6px'
}}>
{totalCount}
</Box>
)}
</>
) : (
<Box sx={{
fontSize: '8px',
color: '#999',
fontWeight: 'normal',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
</Box>
)}
</Box>
<Dialog open={dialogOpen} onClose={handleClose} maxWidth="lg" fullWidth>
<DialogTitle>
{hasDocuments ? `Dokumente (${totalCount})` : 'Dokumentinformationen'}
</DialogTitle>
<DialogContent sx={{ p: 0 }}>
{error && (
<Alert severity="error" sx={{ m: 2 }} onClose={() => setError(null)}>
{error}
</Alert>
)}
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
<Tabs value={tabValue} onChange={handleTabChange}>
<Tab label="Dokumente" />
<Tab label={`Buchungen (${lineItems.length})`} />
</Tabs>
</Box>
{tabValue === 0 && (
<Box sx={{ p: 2 }}>
{hasDocuments ? (
<List>
{allDocuments.map((doc, index) => (
<React.Fragment key={index}>
<ListItem>
<ListItemIcon>
<PdfIcon sx={{ color: '#d32f2f' }} />
</ListItemIcon>
<Box sx={{ flex: 1 }}>
<Typography variant="subtitle2">
{doc.type === 'pdf' ? 'PDF Dokument' : 'Verknüpftes Dokument'} #{index + 1}
</Typography>
{doc.datevlink && (
<Typography variant="body2" color="textSecondary">
DATEV Link: {doc.datevlink}
</Typography>
)}
{doc.note && (
<Typography variant="body2" color="textSecondary">
Notiz: {doc.note}
</Typography>
)}
<Box sx={{ mt: 1, display: 'flex', gap: 1 }}>
<Button
size="small"
startIcon={<OpenIcon />}
onClick={() => handleOpenPdf(doc)}
variant="outlined"
>
PDF öffnen
</Button>
{doc.datevlink && (
<Button
size="small"
startIcon={<CopyIcon />}
onClick={() => handleCopyDatevLink(doc.datevlink)}
variant="outlined"
>
DATEV Link kopieren
</Button>
)}
</Box>
</Box>
</ListItem>
{index < allDocuments.length - 1 && <Divider />}
</React.Fragment>
))}
</List>
) : (
<Box sx={{ textAlign: 'center', py: 4 }}>
<PdfIcon sx={{ fontSize: 48, color: '#ccc', mb: 2 }} />
<Typography variant="h6" color="textSecondary" gutterBottom>
Keine Dokumente verfügbar
</Typography>
<Typography variant="body2" color="textSecondary">
Für diese Transaktion sind keine PDF-Dokumente oder Verknüpfungen vorhanden.
</Typography>
</Box>
)}
</Box>
)}
{tabValue === 1 && (
<Box sx={{ p: 2, height: 400 }}>
{lineItems.length > 0 ? (
<div style={{ height: '100%', width: '100%' }}>
<AgGridReact
columnDefs={columnDefs}
rowData={lineItems}
defaultColDef={defaultColDef}
suppressRowTransform={true}
rowHeight={35}
headerHeight={35}
domLayout="normal"
/>
</div>
) : (
<Box sx={{ textAlign: 'center', py: 4 }}>
<Typography variant="h6" color="textSecondary" gutterBottom>
Keine Buchungsdaten verfügbar
</Typography>
<Typography variant="body2" color="textSecondary">
{hasDocuments
? 'In den vorhandenen Dokumenten wurden keine Buchungsdaten gefunden.'
: 'Keine Dokumente vorhanden, daher keine Buchungsdaten verfügbar.'
}
</Typography>
</Box>
)}
</Box>
)}
</DialogContent>
<DialogActions>
<Button onClick={handleClose}>Schließen</Button>
</DialogActions>
</Dialog>
</>
);
};
export default DocumentRenderer;

View File

@@ -2,6 +2,30 @@ import React from 'react';
const JtlRenderer = (params) => {
const hasJTL = params.value;
// Handle undefined state (database unavailable)
if (hasJTL === undefined) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
<div style={{
width: '12px',
height: '12px',
borderRadius: '50%',
backgroundColor: '#fff3cd',
border: '1px solid #ffc107',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '8px',
color: '#856404',
fontWeight: 'bold'
}}>
?
</div>
</div>
);
}
const backgroundColor = hasJTL ? '#388e3c' : '#f5f5f5';
const border = hasJTL ? 'none' : '1px solid #ccc';

View File

@@ -1,9 +1,39 @@
import React from 'react';
const RecipientRenderer = (params) => {
const isIbanColumn = params.colDef.field === 'Kontonummer/IBAN';
const value = params.value;
const handleClick = (event) => {
if (isIbanColumn && value && params.api) {
// Stop event propagation to prevent row selection
event.stopPropagation();
// Apply filter to IBAN column using the custom IbanSelectionFilter format
const currentFilterModel = params.api.getFilterModel();
params.api.setFilterModel({
...currentFilterModel,
'Kontonummer/IBAN': {
filterType: 'iban-selection',
values: [value]
}
});
}
};
return (
<span style={{ fontSize: '0.7rem', lineHeight: '1.2' }}>
{params.value}
<span
style={{
fontSize: '0.7rem',
lineHeight: '1.2',
cursor: isIbanColumn && value ? 'pointer' : 'default',
color: isIbanColumn && value ? '#1976d2' : 'inherit',
textDecoration: isIbanColumn && value ? 'underline' : 'none'
}}
onClick={isIbanColumn && value ? handleClick : undefined}
title={isIbanColumn && value ? `Nach IBAN "${value}" filtern` : undefined}
>
{value}
</span>
);
};

View File

@@ -0,0 +1,50 @@
import React from 'react';
import { Checkbox } from '@mui/material';
const SelectionRenderer = (params) => {
const { data } = params;
if (!data) return null;
// Create a unique row ID (using row index or a unique field)
const rowId = data.id || params.rowIndex;
// Get selection state and methods from grid context
const context = params.context || {};
const { selectedRows, onSelectionChange } = context;
const isSelected = selectedRows && selectedRows.has && selectedRows.has(rowId);
const handleChange = (event) => {
event.stopPropagation(); // Prevent row click events
if (onSelectionChange) {
onSelectionChange(rowId, data, event.target.checked);
}
};
return (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
width: '100%'
}}
onClick={(e) => e.stopPropagation()} // Prevent row click events
>
<Checkbox
checked={isSelected || false}
onChange={handleChange}
size="small"
sx={{
padding: 0,
'& .MuiSvgIcon-root': {
fontSize: 18
}
}}
/>
</div>
);
};
export default SelectionRenderer;

View File

@@ -1,10 +1,14 @@
import CheckboxFilter from '../filters/CheckboxFilter';
import IbanSelectionFilter from '../filters/IbanSelectionFilter';
import TextHeaderWithFilter from '../headers/TextHeaderWithFilter';
import SelectionHeader from '../headers/SelectionHeader';
import AmountRenderer from '../cellRenderers/AmountRenderer';
import TypeRenderer from '../cellRenderers/TypeRenderer';
import JtlRenderer from '../cellRenderers/JtlRenderer';
import DescriptionRenderer from '../cellRenderers/DescriptionRenderer';
import RecipientRenderer from '../cellRenderers/RecipientRenderer';
import DocumentRenderer from '../cellRenderers/DocumentRenderer';
import SelectionRenderer from '../cellRenderers/SelectionRenderer';
const formatDate = (dateString) => {
if (!dateString) return '';
@@ -16,6 +20,21 @@ const formatDate = (dateString) => {
};
export const getColumnDefs = () => [
{
headerName: '',
field: 'selection',
width: 50,
pinned: 'left',
sortable: false,
filter: false,
resizable: false,
suppressMenu: true,
cellRenderer: SelectionRenderer,
headerComponent: SelectionHeader,
headerComponentParams: {
// These will be set by the parent component
}
},
{
headerName: 'Datum',
field: 'Buchungstag',
@@ -50,6 +69,7 @@ export const getColumnDefs = () => [
field: 'Kontonummer/IBAN',
width: 180,
sortable: true,
filter: IbanSelectionFilter,
headerComponent: TextHeaderWithFilter,
tooltipField: 'Kontonummer/IBAN',
cellRenderer: RecipientRenderer
@@ -79,7 +99,8 @@ export const getColumnDefs = () => [
field: 'typeText',
width: 70,
cellRenderer: TypeRenderer,
sortable: true,
sortable: false,
suppressSorting: true,
filter: CheckboxFilter,
filterParams: {
filterOptions: [
@@ -115,7 +136,8 @@ export const getColumnDefs = () => [
field: 'hasJTL',
width: 70,
cellRenderer: JtlRenderer,
sortable: true,
sortable: false,
suppressSorting: true,
filter: CheckboxFilter,
filterParams: {
filterOptions: [
@@ -146,11 +168,36 @@ export const getColumnDefs = () => [
border: '1px solid #ccc'
},
condition: (fieldValue) => fieldValue === false
},
{
value: 'undefined',
label: 'Unbekannt',
color: 'warning',
dotStyle: {
width: '12px',
height: '12px',
backgroundColor: '#fff3cd',
border: '1px solid #ffc107',
fontSize: '8px',
color: '#856404',
fontWeight: 'bold',
content: '?'
},
condition: (fieldValue) => fieldValue === undefined
}
]
},
floatingFilter: false,
headerComponent: TextHeaderWithFilter
},
{
headerName: 'Dokumente',
field: 'documents',
width: 90,
cellRenderer: DocumentRenderer,
sortable: false,
filter: false,
headerComponent: TextHeaderWithFilter
}
];
@@ -164,10 +211,7 @@ export const defaultColDef = {
export const gridOptions = {
animateRows: true,
rowSelection: {
mode: 'singleRow',
enableClickSelection: true
},
rowSelection: false,
rowBuffer: 10,
// Enable virtualization (default behavior)
suppressRowVirtualisation: false,
@@ -179,5 +223,9 @@ export const gridOptions = {
headerHeight: 40,
// Pagination (optional - can be removed for infinite scrolling)
pagination: false,
paginationPageSize: 100
paginationPageSize: 100,
// Disable cell selection
suppressCellSelection: true,
suppressRowClickSelection: true,
suppressCellFocus: true
};

View File

@@ -0,0 +1,209 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import {
FormControl,
Select,
MenuItem,
Checkbox,
ListItemText,
Box,
Chip,
Button
} from '@mui/material';
export default class IbanSelectionFilter {
constructor() {
this.state = {
selectedValues: [],
availableValues: []
};
// Create the DOM element that AG Grid expects
this.eGui = document.createElement('div');
this.eGui.style.minWidth = '250px';
this.eGui.style.padding = '8px';
}
init(params) {
this.params = params;
this.updateAvailableValues();
this.renderReactComponent();
}
getGui() {
return this.eGui;
}
destroy() {
if (this.reactRoot) {
this.reactRoot.unmount();
this.reactRoot = null;
}
}
updateAvailableValues() {
if (!this.params || !this.params.api) return;
// Get all row data
const allRowData = [];
this.params.api.forEachNode(node => {
if (node.data) {
allRowData.push(node.data);
}
});
// Create a map of IBAN to names, then get unique combinations
const ibanToNameMap = new Map();
allRowData.forEach(row => {
const iban = row['Kontonummer/IBAN'];
const name = row['Beguenstigter/Zahlungspflichtiger'];
if (iban && iban.trim() !== '' && iban !== '0000000000') {
if (!ibanToNameMap.has(iban)) {
ibanToNameMap.set(iban, name || '');
}
}
});
// Convert to array of objects with iban and name
this.availableValues = Array.from(ibanToNameMap.entries()).map(([iban, name]) => ({
iban,
name: name || 'Unbekannt'
}));
}
isFilterActive() {
return this.state.selectedValues.length > 0;
}
doesFilterPass(params) {
const { selectedValues } = this.state;
if (selectedValues.length === 0) return true;
const value = params.data['Kontonummer/IBAN'];
return selectedValues.includes(value);
}
getModel() {
if (!this.isFilterActive()) return null;
return {
filterType: 'iban-selection',
values: this.state.selectedValues
};
}
setModel(model) {
if (!model) {
this.state.selectedValues = [];
} else {
this.state.selectedValues = model.values || [];
}
this.renderReactComponent();
}
handleSelectionChange = (selectedValues) => {
this.state.selectedValues = selectedValues;
this.renderReactComponent();
// Notify AG Grid that filter changed
if (this.params && this.params.filterChangedCallback) {
this.params.filterChangedCallback();
}
};
clearFilter = () => {
this.state.selectedValues = [];
this.renderReactComponent();
if (this.params && this.params.filterChangedCallback) {
this.params.filterChangedCallback();
}
};
renderReactComponent() {
if (!this.reactRoot) {
this.reactRoot = createRoot(this.eGui);
}
const FilterComponent = () => (
<Box sx={{ minWidth: 250 }} className="ag-filter-custom">
<FormControl fullWidth size="small">
<Select
multiple
value={this.state.selectedValues}
onChange={(event) => this.handleSelectionChange(event.target.value)}
displayEmpty
renderValue={(selected) => {
if (selected.length === 0) {
return <em>Alle IBANs</em>;
}
return (
<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 (
<Chip
key={value}
label={ibanData ? `${ibanData.name.slice(0, 15)}...` : value.slice(-4)}
size="small"
color="primary"
variant="outlined"
/>
);
})}
{selected.length > 2 && (
<Chip
label={`+${selected.length - 2} mehr`}
size="small"
color="primary"
variant="outlined"
/>
)}
</Box>
);
}}
MenuProps={{
PaperProps: {
style: {
maxHeight: 300,
width: 300,
},
},
}}
>
{this.availableValues.map((item) => (
<MenuItem key={item.iban} value={item.iban}>
<Checkbox
checked={this.state.selectedValues.indexOf(item.iban) > -1}
size="small"
/>
<ListItemText
primary={item.name}
secondary={item.iban}
primaryTypographyProps={{ fontSize: '0.8rem' }}
secondaryTypographyProps={{ fontSize: '0.7rem', color: 'text.secondary' }}
/>
</MenuItem>
))}
</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>
);
this.reactRoot.render(<FilterComponent />);
}
}

View File

@@ -0,0 +1,64 @@
import React, { useState, useEffect, useImperativeHandle, forwardRef } from 'react';
import { Checkbox } from '@mui/material';
const SelectionHeader = forwardRef((params, ref) => {
const [checkboxState, setCheckboxState] = useState({ checked: false, indeterminate: false });
// Expose updateState method to parent via ref
useImperativeHandle(ref, () => ({
updateState: (selectedRows, displayedRows) => {
const allSelected = displayedRows > 0 && selectedRows.size >= displayedRows;
const someSelected = selectedRows.size > 0;
const indeterminate = someSelected && !allSelected;
setCheckboxState({ checked: allSelected, indeterminate });
}
}));
// Register this component with parent on mount
useEffect(() => {
if (params.api?.setHeaderCheckboxRef) {
params.api.setHeaderCheckboxRef(ref.current || { updateState: (selectedRows, displayedRows) => {
const allSelected = displayedRows > 0 && selectedRows.size >= displayedRows;
const someSelected = selectedRows.size > 0;
const indeterminate = someSelected && !allSelected;
setCheckboxState({ checked: allSelected, indeterminate });
}});
}
}, [params.api, ref]);
return (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
width: '100%'
}}
>
<Checkbox
size="small"
checked={checkboxState.checked}
indeterminate={checkboxState.indeterminate}
onClick={(event) => {
event.stopPropagation();
const parentComponent = params.api?.fibdashComponent;
if (parentComponent && parentComponent.onSelectAll) {
const { selectedRows } = parentComponent.state;
const shouldSelectAll = selectedRows.size === 0;
parentComponent.onSelectAll(shouldSelectAll);
}
}}
sx={{
padding: 0,
'& .MuiSvgIcon-root': {
fontSize: 18
}
}}
/>
</div>
);
});
export default SelectionHeader;

View File

@@ -43,6 +43,17 @@ class HeaderComponent extends Component {
}
onFilterChanged = () => {
// Check if this column's filter was cleared and reset local state
if (this.props.params && this.props.params.api && this.props.params.column) {
const colId = this.props.params.column.colId;
const filterModel = this.props.params.api.getFilterModel();
// If this column no longer has a filter, clear the local filterValue
if (!filterModel || !filterModel[colId]) {
this.setState({ filterValue: '' });
}
}
// Force re-render to update filter icon color
this.forceUpdate();
};
@@ -134,6 +145,9 @@ class HeaderComponent extends Component {
const isTextColumn = column.colDef.field === 'description' ||
column.colDef.field === 'Beguenstigter/Zahlungspflichtiger';
const showTextFilter = isTextColumn;
// Check if sorting is disabled for this column
const isSortingDisabled = column.colDef.sortable === false || column.colDef.suppressSorting === true;
return (
<Box sx={{
@@ -153,44 +167,46 @@ class HeaderComponent extends Component {
fontWeight: 600,
fontSize: '13px',
color: '#212529',
cursor: 'pointer',
cursor: isSortingDisabled ? 'default' : 'pointer',
minWidth: 'fit-content',
marginRight: '6px',
userSelect: 'none',
'&:hover': {
color: '#0969da'
color: isSortingDisabled ? '#212529' : '#0969da'
}
}}
onClick={(e) => this.onSortRequested(sortDirection === 'asc' ? 'desc' : 'asc', e)}
onClick={isSortingDisabled ? undefined : (e) => this.onSortRequested(sortDirection === 'asc' ? 'desc' : 'asc', e)}
>
{displayName}
</Typography>
{/* Sort Icons - Always visible */}
<Box sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
marginRight: '6px',
gap: 0
}}>
<SortUpIcon sx={{
fontSize: '10px',
color: sortDirection === 'asc' ? '#0969da' : '#ccc',
opacity: sortDirection === 'asc' ? 1 : 0.5,
cursor: 'pointer'
}}
onClick={(e) => this.onSortRequested('asc', e)}
/>
<SortDownIcon sx={{
fontSize: '10px',
color: sortDirection === 'desc' ? '#0969da' : '#ccc',
opacity: sortDirection === 'desc' ? 1 : 0.5,
cursor: 'pointer'
}}
onClick={(e) => this.onSortRequested('desc', e)}
/>
</Box>
{/* Sort Icons - Only visible when sorting is enabled */}
{!isSortingDisabled && (
<Box sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
marginRight: '6px',
gap: 0
}}>
<SortUpIcon sx={{
fontSize: '10px',
color: sortDirection === 'asc' ? '#0969da' : '#ccc',
opacity: sortDirection === 'asc' ? 1 : 0.5,
cursor: 'pointer'
}}
onClick={(e) => this.onSortRequested('asc', e)}
/>
<SortDownIcon sx={{
fontSize: '10px',
color: sortDirection === 'desc' ? '#0969da' : '#ccc',
opacity: sortDirection === 'desc' ? 1 : 0.5,
cursor: 'pointer'
}}
onClick={(e) => this.onSortRequested('desc', e)}
/>
</Box>
)}
{/* Filter Input - only for text columns */}
{showTextFilter && (

View File

@@ -1,6 +1,7 @@
export const processTransactionData = (transactions) => {
return transactions.map(transaction => ({
return transactions.map((transaction, index) => ({
...transaction,
id: `row-${index}-${transaction.Buchungstag}-${transaction.numericAmount}`, // Unique ID
description: transaction['Verwendungszweck'] || transaction['Buchungstext'],
type: transaction.numericAmount >= 0 ? 'Income' : 'Expense',
isIncome: transaction.numericAmount >= 0,
@@ -8,16 +9,39 @@ export const processTransactionData = (transactions) => {
}));
};
export const getRowStyle = (params) => {
export const getRowStyle = (params, selectedRows) => {
const rowId = params.data?.id || params.rowIndex;
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) {
return {
backgroundColor: '#ffebee',
backgroundColor: isSelected ? '#e3f2fd' : '#ffebee',
borderLeft: '4px solid #f44336'
};
}
if (isSelected) {
console.log('Row is selected but using CSS class instead:', rowId);
return null; // Don't apply inline styles for selected rows
}
// Return null to allow CSS classes (ag-row-odd/ag-row-even) to handle alternating colors
return null;
};
export const getRowClass = (params, selectedRows) => {
const rowId = params.data?.id || params.rowIndex;
const isSelected = selectedRows && selectedRows.has && selectedRows.has(rowId);
if (isSelected) {
return 'selected-row';
}
return '';
};
export const getMonthName = (monthYear) => {
if (!monthYear) return '';
const [year, month] = monthYear.split('-');