aggrid
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.';
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
181
client/src/components/filters/CheckboxFilter.js
Normal file
181
client/src/components/filters/CheckboxFilter.js
Normal 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 />);
|
||||||
|
}
|
||||||
|
}
|
||||||
216
client/src/components/filters/SelectionFilter.js
Normal file
216
client/src/components/filters/SelectionFilter.js
Normal 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;
|
||||||
331
client/src/components/headers/TextHeaderWithFilter.js
Normal file
331
client/src/components/headers/TextHeaderWithFilter.js
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
31
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user