Remove debug login page and refactor TransactionsTable to utilize new utility functions and cell renderers for improved code organization and maintainability.

This commit is contained in:
sebseb7
2025-07-20 05:06:28 +02:00
parent 992adc7bcf
commit b9af7694a0
11 changed files with 301 additions and 547 deletions

View File

@@ -10,275 +10,28 @@ import {
Typography, Typography,
CircularProgress, CircularProgress,
} from '@mui/material'; } from '@mui/material';
import CheckboxFilter from './filters/CheckboxFilter'; import { getColumnDefs, defaultColDef, gridOptions } from './config/gridConfig';
import TextHeaderWithFilter from './headers/TextHeaderWithFilter'; import { processTransactionData, getRowStyle, getSelectedDisplayName } from './utils/dataUtils';
class TransactionsTable extends Component { class TransactionsTable extends Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { this.state = {
columnDefs: [ columnDefs: getColumnDefs(),
{ defaultColDef: defaultColDef,
headerName: 'Datum', gridOptions: gridOptions
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',
cellRenderer: this.DescriptionRenderer
},
{
headerName: 'Empfänger/Zahler',
field: 'Beguenstigter/Zahlungspflichtiger',
width: 200,
sortable: true,
headerComponent: TextHeaderWithFilter,
tooltipField: 'Beguenstigter/Zahlungspflichtiger',
cellRenderer: this.RecipientRenderer
},
{
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: 26,
headerHeight: 40,
// Pagination (optional - can be removed for infinite scrolling)
pagination: false,
paginationPageSize: 100
}
}; };
} }
formatAmount = (amount) => {
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR'
}).format(amount);
};
formatDate = (dateString) => {
if (!dateString) return '';
const parts = dateString.split('.');
if (parts.length === 3) {
return `${parts[0]}.${parts[1]}.20${parts[2]}`;
}
return dateString;
};
getMonthName = (monthYear) => {
if (!monthYear) return '';
const [year, month] = monthYear.split('-');
const date = new Date(year, month - 1);
return date.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' });
};
// Custom cell renderers as React components
DescriptionRenderer = (params) => {
return (
<span style={{ fontSize: '0.7rem', lineHeight: '1.2' }}>
{params.value}
</span>
);
};
RecipientRenderer = (params) => {
return (
<span style={{ fontSize: '0.7rem', lineHeight: '1.2' }}>
{params.value}
</span>
);
};
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) { componentDidUpdate(prevProps) {
// Update data without recreating grid when transactions change // Update data without recreating grid when transactions change
if (prevProps.transactions !== this.props.transactions && this.gridApi) { if (prevProps.transactions !== this.props.transactions && this.gridApi) {
const processedTransactions = this.props.transactions ? this.processTransactionData(this.props.transactions) : []; const processedTransactions = this.props.transactions ? processTransactionData(this.props.transactions) : [];
// Use setGridOption to update data while preserving grid state // Use setGridOption to update data while preserving grid state
this.gridApi.setGridOption('rowData', processedTransactions); this.gridApi.setGridOption('rowData', processedTransactions);
} }
@@ -300,7 +53,7 @@ class TransactionsTable extends Component {
// Set initial data if available // Set initial data if available
if (this.props.transactions) { if (this.props.transactions) {
const processedTransactions = this.processTransactionData(this.props.transactions); const processedTransactions = processTransactionData(this.props.transactions);
this.gridApi.setGridOption('rowData', processedTransactions); this.gridApi.setGridOption('rowData', processedTransactions);
} }
@@ -311,18 +64,7 @@ class TransactionsTable extends Component {
this.gridInitialized = true; this.gridInitialized = true;
}; };
getSelectedDisplayName = (selectedValue) => {
if (!selectedValue) return '';
if (selectedValue.includes('-Q')) {
const [year, quarterPart] = selectedValue.split('-Q');
return `Q${quarterPart} ${year}`;
} else if (selectedValue.length === 4) {
return `Jahr ${selectedValue}`;
} else {
return this.getMonthName(selectedValue);
}
};
render() { render() {
const { selectedMonth, loading } = this.props; const { selectedMonth, loading } = this.props;
@@ -336,7 +78,7 @@ class TransactionsTable extends Component {
}}> }}>
<Box sx={{ p: 1, flexShrink: 0 }}> <Box sx={{ p: 1, flexShrink: 0 }}>
<Typography variant="h6" component="h2" gutterBottom sx={{ m: 0 }}> <Typography variant="h6" component="h2" gutterBottom sx={{ m: 0 }}>
Transaktionen für {this.getSelectedDisplayName(selectedMonth)} Transaktionen für {getSelectedDisplayName(selectedMonth)}
</Typography> </Typography>
</Box> </Box>
@@ -367,7 +109,7 @@ class TransactionsTable extends Component {
defaultColDef={this.state.defaultColDef} defaultColDef={this.state.defaultColDef}
gridOptions={this.state.gridOptions} gridOptions={this.state.gridOptions}
onGridReady={this.onGridReady} onGridReady={this.onGridReady}
getRowStyle={this.getRowStyle} getRowStyle={getRowStyle}
suppressRowTransform={true} suppressRowTransform={true}
// Use new theming system // Use new theming system
theme={themeQuartz} theme={themeQuartz}

View File

@@ -0,0 +1,21 @@
import React from 'react';
const AmountRenderer = (params) => {
const formatAmount = (amount) => {
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR'
}).format(amount);
};
const amount = params.value;
const color = amount >= 0 ? '#388e3c' : '#d32f2f';
return (
<span style={{ color: color, fontWeight: '600' }}>
{formatAmount(amount)}
</span>
);
};
export default AmountRenderer;

View File

@@ -0,0 +1,11 @@
import React from 'react';
const DescriptionRenderer = (params) => {
return (
<span style={{ fontSize: '0.7rem', lineHeight: '1.2' }}>
{params.value}
</span>
);
};
export default DescriptionRenderer;

View File

@@ -0,0 +1,29 @@
import React from 'react';
const 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>
);
};
export default JtlRenderer;

View File

@@ -0,0 +1,11 @@
import React from 'react';
const RecipientRenderer = (params) => {
return (
<span style={{ fontSize: '0.7rem', lineHeight: '1.2' }}>
{params.value}
</span>
);
};
export default RecipientRenderer;

View File

@@ -0,0 +1,14 @@
import React from 'react';
const 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>
);
};
export default TypeRenderer;

View File

@@ -0,0 +1,165 @@
import CheckboxFilter from '../filters/CheckboxFilter';
import TextHeaderWithFilter from '../headers/TextHeaderWithFilter';
import AmountRenderer from '../cellRenderers/AmountRenderer';
import TypeRenderer from '../cellRenderers/TypeRenderer';
import JtlRenderer from '../cellRenderers/JtlRenderer';
import DescriptionRenderer from '../cellRenderers/DescriptionRenderer';
import RecipientRenderer from '../cellRenderers/RecipientRenderer';
const formatDate = (dateString) => {
if (!dateString) return '';
const parts = dateString.split('.');
if (parts.length === 3) {
return `${parts[0]}.${parts[1]}.20${parts[2]}`;
}
return dateString;
};
export const getColumnDefs = () => [
{
headerName: 'Datum',
field: 'Buchungstag',
width: 100,
valueFormatter: (params) => 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',
cellRenderer: DescriptionRenderer
},
{
headerName: 'Empfänger/Zahler',
field: 'Beguenstigter/Zahlungspflichtiger',
width: 200,
sortable: true,
headerComponent: TextHeaderWithFilter,
tooltipField: 'Beguenstigter/Zahlungspflichtiger',
cellRenderer: RecipientRenderer
},
{
headerName: 'Betrag',
field: 'numericAmount',
width: 120,
cellRenderer: AmountRenderer,
sortable: true,
filter: 'agNumberColumnFilter',
floatingFilter: false,
type: 'rightAligned',
headerComponent: TextHeaderWithFilter
},
{
headerName: 'Typ',
field: 'typeText',
width: 70,
cellRenderer: 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: 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
}
];
export const defaultColDef = {
resizable: true,
sortable: true,
filter: true,
floatingFilter: false,
suppressHeaderMenuButton: false
};
export const 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: 26,
headerHeight: 40,
// Pagination (optional - can be removed for infinite scrolling)
pagination: false,
paginationPageSize: 100
};

View File

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

View File

@@ -0,0 +1,39 @@
export const 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'
}));
};
export const getRowStyle = (params) => {
if (params.data.isJTLOnly) {
return {
backgroundColor: '#ffebee',
borderLeft: '4px solid #f44336'
};
}
return null;
};
export const getMonthName = (monthYear) => {
if (!monthYear) return '';
const [year, month] = monthYear.split('-');
const date = new Date(year, month - 1);
return date.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' });
};
export const 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 getMonthName(selectedValue);
}
};

View File

@@ -1,57 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>FibDash Login Debug</title>
<script src="https://accounts.google.com/gsi/client" async defer></script>
</head>
<body>
<h1>FibDash Login Debug</h1>
<div id="status">Initializing...</div>
<div id="g_id_onload"
data-client_id="928121624463-jbgfdlgem22scs1k9c87ucg4ffvaik6o.apps.googleusercontent.com"
data-callback="handleCredentialResponse">
</div>
<div class="g_id_signin" data-type="standard"></div>
<script>
function handleCredentialResponse(response) {
console.log('Google credential response:', response);
document.getElementById('status').innerHTML = 'Got Google token, sending to server...';
// Send to our backend
fetch('/api/auth/google', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
token: response.credential
})
})
.then(response => {
console.log('Server response status:', response.status);
return response.json();
})
.then(data => {
console.log('Server response data:', data);
if (data.success) {
document.getElementById('status').innerHTML = '✅ Login successful! User: ' + data.user.email;
} else {
document.getElementById('status').innerHTML = '❌ Login failed: ' + (data.message || data.error);
}
})
.catch(error => {
console.error('Login error:', error);
document.getElementById('status').innerHTML = '❌ Network error: ' + error.message;
});
}
window.onload = function() {
document.getElementById('status').innerHTML = 'Ready - Click "Sign in with Google"';
};
</script>
</body>
</html>

View File

@@ -27,12 +27,7 @@ app.get('/api/health', (req, res) => {
res.json({ status: 'OK', timestamp: new Date().toISOString() }); res.json({ status: 'OK', timestamp: new Date().toISOString() });
}); });
// Debug login page (development only)
if (process.env.NODE_ENV !== 'production') {
app.get('/debug-login', (req, res) => {
res.sendFile(path.join(__dirname, '../debug-login.html'));
});
}
// Serve static files in production // Serve static files in production
if (process.env.NODE_ENV === 'production') { if (process.env.NODE_ENV === 'production') {