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 ( +
+
+ {hasJTL && '✓'} +
+
+ ); + }; + + // 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');