diff --git a/client/public/index.html b/client/public/index.html
index 6f404eb..2b89f64 100644
--- a/client/public/index.html
+++ b/client/public/index.html
@@ -5,6 +5,58 @@
FibDash
+
+
diff --git a/client/src/App.js b/client/src/App.js
index 19487a9..eeb5c07 100644
--- a/client/src/App.js
+++ b/client/src/App.js
@@ -126,13 +126,15 @@ class App extends Component {
-
- {isAuthenticated ? (
-
- ) : (
-
- )}
-
+
+
+ {isAuthenticated ? (
+
+ ) : (
+
+ )}
+
+
);
}
diff --git a/client/src/components/DataViewer.js b/client/src/components/DataViewer.js
index 9014839..7b657f8 100644
--- a/client/src/components/DataViewer.js
+++ b/client/src/components/DataViewer.js
@@ -118,21 +118,25 @@ class DataViewer extends Component {
}
return (
-
-
+
+
+
+
-
+
+
+
);
}
diff --git a/client/src/components/Login.js b/client/src/components/Login.js
index 7b20d96..6d13fed 100644
--- a/client/src/components/Login.js
+++ b/client/src/components/Login.js
@@ -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.';
diff --git a/client/src/components/SummaryHeader.js b/client/src/components/SummaryHeader.js
index 1d6c4ae..1d7a139 100644
--- a/client/src/components/SummaryHeader.js
+++ b/client/src/components/SummaryHeader.js
@@ -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 (
- Monat
+ Zeitraum
diff --git a/client/src/components/TransactionsTable.js b/client/src/components/TransactionsTable.js
index b1b9158..6aa1a03 100644
--- a/client/src/components/TransactionsTable.js
+++ b/client/src/components/TransactionsTable.js
@@ -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 (
+
+ {this.formatAmount(amount)}
+
+ );
+ };
+
+ TypeRenderer = (params) => {
+ const amount = params.data.numericAmount;
+ const color = amount >= 0 ? '#388e3c' : '#d32f2f';
+ return (
+
+ );
+ };
+
+ JtlRenderer = (params) => {
+ const hasJTL = params.value;
+ const backgroundColor = hasJTL ? '#388e3c' : '#f5f5f5';
+ const border = hasJTL ? 'none' : '1px solid #ccc';
+
+ return (
+
+ );
+ };
+
+ // 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 (
-
-
-
- Transaktionen für {this.getMonthName(selectedMonth)}
+
+
+
+ Transaktionen für {this.getSelectedDisplayName(selectedMonth)}
-
- {loading ? (
-
+
+
+
+ {loading && (
+
- ) : (
-
-
-
-
- Datum
- Beschreibung
- Empfänger/Zahler
- Betrag
- Typ
- JTL
-
-
-
- {transactions.map((transaction, index) => (
-
-
- {this.formatDate(transaction['Buchungstag'])}
-
-
- {transaction['Verwendungszweck'] || transaction['Buchungstext']}
-
-
- {transaction['Beguenstigter/Zahlungspflichtiger']}
-
- = 0 ? '#388e3c' : '#d32f2f',
- whiteSpace: 'nowrap'
- }}
- >
- {this.formatAmount(transaction.numericAmount)}
-
-
- = 0 ? '#388e3c' : '#d32f2f',
- margin: 'auto'
- }}
- />
-
-
-
- {transaction.hasJTL && (
-
- ✓
-
- )}
-
-
-
- ))}
-
-
-
)}
+
);
diff --git a/client/src/components/filters/CheckboxFilter.js b/client/src/components/filters/CheckboxFilter.js
new file mode 100644
index 0000000..1dc47cd
--- /dev/null
+++ b/client/src/components/filters/CheckboxFilter.js
@@ -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 = () => (
+
+
+ {/* Show All Checkbox */}
+
+ }
+ label="Alle anzeigen"
+ sx={{ margin: 0, '& .MuiFormControlLabel-label': { fontSize: '14px' } }}
+ />
+
+ {/* Radio Options - disabled when showing all */}
+ {this.options.map((option) => (
+
+ }
+ label={
+
+ {option.dotStyle && (
+
+ {option.dotStyle.content || ''}
+
+ )}
+ {option.label}
+
+ }
+ sx={{ margin: 0, '& .MuiFormControlLabel-label': { fontSize: '14px' } }}
+ />
+ ))}
+
+
+ );
+
+ // Recreate React root every time to avoid state corruption
+ if (this.reactRoot) {
+ this.reactRoot.unmount();
+ }
+ this.reactRoot = createRoot(this.eGui);
+ this.reactRoot.render();
+ }
+}
\ No newline at end of file
diff --git a/client/src/components/filters/SelectionFilter.js b/client/src/components/filters/SelectionFilter.js
new file mode 100644
index 0000000..5458b23
--- /dev/null
+++ b/client/src/components/filters/SelectionFilter.js
@@ -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 (
+ this.eGui = el}
+ sx={{ minWidth: 200 }}
+ className="ag-filter-custom"
+ >
+
+ Filter {colDef.headerName}
+
+
+
+ {selectedValues.length > 0 && (
+
+
+
+ )}
+
+ );
+ }
+}
+
+export default SelectionFilter;
\ No newline at end of file
diff --git a/client/src/components/headers/TextHeaderWithFilter.js b/client/src/components/headers/TextHeaderWithFilter.js
new file mode 100644
index 0000000..9b63dca
--- /dev/null
+++ b/client/src/components/headers/TextHeaderWithFilter.js
@@ -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 (
+
+ {/* Column Title with proper AG Grid styling */}
+ this.onSortRequested(sortDirection === 'asc' ? 'desc' : 'asc', e)}
+ >
+ {displayName}
+
+
+ {/* Sort Icons - Always visible */}
+
+ this.onSortRequested('asc', e)}
+ />
+ this.onSortRequested('desc', e)}
+ />
+
+
+ {/* Filter Input - only for text columns */}
+ {showTextFilter && (
+
+ )}
+
+ {/* Spacer for non-text columns */}
+ {!showTextFilter && }
+
+ {/* Filter Menu Icon - Larger hit zone with active state */}
+ {
+ e.currentTarget.style.backgroundColor = this.isFilterActive() ? '#bbdefb' : '#f0f0f0';
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.backgroundColor = this.isFilterActive() ? '#e3f2fd' : 'transparent';
+ }}
+ >
+
+
+
+ );
+ }
+}
+
+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(
+ { this.headerComponent = ref; }}
+ params={this.params}
+ />
+ );
+ }
+ }, 0);
+ }
+}
\ No newline at end of file
diff --git a/client/src/services/AuthService.js b/client/src/services/AuthService.js
index 26a7ac7..475d9cf 100644
--- a/client/src/services/AuthService.js
+++ b/client/src/services/AuthService.js
@@ -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;
}
diff --git a/package-lock.json b/package-lock.json
index f227ccc..7bf209a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -13,6 +13,8 @@
"@emotion/styled": "^11.11.0",
"@mui/icons-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",
"dotenv": "^16.0.0",
"express": "^4.18.0",
@@ -3148,6 +3150,35 @@
"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": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
diff --git a/package.json b/package.json
index 4621b1b..4baa81b 100644
--- a/package.json
+++ b/package.json
@@ -24,6 +24,8 @@
"@emotion/styled": "^11.11.0",
"@mui/icons-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",
"dotenv": "^16.0.0",
"express": "^4.18.0",
diff --git a/src/routes/data.js b/src/routes/data.js
index 64b193b..0e95132 100644
--- a/src/routes/data.js
+++ b/src/routes/data.js
@@ -110,14 +110,48 @@ const getJTLTransactions = async () => {
}
};
-// Get transactions for a specific month
-router.get('/transactions/:monthYear', authenticateToken, async (req, res) => {
+// Get transactions for a specific time period (month, quarter, or year)
+router.get('/transactions/:timeRange', authenticateToken, async (req, res) => {
try {
- const { monthYear } = req.params;
+ const { timeRange } = req.params;
const transactions = parseCSV();
- const monthTransactions = transactions
- .filter(t => t.monthYear === monthYear)
+ let filteredTransactions = [];
+ 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
// 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');
}
- // Filter JTL transactions for the selected month
- const [year, month] = monthYear.split('-');
- const jtlMonthTransactions = jtlTransactions.filter(jtl => {
- const jtlDate = new Date(jtl.dBuchungsdatum);
- return jtlDate.getFullYear() === parseInt(year) &&
- jtlDate.getMonth() === parseInt(month) - 1;
- });
+ // Filter JTL transactions for the selected time period
+ let jtlMonthTransactions = [];
+
+ if (timeRange.includes('-Q')) {
+ const [year, quarterPart] = timeRange.split('-Q');
+ 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
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',
numericAmount: parseFloat(jtl.fBetrag) || 0,
parsedDate: new Date(jtl.dBuchungsdatum),
- monthYear: monthYear,
+ monthYear: timeRange,
hasJTL: true,
jtlId: jtl.kZahlungsabgleichUmsatz,
isFromCSV: false,
@@ -229,7 +284,8 @@ router.get('/transactions/:monthYear', authenticateToken, async (req, res) => {
res.json({
transactions: allTransactions,
summary,
- monthYear
+ timeRange,
+ periodDescription
});
} catch (error) {
console.error('Error getting transactions:', error);
@@ -290,25 +346,61 @@ const quote = (str, maxLen = 60) => {
};
// DATEV export endpoint
-router.get('/datev/:monthYear', authenticateToken, async (req, res) => {
+router.get('/datev/:timeRange', authenticateToken, async (req, res) => {
try {
- const { monthYear } = req.params;
- const [year, month] = monthYear.split('-');
+ const { timeRange } = req.params;
- // Get transactions for the month
+ // Get transactions for the time period
const transactions = parseCSV();
- const monthTransactions = transactions
- .filter(t => t.monthYear === monthYear)
+ let filteredTransactions = [];
+ 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
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
- 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 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');
// Set headers for file download
- const filename = `DATEV_${year}_${month.padStart(2, '0')}.csv`;
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.setHeader('Content-Type', 'text/csv; charset=latin1');