aggrid
This commit is contained in:
@@ -5,6 +5,58 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>FibDash</title>
|
||||
<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>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -126,13 +126,15 @@ class App extends Component {
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
<Container maxWidth="xl" sx={{ mt: 4 }}>
|
||||
{isAuthenticated ? (
|
||||
<DataViewer user={user} />
|
||||
) : (
|
||||
<Login onLogin={this.handleLogin} />
|
||||
)}
|
||||
</Container>
|
||||
<Box sx={{ height: 'calc(100vh - 64px)', display: 'flex', flexDirection: 'column' }}>
|
||||
<Container maxWidth="xl" sx={{ mt: 4, flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
{isAuthenticated ? (
|
||||
<DataViewer user={user} />
|
||||
) : (
|
||||
<Login onLogin={this.handleLogin} />
|
||||
)}
|
||||
</Container>
|
||||
</Box>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -118,21 +118,25 @@ class DataViewer extends Component {
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<SummaryHeader
|
||||
months={months}
|
||||
selectedMonth={selectedMonth}
|
||||
summary={summary}
|
||||
loading={loading}
|
||||
onMonthChange={this.handleMonthChange}
|
||||
onDownloadDatev={this.downloadDatev}
|
||||
/>
|
||||
<Box sx={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>
|
||||
<Box sx={{ flexShrink: 0 }}>
|
||||
<SummaryHeader
|
||||
months={months}
|
||||
selectedMonth={selectedMonth}
|
||||
summary={summary}
|
||||
loading={loading}
|
||||
onMonthChange={this.handleMonthChange}
|
||||
onDownloadDatev={this.downloadDatev}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<TransactionsTable
|
||||
transactions={transactions}
|
||||
selectedMonth={selectedMonth}
|
||||
loading={loading}
|
||||
/>
|
||||
<Box sx={{ flex: 1, minHeight: 0 }}>
|
||||
<TransactionsTable
|
||||
transactions={transactions}
|
||||
selectedMonth={selectedMonth}
|
||||
loading={loading}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -59,7 +59,12 @@ class Login extends Component {
|
||||
|
||||
// Check if it's an authorization error
|
||||
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('403')) {
|
||||
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,
|
||||
Grid,
|
||||
Button,
|
||||
ListSubheader,
|
||||
Divider,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Download as DownloadIcon,
|
||||
CalendarToday as CalendarIcon,
|
||||
DateRange as QuarterIcon,
|
||||
Event as YearIcon,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
class SummaryHeader extends Component {
|
||||
@@ -29,6 +34,61 @@ class SummaryHeader extends Component {
|
||||
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() {
|
||||
const {
|
||||
months,
|
||||
@@ -41,22 +101,109 @@ class SummaryHeader extends Component {
|
||||
|
||||
if (!summary) return null;
|
||||
|
||||
const timeRangeOptions = this.generateTimeRangeOptions(months);
|
||||
|
||||
return (
|
||||
<Paper elevation={1} sx={{ p: { xs: 1.5, sm: 2 }, mb: 2 }}>
|
||||
<Grid container alignItems="center" spacing={{ xs: 1, sm: 2 }}>
|
||||
<Grid item xs={12} md={3}>
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>Monat</InputLabel>
|
||||
<InputLabel>Zeitraum</InputLabel>
|
||||
<Select
|
||||
value={selectedMonth}
|
||||
onChange={onMonthChange}
|
||||
label="Month"
|
||||
label="Zeitraum"
|
||||
sx={{
|
||||
'& .MuiSelect-select': {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1
|
||||
}
|
||||
}}
|
||||
>
|
||||
{months.map((month) => (
|
||||
<MenuItem key={month} value={month}>
|
||||
{/* Months Section */}
|
||||
<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)}
|
||||
</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>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
@@ -1,18 +1,170 @@
|
||||
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 {
|
||||
Box,
|
||||
Paper,
|
||||
Typography,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
CircularProgress,
|
||||
} from '@mui/material';
|
||||
import CheckboxFilter from './filters/CheckboxFilter';
|
||||
import TextHeaderWithFilter from './headers/TextHeaderWithFilter';
|
||||
|
||||
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) => {
|
||||
return new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
@@ -36,109 +188,183 @@ class TransactionsTable extends Component {
|
||||
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() {
|
||||
const { transactions, selectedMonth, loading } = this.props;
|
||||
const { selectedMonth, loading } = this.props;
|
||||
|
||||
return (
|
||||
<Paper elevation={2}>
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Typography variant="h6" component="h2" gutterBottom>
|
||||
Transaktionen für {this.getMonthName(selectedMonth)}
|
||||
<Paper elevation={2} sx={{
|
||||
m: 0,
|
||||
height: '100%',
|
||||
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>
|
||||
|
||||
{loading ? (
|
||||
<Box display="flex" justifyContent="center" p={2}>
|
||||
</Box>
|
||||
|
||||
<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 />
|
||||
</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>
|
||||
</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) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
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();
|
||||
} catch (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;
|
||||
}
|
||||
}
|
||||
@@ -39,12 +58,23 @@ class AuthService {
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.user;
|
||||
} 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);
|
||||
return null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user