This commit is contained in:
sebseb7
2025-07-20 04:46:01 +02:00
parent 102a4ec9ff
commit 9a0c985bfa
13 changed files with 1473 additions and 155 deletions

View File

@@ -5,6 +5,58 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FibDash</title> <title>FibDash</title>
<meta name="google-signin-client_id" content="%REACT_APP_GOOGLE_CLIENT_ID%"> <meta name="google-signin-client_id" content="%REACT_APP_GOOGLE_CLIENT_ID%">
<style>
/* Position filter icon on right side for Betrag column (right-aligned data) - NO DEBUG COLORS */
.ag-header-cell[col-id="numericAmount"] {
/* Removed: background-color: rgba(255, 0, 0, 0.1) !important; */
}
/* Fix spacing for Betrag column - prevent text/icon overlap */
.ag-header-cell[col-id="numericAmount"] .ag-header-cell-comp-wrapper {
justify-content: space-between !important;
padding-right: 8px !important;
}
/* Ensure proper spacing between text and icons in Betrag column */
.ag-header-cell[col-id="numericAmount"] .ag-header-cell-text {
margin-right: 8px !important;
}
/* Position filter icon on the right for Betrag column */
.ag-header-cell[col-id="numericAmount"] .ag-header-menu-button {
margin-left: auto !important;
flex-shrink: 0 !important;
}
/* Only style our custom filter components to match ag-grid defaults */
.ag-filter-custom {
padding: 16px;
min-width: 200px;
}
</style>
<script>
// Debug: Log AG Grid header structure after page loads
window.addEventListener('load', () => {
setTimeout(() => {
const betragHeader = document.querySelector('.ag-header-cell[col-id="numericAmount"]');
if (betragHeader) {
console.log('Found Betrag header:', betragHeader);
console.log('Header classes:', betragHeader.className);
console.log('Header HTML:', betragHeader.innerHTML);
} else {
console.log('Could not find Betrag header with col-id="numericAmount"');
// Try to find it by text content
const allHeaders = document.querySelectorAll('.ag-header-cell');
console.log('All headers found:', allHeaders.length);
allHeaders.forEach((header, index) => {
console.log(`Header ${index}:`, header.textContent, header.getAttribute('col-id'));
});
}
}, 2000);
});
</script>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -126,13 +126,15 @@ class App extends Component {
</Toolbar> </Toolbar>
</AppBar> </AppBar>
<Container maxWidth="xl" sx={{ mt: 4 }}> <Box sx={{ height: 'calc(100vh - 64px)', display: 'flex', flexDirection: 'column' }}>
{isAuthenticated ? ( <Container maxWidth="xl" sx={{ mt: 4, flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
<DataViewer user={user} /> {isAuthenticated ? (
) : ( <DataViewer user={user} />
<Login onLogin={this.handleLogin} /> ) : (
)} <Login onLogin={this.handleLogin} />
</Container> )}
</Container>
</Box>
</ThemeProvider> </ThemeProvider>
); );
} }

View File

@@ -118,21 +118,25 @@ class DataViewer extends Component {
} }
return ( return (
<Box> <Box sx={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>
<SummaryHeader <Box sx={{ flexShrink: 0 }}>
months={months} <SummaryHeader
selectedMonth={selectedMonth} months={months}
summary={summary} selectedMonth={selectedMonth}
loading={loading} summary={summary}
onMonthChange={this.handleMonthChange} loading={loading}
onDownloadDatev={this.downloadDatev} onMonthChange={this.handleMonthChange}
/> onDownloadDatev={this.downloadDatev}
/>
</Box>
<TransactionsTable <Box sx={{ flex: 1, minHeight: 0 }}>
transactions={transactions} <TransactionsTable
selectedMonth={selectedMonth} transactions={transactions}
loading={loading} selectedMonth={selectedMonth}
/> loading={loading}
/>
</Box>
</Box> </Box>
); );
} }

View File

@@ -59,7 +59,12 @@ class Login extends Component {
// Check if it's an authorization error // Check if it's an authorization error
if (error.message) { if (error.message) {
if (error.message.includes('Access denied') || if (error.message.includes('FibDash Service nicht verfügbar') ||
error.message.includes('FibDash Service nicht erreichbar')) {
errorMessage = `🔧 ${error.message}`;
} else if (error.message.includes('FibDash Server Fehler')) {
errorMessage = `⚠️ ${error.message}`;
} else if (error.message.includes('Access denied') ||
error.message.includes('not authorized') || error.message.includes('not authorized') ||
error.message.includes('403')) { error.message.includes('403')) {
errorMessage = '🚫 Zugriff verweigert: Ihre E-Mail-Adresse ist nicht autorisiert. Versuchen Sie, sich mit einem anderen Google-Konto anzumelden.'; errorMessage = '🚫 Zugriff verweigert: Ihre E-Mail-Adresse ist nicht autorisiert. Versuchen Sie, sich mit einem anderen Google-Konto anzumelden.';

View File

@@ -9,9 +9,14 @@ import {
InputLabel, InputLabel,
Grid, Grid,
Button, Button,
ListSubheader,
Divider,
} from '@mui/material'; } from '@mui/material';
import { import {
Download as DownloadIcon, Download as DownloadIcon,
CalendarToday as CalendarIcon,
DateRange as QuarterIcon,
Event as YearIcon,
} from '@mui/icons-material'; } from '@mui/icons-material';
class SummaryHeader extends Component { class SummaryHeader extends Component {
@@ -29,6 +34,61 @@ class SummaryHeader extends Component {
return date.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' }); return date.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' });
}; };
getQuarterName = (year, quarter) => {
return `Q${quarter} ${year}`;
};
getYearName = (year) => {
return `Jahr ${year}`;
};
generateTimeRangeOptions = (months) => {
if (!months || months.length === 0) return { months: [], quarters: [], years: [] };
// Extract years from months
const years = [...new Set(months.map(month => month.split('-')[0]))].sort().reverse();
// Generate quarters
const quarters = [];
years.forEach(year => {
for (let q = 4; q >= 1; q--) {
const quarterMonths = this.getQuarterMonths(year, q);
// Only include quarter if we have data for at least one month in it
if (quarterMonths.some(month => months.includes(month))) {
quarters.push({ year, quarter: q, value: `${year}-Q${q}` });
}
}
});
return {
months: months,
quarters: quarters,
years: years.map(year => ({ year, value: year }))
};
};
getQuarterMonths = (year, quarter) => {
const startMonth = (quarter - 1) * 3 + 1;
return [
`${year}-${startMonth.toString().padStart(2, '0')}`,
`${year}-${(startMonth + 1).toString().padStart(2, '0')}`,
`${year}-${(startMonth + 2).toString().padStart(2, '0')}`
];
};
getSelectedDisplayName = (selectedValue, timeRangeOptions) => {
if (!selectedValue) return '';
if (selectedValue.includes('-Q')) {
const [year, quarterPart] = selectedValue.split('-Q');
return this.getQuarterName(year, quarterPart);
} else if (selectedValue.length === 4) {
return this.getYearName(selectedValue);
} else {
return this.getMonthName(selectedValue);
}
};
render() { render() {
const { const {
months, months,
@@ -41,22 +101,109 @@ class SummaryHeader extends Component {
if (!summary) return null; if (!summary) return null;
const timeRangeOptions = this.generateTimeRangeOptions(months);
return ( return (
<Paper elevation={1} sx={{ p: { xs: 1.5, sm: 2 }, mb: 2 }}> <Paper elevation={1} sx={{ p: { xs: 1.5, sm: 2 }, mb: 2 }}>
<Grid container alignItems="center" spacing={{ xs: 1, sm: 2 }}> <Grid container alignItems="center" spacing={{ xs: 1, sm: 2 }}>
<Grid item xs={12} md={3}> <Grid item xs={12} md={3}>
<FormControl fullWidth size="small"> <FormControl fullWidth size="small">
<InputLabel>Monat</InputLabel> <InputLabel>Zeitraum</InputLabel>
<Select <Select
value={selectedMonth} value={selectedMonth}
onChange={onMonthChange} onChange={onMonthChange}
label="Month" label="Zeitraum"
sx={{
'& .MuiSelect-select': {
display: 'flex',
alignItems: 'center',
gap: 1
}
}}
> >
{months.map((month) => ( {/* Months Section */}
<MenuItem key={month} value={month}> <ListSubheader sx={{
color: 'primary.main',
fontWeight: 'bold',
display: 'flex',
alignItems: 'center',
gap: 1,
backgroundColor: 'background.paper',
lineHeight: '36px'
}}>
<CalendarIcon fontSize="small" />
Monate
</ListSubheader>
{timeRangeOptions.months.map((month) => (
<MenuItem
key={month}
value={month}
sx={{
pl: 4,
'&:hover': { backgroundColor: 'action.hover' }
}}
>
{this.getMonthName(month)} {this.getMonthName(month)}
</MenuItem> </MenuItem>
))} ))}
<Divider sx={{ my: 1 }} />
{/* Quarters Section */}
<ListSubheader sx={{
color: 'secondary.main',
fontWeight: 'bold',
display: 'flex',
alignItems: 'center',
gap: 1,
backgroundColor: 'background.paper',
lineHeight: '36px'
}}>
<QuarterIcon fontSize="small" />
Quartale
</ListSubheader>
{timeRangeOptions.quarters.map((quarter) => (
<MenuItem
key={quarter.value}
value={quarter.value}
sx={{
pl: 4,
color: 'secondary.main',
'&:hover': { backgroundColor: 'secondary.light', color: 'secondary.contrastText' }
}}
>
{this.getQuarterName(quarter.year, quarter.quarter)}
</MenuItem>
))}
<Divider sx={{ my: 1 }} />
{/* Years Section */}
<ListSubheader sx={{
color: 'success.main',
fontWeight: 'bold',
display: 'flex',
alignItems: 'center',
gap: 1,
backgroundColor: 'background.paper',
lineHeight: '36px'
}}>
<YearIcon fontSize="small" />
Jahre
</ListSubheader>
{timeRangeOptions.years.map((year) => (
<MenuItem
key={year.value}
value={year.value}
sx={{
pl: 4,
color: 'success.main',
'&:hover': { backgroundColor: 'success.light', color: 'success.contrastText' }
}}
>
{this.getYearName(year.year)}
</MenuItem>
))}
</Select> </Select>
</FormControl> </FormControl>
</Grid> </Grid>

View File

@@ -1,18 +1,170 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import { AgGridReact } from 'ag-grid-react';
import { ModuleRegistry, AllCommunityModule, themeQuartz } from 'ag-grid-community';
// Register AG Grid modules
ModuleRegistry.registerModules([AllCommunityModule]);
import { import {
Box, Box,
Paper, Paper,
Typography, Typography,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
CircularProgress, CircularProgress,
} from '@mui/material'; } from '@mui/material';
import CheckboxFilter from './filters/CheckboxFilter';
import TextHeaderWithFilter from './headers/TextHeaderWithFilter';
class TransactionsTable extends Component { class TransactionsTable extends Component {
constructor(props) {
super(props);
this.state = {
columnDefs: [
{
headerName: 'Datum',
field: 'Buchungstag',
width: 100,
valueFormatter: (params) => this.formatDate(params.value),
pinned: 'left',
sortable: true,
filter: 'agDateColumnFilter',
floatingFilter: false,
headerComponent: TextHeaderWithFilter
},
{
headerName: 'Beschreibung',
field: 'description',
width: 350,
sortable: true,
headerComponent: TextHeaderWithFilter,
tooltipField: 'description'
},
{
headerName: 'Empfänger/Zahler',
field: 'Beguenstigter/Zahlungspflichtiger',
width: 200,
sortable: true,
headerComponent: TextHeaderWithFilter,
tooltipField: 'Beguenstigter/Zahlungspflichtiger'
},
{
headerName: 'Betrag',
field: 'numericAmount',
width: 120,
cellRenderer: this.AmountRenderer,
sortable: true,
filter: 'agNumberColumnFilter',
floatingFilter: false,
type: 'rightAligned',
headerComponent: TextHeaderWithFilter
},
{
headerName: 'Typ',
field: 'typeText',
width: 70,
cellRenderer: this.TypeRenderer,
sortable: true,
filter: CheckboxFilter,
filterParams: {
filterOptions: [
{
value: 'income',
label: 'Einnahme',
color: 'success',
dotStyle: {
width: '8px',
height: '8px',
backgroundColor: '#388e3c'
},
condition: (fieldValue) => fieldValue === 'Einnahme'
},
{
value: 'expense',
label: 'Ausgabe',
color: 'error',
dotStyle: {
width: '8px',
height: '8px',
backgroundColor: '#d32f2f'
},
condition: (fieldValue) => fieldValue === 'Ausgabe'
}
]
},
floatingFilter: false,
headerComponent: TextHeaderWithFilter
},
{
headerName: 'JTL',
field: 'hasJTL',
width: 70,
cellRenderer: this.JtlRenderer,
sortable: true,
filter: CheckboxFilter,
filterParams: {
filterOptions: [
{
value: 'present',
label: 'Vorhanden',
color: 'success',
dotStyle: {
width: '12px',
height: '12px',
backgroundColor: '#388e3c',
border: 'none',
fontSize: '8px',
color: 'white',
fontWeight: 'bold',
content: '✓'
},
condition: (fieldValue) => fieldValue === true
},
{
value: 'missing',
label: 'Fehlend',
color: 'error',
dotStyle: {
width: '12px',
height: '12px',
backgroundColor: '#f5f5f5',
border: '1px solid #ccc'
},
condition: (fieldValue) => fieldValue === false
}
]
},
floatingFilter: false,
headerComponent: TextHeaderWithFilter
}
],
defaultColDef: {
resizable: true,
sortable: true,
filter: true,
floatingFilter: false,
suppressHeaderMenuButton: false
},
gridOptions: {
animateRows: true,
rowSelection: {
mode: 'singleRow',
enableClickSelection: true
},
rowBuffer: 10,
// Enable virtualization (default behavior)
suppressRowVirtualisation: false,
suppressColumnVirtualisation: false,
// Performance optimizations
suppressChangeDetection: false,
// Row height
rowHeight: 35,
headerHeight: 40,
// Pagination (optional - can be removed for infinite scrolling)
pagination: false,
paginationPageSize: 100
}
};
}
formatAmount = (amount) => { formatAmount = (amount) => {
return new Intl.NumberFormat('de-DE', { return new Intl.NumberFormat('de-DE', {
style: 'currency', style: 'currency',
@@ -36,109 +188,183 @@ class TransactionsTable extends Component {
return date.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' }); return date.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' });
}; };
// Custom cell renderers as React components
AmountRenderer = (params) => {
const amount = params.value;
const color = amount >= 0 ? '#388e3c' : '#d32f2f';
return (
<span style={{ color: color, fontWeight: '600' }}>
{this.formatAmount(amount)}
</span>
);
};
TypeRenderer = (params) => {
const amount = params.data.numericAmount;
const color = amount >= 0 ? '#388e3c' : '#d32f2f';
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
<div style={{ width: '8px', height: '8px', borderRadius: '50%', backgroundColor: color }}></div>
</div>
);
};
JtlRenderer = (params) => {
const hasJTL = params.value;
const backgroundColor = hasJTL ? '#388e3c' : '#f5f5f5';
const border = hasJTL ? 'none' : '1px solid #ccc';
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
<div style={{
width: '12px',
height: '12px',
borderRadius: '50%',
backgroundColor: backgroundColor,
border: border,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '8px',
color: 'white',
fontWeight: 'bold'
}}>
{hasJTL && '✓'}
</div>
</div>
);
};
// Row styling based on JTL status
getRowStyle = (params) => {
if (params.data.isJTLOnly) {
return {
backgroundColor: '#ffebee',
borderLeft: '4px solid #f44336'
};
}
return null;
};
// Process data for AG Grid
processTransactionData = (transactions) => {
return transactions.map(transaction => ({
...transaction,
description: transaction['Verwendungszweck'] || transaction['Buchungstext'],
type: transaction.numericAmount >= 0 ? 'Income' : 'Expense',
isIncome: transaction.numericAmount >= 0,
typeText: transaction.numericAmount >= 0 ? 'Einnahme' : 'Ausgabe'
}));
};
componentDidUpdate(prevProps) {
// Update data without recreating grid when transactions change
if (prevProps.transactions !== this.props.transactions && this.gridApi) {
const processedTransactions = this.props.transactions ? this.processTransactionData(this.props.transactions) : [];
// Use setGridOption to update data while preserving grid state
this.gridApi.setGridOption('rowData', processedTransactions);
}
}
shouldComponentUpdate(nextProps, nextState) {
// Only re-render if loading state changes, not when data changes
// This prevents grid recreation while allowing data updates via componentDidUpdate
return (
this.props.loading !== nextProps.loading ||
this.state !== nextState
);
}
onGridReady = (params) => {
console.log('Grid ready - should only happen once per component lifecycle');
this.gridApi = params.api;
this.gridColumnApi = params.columnApi;
// Set initial data if available
if (this.props.transactions) {
const processedTransactions = this.processTransactionData(this.props.transactions);
this.gridApi.setGridOption('rowData', processedTransactions);
}
// Auto-size columns to fit content
params.api.sizeColumnsToFit();
// Store reference to prevent grid recreation
this.gridInitialized = true;
};
getSelectedDisplayName = (selectedValue) => {
if (!selectedValue) return '';
if (selectedValue.includes('-Q')) {
const [year, quarterPart] = selectedValue.split('-Q');
return `Q${quarterPart} ${year}`;
} else if (selectedValue.length === 4) {
return `Jahr ${selectedValue}`;
} else {
return this.getMonthName(selectedValue);
}
};
render() { render() {
const { transactions, selectedMonth, loading } = this.props; const { selectedMonth, loading } = this.props;
return ( return (
<Paper elevation={2}> <Paper elevation={2} sx={{
<Box sx={{ p: 2 }}> m: 0,
<Typography variant="h6" component="h2" gutterBottom> height: '100%',
Transaktionen für {this.getMonthName(selectedMonth)} display: 'flex',
flexDirection: 'column'
}}>
<Box sx={{ p: 1, flexShrink: 0 }}>
<Typography variant="h6" component="h2" gutterBottom sx={{ m: 0 }}>
Transaktionen für {this.getSelectedDisplayName(selectedMonth)}
</Typography> </Typography>
</Box>
{loading ? (
<Box display="flex" justifyContent="center" p={2}> <Box sx={{ flex: 1, width: '100%', minHeight: 0, position: 'relative' }}>
{loading && (
<Box
sx={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(255, 255, 255, 0.7)',
zIndex: 1000
}}
>
<CircularProgress /> <CircularProgress />
</Box> </Box>
) : (
<TableContainer sx={{ maxHeight: 700 }}>
<Table stickyHeader size="small" sx={{ '& .MuiTableCell-root': { padding: '4px 8px', fontSize: '0.75rem', borderBottom: '1px solid #e0e0e0' } }}>
<TableHead>
<TableRow sx={{ '& .MuiTableCell-root': { backgroundColor: '#f5f5f5', fontWeight: 600 } }}>
<TableCell sx={{ width: 80 }}>Datum</TableCell>
<TableCell sx={{ width: 320 }}>Beschreibung</TableCell>
<TableCell sx={{ width: 180 }}>Empfänger/Zahler</TableCell>
<TableCell align="right" sx={{ width: 100 }}>Betrag</TableCell>
<TableCell sx={{ width: 50 }}>Typ</TableCell>
<TableCell sx={{ width: 50 }}>JTL</TableCell>
</TableRow>
</TableHead>
<TableBody>
{transactions.map((transaction, index) => (
<TableRow
key={index}
hover
sx={{
'&:hover': { backgroundColor: transaction.isJTLOnly ? '#ffebee' : '#f9f9f9' },
'& .MuiTableCell-root': {
padding: '2px 8px',
backgroundColor: transaction.isJTLOnly ? '#ffebee' : 'inherit',
borderLeft: transaction.isJTLOnly ? '4px solid #f44336' : 'none'
}
}}
>
<TableCell sx={{ fontSize: '0.7rem', whiteSpace: 'nowrap' }}>
{this.formatDate(transaction['Buchungstag'])}
</TableCell>
<TableCell sx={{ fontSize: '0.7rem', maxWidth: 320, overflow: 'hidden', textOverflow: 'ellipsis' }}>
{transaction['Verwendungszweck'] || transaction['Buchungstext']}
</TableCell>
<TableCell sx={{ fontSize: '0.7rem', maxWidth: 180, overflow: 'hidden', textOverflow: 'ellipsis' }}>
{transaction['Beguenstigter/Zahlungspflichtiger']}
</TableCell>
<TableCell
align="right"
sx={{
fontSize: '0.7rem',
fontWeight: 600,
color: transaction.numericAmount >= 0 ? '#388e3c' : '#d32f2f',
whiteSpace: 'nowrap'
}}
>
{this.formatAmount(transaction.numericAmount)}
</TableCell>
<TableCell>
<Box
sx={{
width: 8,
height: 8,
borderRadius: '50%',
backgroundColor: transaction.numericAmount >= 0 ? '#388e3c' : '#d32f2f',
margin: 'auto'
}}
/>
</TableCell>
<TableCell>
<Box
sx={{
width: 12,
height: 12,
borderRadius: '50%',
backgroundColor: transaction.hasJTL ? '#388e3c' : '#f5f5f5',
border: transaction.hasJTL ? 'none' : '1px solid #ccc',
margin: 'auto',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
{transaction.hasJTL && (
<Box sx={{
fontSize: '8px',
color: 'white',
fontWeight: 'bold'
}}>
</Box>
)}
</Box>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)} )}
<div style={{ height: '100%', width: '100%' }}>
<AgGridReact
key="transactions-grid" // Stable key prevents recreation
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={this.getRowStyle}
suppressRowTransform={true}
// Use new theming system
theme={themeQuartz}
// Virtualization settings for performance
rowBuffer={10}
suppressRowVirtualisation={false}
suppressColumnVirtualisation={false}
// Additional performance settings
suppressChangeDetection={false}
animateRows={true}
// Maintain state across data updates
maintainColumnOrder={true}
suppressColumnStateEvents={false}
/>
</div>
</Box> </Box>
</Paper> </Paper>
); );

View File

@@ -0,0 +1,181 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import {
FormControl,
FormControlLabel,
Checkbox,
Radio,
RadioGroup,
Box,
Stack
} from '@mui/material';
export default class CheckboxFilter {
constructor() {
this.state = {
filterType: 'all',
showAll: true
};
// Create the DOM element that AG Grid expects
this.eGui = document.createElement('div');
this.eGui.style.minWidth = '200px';
console.log('CheckboxFilter constructor');
}
init(params) {
this.params = params;
console.log('CheckboxFilter init params:', params);
// Get filter options from params
this.options = params.filterOptions || [];
// Render React component into the DOM element
this.renderReactComponent();
}
getGui() {
console.log('getGui called, returning eGui:', this.eGui);
return this.eGui;
}
isFilterActive() {
const { showAll } = this.state;
return !showAll; // Filter is active when not showing all
}
doesFilterPass(params) {
const { showAll, filterType } = this.state;
if (showAll) {
return true; // Show all rows
}
// Get the field value
const fieldValue = params.data[this.params.colDef.field];
// Find the matching option and check its condition
const option = this.options.find(opt => opt.value === filterType);
if (!option) return true;
return option.condition(fieldValue);
}
getModel() {
const { showAll, filterType } = this.state;
if (showAll) {
return null; // No filter applied
}
return {
filterType: 'custom',
type: filterType
};
}
setModel(model) {
if (!model) {
this.state.showAll = true;
this.state.filterType = 'all';
} else {
this.state.showAll = false;
this.state.filterType = model.type || 'all';
}
this.renderReactComponent();
}
handleShowAllChange = (event) => {
this.state.showAll = event.target.checked;
if (this.state.showAll) {
this.state.filterType = 'all';
}
this.renderReactComponent();
this.params.filterChangedCallback();
};
handleTypeChange = (event) => {
this.state.filterType = event.target.value;
this.renderReactComponent();
this.params.filterChangedCallback();
};
destroy() {
if (this.reactRoot) {
this.reactRoot.unmount();
}
}
renderReactComponent() {
const { showAll, filterType } = this.state;
const FilterComponent = () => (
<Box sx={{ minWidth: 200, padding: '8px' }} className="ag-filter-custom">
<Stack direction="column" spacing={0.5}>
{/* Show All Checkbox */}
<FormControlLabel
control={
<Checkbox
checked={showAll}
onChange={this.handleShowAllChange}
color="primary"
size="small"
/>
}
label="Alle anzeigen"
sx={{ margin: 0, '& .MuiFormControlLabel-label': { fontSize: '14px' } }}
/>
{/* Radio Options - disabled when showing all */}
{this.options.map((option) => (
<FormControlLabel
key={option.value}
control={
<Radio
checked={!showAll && filterType === option.value}
onChange={this.handleTypeChange}
value={option.value}
size="small"
color={option.color || "primary"}
disabled={showAll}
/>
}
label={
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{option.dotStyle && (
<div style={{
width: option.dotStyle.width || '8px',
height: option.dotStyle.height || '8px',
borderRadius: '50%',
backgroundColor: option.dotStyle.backgroundColor,
border: option.dotStyle.border || 'none',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: option.dotStyle.fontSize || '8px',
color: option.dotStyle.color || 'transparent',
fontWeight: option.dotStyle.fontWeight || 'normal'
}}>
{option.dotStyle.content || ''}
</div>
)}
{option.label}
</div>
}
sx={{ margin: 0, '& .MuiFormControlLabel-label': { fontSize: '14px' } }}
/>
))}
</Stack>
</Box>
);
// Recreate React root every time to avoid state corruption
if (this.reactRoot) {
this.reactRoot.unmount();
}
this.reactRoot = createRoot(this.eGui);
this.reactRoot.render(<FilterComponent />);
}
}

View File

@@ -0,0 +1,216 @@
import React, { Component } from 'react';
import {
Select,
MenuItem,
FormControl,
InputLabel,
Checkbox,
ListItemText,
Box,
Chip
} from '@mui/material';
class SelectionFilter extends Component {
constructor(props) {
super(props);
this.state = {
selectedValues: [],
isActive: false
};
}
// AG Grid filter interface methods
init = (params) => {
this.params = params;
this.updateAvailableValues();
};
getGui = () => {
return this.eGui;
};
destroy = () => {
// Cleanup if needed
};
componentDidMount() {
// Get unique values from the column data
this.updateAvailableValues();
}
componentDidUpdate(prevProps) {
if (prevProps.api !== this.props.api) {
this.updateAvailableValues();
}
}
updateAvailableValues = () => {
const api = (this.params && this.params.api) || this.props.api;
const colDef = (this.params && this.params.colDef) || this.props.colDef;
if (!api || !colDef) return;
// Get all row data
const allRowData = [];
api.forEachNode(node => {
if (node.data) {
allRowData.push(node.data);
}
});
// Extract unique values from the column
const values = [...new Set(allRowData.map(row => row[colDef.field]))];
this.availableValues = values.filter(val => val != null);
};
isFilterActive = () => {
return this.state.selectedValues.length > 0;
};
doesFilterPass = (params) => {
const { selectedValues } = this.state;
if (selectedValues.length === 0) return true;
const value = params.data[this.props.colDef.field];
return selectedValues.includes(value);
};
getModel = () => {
if (!this.isFilterActive()) return null;
return {
filterType: 'selection',
values: this.state.selectedValues
};
};
setModel = (model) => {
if (!model) {
this.setState({ selectedValues: [], isActive: false });
} else {
this.setState({
selectedValues: model.values || [],
isActive: true
});
}
};
notifyFilterChanged = () => {
// Use the params callback if available, otherwise try props
const callback = (this.params && this.params.filterChangedCallback) ||
this.props.filterChangedCallback ||
this.props.onFilterChanged;
if (callback && typeof callback === 'function') {
callback();
}
};
handleSelectionChange = (event) => {
const values = event.target.value;
this.setState({
selectedValues: values,
isActive: values.length > 0
}, () => {
this.notifyFilterChanged();
});
};
clearFilter = () => {
this.setState({ selectedValues: [], isActive: false }, () => {
this.notifyFilterChanged();
});
};
getDisplayValue = (value) => {
const colDef = (this.params && this.params.colDef) || this.props.colDef;
// Use custom display formatter if provided
if (colDef && colDef.filterParams && colDef.filterParams.valueFormatter) {
return colDef.filterParams.valueFormatter(value);
}
return value;
};
render() {
const { selectedValues } = this.state;
const colDef = (this.params && this.params.colDef) || this.props.colDef;
if (!this.availableValues || !colDef) return null;
const displayValues = this.availableValues.map(val => ({
value: val,
display: this.getDisplayValue(val)
}));
return (
<Box
ref={(el) => this.eGui = el}
sx={{ minWidth: 200 }}
className="ag-filter-custom"
>
<FormControl fullWidth size="small">
<InputLabel>Filter {colDef.headerName}</InputLabel>
<Select
multiple
value={selectedValues}
onChange={this.handleSelectionChange}
label={`Filter ${colDef.headerName}`}
renderValue={(selected) => (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{selected.map((value) => (
<Chip
key={value}
label={this.getDisplayValue(value)}
size="small"
color="primary"
variant="outlined"
/>
))}
</Box>
)}
MenuProps={{
PaperProps: {
style: {
maxHeight: 300,
width: 250,
},
},
}}
>
{displayValues.map((item) => (
<MenuItem key={item.value} value={item.value}>
<Checkbox
checked={selectedValues.indexOf(item.value) > -1}
size="small"
/>
<ListItemText primary={item.display} />
</MenuItem>
))}
</Select>
</FormControl>
{selectedValues.length > 0 && (
<Box sx={{ mt: 1, textAlign: 'right' }}>
<button
onClick={this.clearFilter}
style={{
background: 'none',
border: 'none',
color: '#1976d2',
cursor: 'pointer',
fontSize: '12px',
textDecoration: 'underline'
}}
>
Clear Filter
</button>
</Box>
)}
</Box>
);
}
}
export default SelectionFilter;

View File

@@ -0,0 +1,331 @@
import React, { Component } from 'react';
import { createRoot } from 'react-dom/client';
import {
Box,
TextField,
Typography,
IconButton
} from '@mui/material';
import {
FilterList as FilterIcon,
ArrowUpward as SortUpIcon,
ArrowDownward as SortDownIcon
} from '@mui/icons-material';
class HeaderComponent extends Component {
constructor(props) {
super(props);
this.state = {
filterValue: '',
sortDirection: null,
menuOpen: false
};
}
componentDidMount() {
// Get initial sort direction
if (this.props.params && this.props.params.column) {
const sort = this.props.params.column.getSort();
this.setState({ sortDirection: sort });
}
// Listen for filter changes to update icon color
if (this.props.params && this.props.params.api) {
this.props.params.api.addEventListener('filterChanged', this.onFilterChanged);
}
}
componentWillUnmount() {
// Clean up event listener
if (this.props.params && this.props.params.api) {
this.props.params.api.removeEventListener('filterChanged', this.onFilterChanged);
}
}
onFilterChanged = () => {
// Force re-render to update filter icon color
this.forceUpdate();
};
isFilterActive = () => {
if (!this.props.params || !this.props.params.api || !this.props.params.column) {
return false;
}
const filterModel = this.props.params.api.getFilterModel();
const colId = this.props.params.column.colId;
// Check if this column has an active filter
return filterModel && filterModel[colId] &&
(filterModel[colId].filter !== '' || filterModel[colId].filterType === 'type');
};
onSortRequested = (order, event) => {
if (this.props.params && this.props.params.setSort) {
this.props.params.setSort(order, event.shiftKey);
this.setState({ sortDirection: order });
}
};
onFilterIconClick = (event) => {
const { params } = this.props;
try {
if (this.state.menuOpen) {
// Menu is open, try to close it
if (params && params.api) {
params.api.hidePopupMenu();
}
this.setState({ menuOpen: false });
} else {
// Menu is closed, open it
if (params && params.showColumnMenu) {
params.showColumnMenu(event.target);
this.setState({ menuOpen: true });
} else if (params && params.api) {
// Alternative method using grid API
params.api.showColumnMenuAfterButtonClick(params.column, event.target);
this.setState({ menuOpen: true });
} else {
console.warn('Filter menu not available - params:', params);
}
}
} catch (error) {
console.error('Error toggling filter menu:', error);
this.setState({ menuOpen: false });
}
};
handleFilterChange = (event) => {
const value = event.target.value;
this.setState({ filterValue: value });
// Apply filter to the column
if (this.props.params && this.props.params.api && this.props.params.column) {
const colId = this.props.params.column.colId;
if (value.trim() === '') {
// Clear the filter when input is empty
const currentFilterModel = this.props.params.api.getFilterModel();
delete currentFilterModel[colId];
this.props.params.api.setFilterModel(currentFilterModel);
} else {
// Apply filter when input has value
this.props.params.api.setFilterModel({
...this.props.params.api.getFilterModel(),
[colId]: {
type: 'contains',
filter: value
}
});
}
}
};
render() {
const { filterValue, sortDirection } = this.state;
const { params } = this.props;
if (!params) return null;
const { displayName, column } = params;
// Only show text input for text columns (Beschreibung, Empfänger/Zahler)
const isTextColumn = column.colDef.field === 'description' ||
column.colDef.field === 'Beguenstigter/Zahlungspflichtiger';
const showTextFilter = isTextColumn;
return (
<Box sx={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
padding: '0 8px',
backgroundColor: '#f8f9fa',
borderBottom: '1px solid #dee2e6',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'
}}>
{/* Column Title with proper AG Grid styling */}
<Typography
variant="body2"
sx={{
fontWeight: 600,
fontSize: '13px',
color: '#212529',
cursor: 'pointer',
minWidth: 'fit-content',
marginRight: '6px',
userSelect: 'none',
'&:hover': {
color: '#0969da'
}
}}
onClick={(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>
{/* Filter Input - only for text columns */}
{showTextFilter && (
<TextField
size="small"
variant="outlined"
placeholder="Filter..."
value={filterValue}
onChange={this.handleFilterChange}
sx={{
flex: 1,
marginRight: '6px',
'& .MuiOutlinedInput-root': {
height: '24px',
fontSize: '12px',
backgroundColor: '#fff',
'& fieldset': {
borderColor: '#ced4da',
borderWidth: '1px'
},
'&:hover fieldset': {
borderColor: '#adb5bd'
},
'&.Mui-focused fieldset': {
borderColor: '#0969da',
borderWidth: '2px'
}
},
'& .MuiOutlinedInput-input': {
padding: '4px 8px',
fontFamily: 'inherit'
}
}}
/>
)}
{/* Spacer for non-text columns */}
{!showTextFilter && <Box sx={{ flex: 1 }} />}
{/* Filter Menu Icon - Larger hit zone with active state */}
<div
className="ag-header-menu-button"
onClick={this.onFilterIconClick}
style={{
cursor: 'pointer',
padding: '8px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minWidth: '24px',
minHeight: '24px',
borderRadius: '4px',
backgroundColor: this.isFilterActive() ? '#e3f2fd' : 'transparent'
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = this.isFilterActive() ? '#bbdefb' : '#f0f0f0';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = this.isFilterActive() ? '#e3f2fd' : 'transparent';
}}
>
<span
className="ag-icon ag-icon-filter"
role="presentation"
unselectable="on"
style={{
color: this.isFilterActive() ? '#1976d2' : '#666'
}}
></span>
</div>
</Box>
);
}
}
export default class TextHeaderWithFilter {
constructor() {
// Create the DOM element that AG Grid expects
this.eGui = document.createElement('div');
this.eGui.style.width = '100%';
this.eGui.style.height = '100%';
this.eGui.style.display = 'flex';
this.eGui.style.flexDirection = 'column';
console.log('TextHeaderWithFilter constructor');
}
init(params) {
this.params = params;
console.log('TextHeaderWithFilter init params:', params);
// Listen for menu close events to keep state in sync
if (params.api) {
params.api.addEventListener('popupMenuVisibleChanged', (event) => {
if (!event.visible && this.headerComponent) {
this.headerComponent.setState({ menuOpen: false });
}
});
}
// Render React component into the DOM element
this.renderReactComponent();
}
getGui() {
console.log('TextHeaderWithFilter getGui called');
return this.eGui;
}
destroy() {
// Use setTimeout to avoid unmounting during render
setTimeout(() => {
if (this.reactRoot) {
this.reactRoot.unmount();
this.reactRoot = null;
}
}, 0);
}
renderReactComponent() {
// Create React root if it doesn't exist
if (!this.reactRoot) {
this.reactRoot = createRoot(this.eGui);
}
// Use setTimeout to avoid synchronous render during React render cycle
setTimeout(() => {
if (this.reactRoot) {
this.reactRoot.render(
<HeaderComponent
ref={(ref) => { this.headerComponent = ref; }}
params={this.params}
/>
);
}
}, 0);
}
}

View File

@@ -18,13 +18,32 @@ class AuthService {
if (!response.ok) { if (!response.ok) {
const errorData = await response.json().catch(() => ({})); const errorData = await response.json().catch(() => ({}));
console.log('Server error response:', errorData); console.log('Server error response:', errorData);
const errorMessage = errorData.message || errorData.error || `HTTP ${response.status}: Login failed`;
throw new Error(errorMessage); // Handle different types of errors with clearer messages
if (response.status === 502 || response.status === 503) {
throw new Error('FibDash Service nicht verfügbar - Bitte versuchen Sie es später erneut');
} else if (response.status === 500) {
throw new Error('FibDash Server Fehler - Bitte kontaktieren Sie den Administrator');
} else if (response.status === 403) {
const message = errorData.message || 'Zugriff verweigert';
throw new Error(message);
} else if (response.status === 401) {
throw new Error('Google-Anmeldung fehlgeschlagen - Bitte versuchen Sie es erneut');
} else {
const errorMessage = errorData.message || errorData.error || `HTTP ${response.status}: Unbekannter Fehler`;
throw new Error(errorMessage);
}
} }
return await response.json(); return await response.json();
} catch (error) { } catch (error) {
console.error('Google login error:', error); console.error('Google login error:', error);
// Handle network errors (when backend is completely unreachable)
if (error instanceof TypeError && error.message.includes('fetch')) {
throw new Error('FibDash Service nicht erreichbar - Prüfen Sie Ihre Internetverbindung oder versuchen Sie es später erneut');
}
throw error; throw error;
} }
} }
@@ -39,12 +58,23 @@ class AuthService {
}); });
if (!response.ok) { if (!response.ok) {
// Don't clear token for service unavailability - just return null
if (response.status === 502 || response.status === 503) {
console.warn('FibDash Service temporarily unavailable during token verification');
return null;
}
return null; return null;
} }
const data = await response.json(); const data = await response.json();
return data.user; return data.user;
} catch (error) { } catch (error) {
// Handle network errors gracefully - don't clear token for temporary network issues
if (error instanceof TypeError && error.message.includes('fetch')) {
console.warn('Network error during token verification - service may be unavailable');
return null;
}
console.error('Token verification error:', error); console.error('Token verification error:', error);
return null; return null;
} }

31
package-lock.json generated
View File

@@ -13,6 +13,8 @@
"@emotion/styled": "^11.11.0", "@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.14.0", "@mui/icons-material": "^5.14.0",
"@mui/material": "^5.14.0", "@mui/material": "^5.14.0",
"ag-grid-community": "^34.0.2",
"ag-grid-react": "^34.0.2",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.0.0", "dotenv": "^16.0.0",
"express": "^4.18.0", "express": "^4.18.0",
@@ -3148,6 +3150,35 @@
"acorn": "^8.14.0" "acorn": "^8.14.0"
} }
}, },
"node_modules/ag-charts-types": {
"version": "12.0.2",
"resolved": "https://registry.npmjs.org/ag-charts-types/-/ag-charts-types-12.0.2.tgz",
"integrity": "sha512-AWM1Y+XW+9VMmV3AbzdVEnreh/I2C9Pmqpc2iLmtId3Xbvmv7O56DqnuDb9EXjK5uPxmyUerTP+utL13UGcztw==",
"license": "MIT"
},
"node_modules/ag-grid-community": {
"version": "34.0.2",
"resolved": "https://registry.npmjs.org/ag-grid-community/-/ag-grid-community-34.0.2.tgz",
"integrity": "sha512-hVJp5vrmwHRB10YjfSOVni5YJkO/v+asLjT72S4YnIFSx8lAgyPmByNJgtojk1aJ5h6Up93jTEmGDJeuKiWWLA==",
"license": "MIT",
"dependencies": {
"ag-charts-types": "12.0.2"
}
},
"node_modules/ag-grid-react": {
"version": "34.0.2",
"resolved": "https://registry.npmjs.org/ag-grid-react/-/ag-grid-react-34.0.2.tgz",
"integrity": "sha512-1KBXkTvwtZiYVlSuDzBkiqfHjZgsATOmpLZdAtdmsCSOOOEWai0F9zHHgBuHfyciAE4nrbQWfojkx8IdnwsKFw==",
"license": "MIT",
"dependencies": {
"ag-grid-community": "34.0.2",
"prop-types": "^15.8.1"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/agent-base": { "node_modules/agent-base": {
"version": "7.1.4", "version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",

View File

@@ -24,6 +24,8 @@
"@emotion/styled": "^11.11.0", "@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.14.0", "@mui/icons-material": "^5.14.0",
"@mui/material": "^5.14.0", "@mui/material": "^5.14.0",
"ag-grid-community": "^34.0.2",
"ag-grid-react": "^34.0.2",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.0.0", "dotenv": "^16.0.0",
"express": "^4.18.0", "express": "^4.18.0",

View File

@@ -110,14 +110,48 @@ const getJTLTransactions = async () => {
} }
}; };
// Get transactions for a specific month // Get transactions for a specific time period (month, quarter, or year)
router.get('/transactions/:monthYear', authenticateToken, async (req, res) => { router.get('/transactions/:timeRange', authenticateToken, async (req, res) => {
try { try {
const { monthYear } = req.params; const { timeRange } = req.params;
const transactions = parseCSV(); const transactions = parseCSV();
const monthTransactions = transactions let filteredTransactions = [];
.filter(t => t.monthYear === monthYear) let periodDescription = '';
if (timeRange.includes('-Q')) {
// Quarter format: YYYY-Q1, YYYY-Q2, etc.
const [year, quarterPart] = timeRange.split('-Q');
const quarter = parseInt(quarterPart);
const startMonth = (quarter - 1) * 3 + 1;
const endMonth = startMonth + 2;
filteredTransactions = transactions.filter(t => {
if (!t.monthYear) return false;
const [tYear, tMonth] = t.monthYear.split('-');
const monthNum = parseInt(tMonth);
return tYear === year && monthNum >= startMonth && monthNum <= endMonth;
});
periodDescription = `Q${quarter} ${year}`;
} else if (timeRange.length === 4) {
// Year format: YYYY
filteredTransactions = transactions.filter(t => {
if (!t.monthYear) return false;
const [tYear] = t.monthYear.split('-');
return tYear === timeRange;
});
periodDescription = `Jahr ${timeRange}`;
} else {
// Month format: YYYY-MM
filteredTransactions = transactions.filter(t => t.monthYear === timeRange);
const [year, month] = timeRange.split('-');
const date = new Date(year, month - 1);
periodDescription = date.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' });
}
const monthTransactions = filteredTransactions
.sort((a, b) => b.parsedDate - a.parsedDate); // Newest first .sort((a, b) => b.parsedDate - a.parsedDate); // Newest first
// Get JTL transactions for comparison // Get JTL transactions for comparison
@@ -128,13 +162,34 @@ router.get('/transactions/:monthYear', authenticateToken, async (req, res) => {
console.log('JTL database not available, continuing without JTL data'); console.log('JTL database not available, continuing without JTL data');
} }
// Filter JTL transactions for the selected month // Filter JTL transactions for the selected time period
const [year, month] = monthYear.split('-'); let jtlMonthTransactions = [];
const jtlMonthTransactions = jtlTransactions.filter(jtl => {
const jtlDate = new Date(jtl.dBuchungsdatum); if (timeRange.includes('-Q')) {
return jtlDate.getFullYear() === parseInt(year) && const [year, quarterPart] = timeRange.split('-Q');
jtlDate.getMonth() === parseInt(month) - 1; const quarter = parseInt(quarterPart);
}); const startMonth = (quarter - 1) * 3 + 1;
const endMonth = startMonth + 2;
jtlMonthTransactions = jtlTransactions.filter(jtl => {
const jtlDate = new Date(jtl.dBuchungsdatum);
const jtlMonth = jtlDate.getMonth() + 1; // 0-based to 1-based
return jtlDate.getFullYear() === parseInt(year) &&
jtlMonth >= startMonth && jtlMonth <= endMonth;
});
} else if (timeRange.length === 4) {
jtlMonthTransactions = jtlTransactions.filter(jtl => {
const jtlDate = new Date(jtl.dBuchungsdatum);
return jtlDate.getFullYear() === parseInt(timeRange);
});
} else {
const [year, month] = timeRange.split('-');
jtlMonthTransactions = jtlTransactions.filter(jtl => {
const jtlDate = new Date(jtl.dBuchungsdatum);
return jtlDate.getFullYear() === parseInt(year) &&
jtlDate.getMonth() === parseInt(month) - 1;
});
}
// Add JTL status to each CSV transaction // Add JTL status to each CSV transaction
const transactionsWithJTL = monthTransactions.map(transaction => { const transactionsWithJTL = monthTransactions.map(transaction => {
@@ -199,7 +254,7 @@ router.get('/transactions/:monthYear', authenticateToken, async (req, res) => {
'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),
monthYear: monthYear, monthYear: timeRange,
hasJTL: true, hasJTL: true,
jtlId: jtl.kZahlungsabgleichUmsatz, jtlId: jtl.kZahlungsabgleichUmsatz,
isFromCSV: false, isFromCSV: false,
@@ -229,7 +284,8 @@ router.get('/transactions/:monthYear', authenticateToken, async (req, res) => {
res.json({ res.json({
transactions: allTransactions, transactions: allTransactions,
summary, summary,
monthYear timeRange,
periodDescription
}); });
} catch (error) { } catch (error) {
console.error('Error getting transactions:', error); console.error('Error getting transactions:', error);
@@ -290,25 +346,61 @@ const quote = (str, maxLen = 60) => {
}; };
// DATEV export endpoint // DATEV export endpoint
router.get('/datev/:monthYear', authenticateToken, async (req, res) => { router.get('/datev/:timeRange', authenticateToken, async (req, res) => {
try { try {
const { monthYear } = req.params; const { timeRange } = req.params;
const [year, month] = monthYear.split('-');
// Get transactions for the month // Get transactions for the time period
const transactions = parseCSV(); const transactions = parseCSV();
const monthTransactions = transactions let filteredTransactions = [];
.filter(t => t.monthYear === monthYear) let periodStart, periodEnd, filename;
if (timeRange.includes('-Q')) {
// Quarter format: YYYY-Q1, YYYY-Q2, etc.
const [year, quarterPart] = timeRange.split('-Q');
const quarter = parseInt(quarterPart);
const startMonth = (quarter - 1) * 3 + 1;
const endMonth = startMonth + 2;
filteredTransactions = transactions.filter(t => {
if (!t.monthYear) return false;
const [tYear, tMonth] = t.monthYear.split('-');
const monthNum = parseInt(tMonth);
return tYear === year && monthNum >= startMonth && monthNum <= endMonth;
});
periodStart = `${year}${startMonth.toString().padStart(2, '0')}01`;
periodEnd = new Date(year, endMonth, 0).toISOString().slice(0, 10).replace(/-/g, '');
filename = `DATEV_${year}_Q${quarter}.csv`;
} else if (timeRange.length === 4) {
// Year format: YYYY
filteredTransactions = transactions.filter(t => {
if (!t.monthYear) return false;
const [tYear] = t.monthYear.split('-');
return tYear === timeRange;
});
periodStart = `${timeRange}0101`;
periodEnd = `${timeRange}1231`;
filename = `DATEV_${timeRange}.csv`;
} else {
// Month format: YYYY-MM
const [year, month] = timeRange.split('-');
filteredTransactions = transactions.filter(t => t.monthYear === timeRange);
periodStart = `${year}${month.padStart(2, '0')}01`;
periodEnd = new Date(year, month, 0).toISOString().slice(0, 10).replace(/-/g, '');
filename = `DATEV_${year}_${month.padStart(2, '0')}.csv`;
}
const monthTransactions = filteredTransactions
.sort((a, b) => a.parsedDate - b.parsedDate); // Oldest first for DATEV .sort((a, b) => a.parsedDate - b.parsedDate); // Oldest first for DATEV
if (!monthTransactions.length) { if (!monthTransactions.length) {
return res.status(404).json({ error: 'No transactions found for this month' }); return res.status(404).json({ error: 'No transactions found for this time period' });
} }
// Build DATEV format // Build DATEV format
const periodStart = `${year}${month.padStart(2, '0')}01`;
const periodEnd = new Date(year, month, 0).toISOString().slice(0, 10).replace(/-/g, '');
const header = buildDatevHeader(periodStart, periodEnd); const header = buildDatevHeader(periodStart, periodEnd);
const rows = monthTransactions.map((transaction, index) => { const rows = monthTransactions.map((transaction, index) => {
@@ -335,7 +427,6 @@ router.get('/datev/:monthYear', authenticateToken, async (req, res) => {
const csv = [header, DATEV_COLS, ...rows].join('\r\n'); const csv = [header, DATEV_COLS, ...rows].join('\r\n');
// Set headers for file download // Set headers for file download
const filename = `DATEV_${year}_${month.padStart(2, '0')}.csv`;
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.setHeader('Content-Type', 'text/csv; charset=latin1'); res.setHeader('Content-Type', 'text/csv; charset=latin1');