Compare commits
3 Commits
b9af7694a0
...
58f5bb4b4f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58f5bb4b4f | ||
|
|
429fd70497 | ||
|
|
2a43b7106d |
@@ -34,6 +34,34 @@
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
/* Alternating row colors for better readability */
|
||||
.ag-row-odd {
|
||||
background-color: #f8f9fa !important;
|
||||
}
|
||||
|
||||
.ag-row-even {
|
||||
background-color: #ffffff !important;
|
||||
}
|
||||
|
||||
/* Maintain alternating colors on hover */
|
||||
.ag-row-odd:hover {
|
||||
background-color: #e9ecef !important;
|
||||
}
|
||||
|
||||
.ag-row-even:hover {
|
||||
background-color: #f1f3f4 !important;
|
||||
}
|
||||
|
||||
/* Ensure JTL-only rows (red rows) override alternating colors */
|
||||
.ag-row[style*="background-color: rgb(255, 235, 238)"] {
|
||||
background-color: #ffebee !important;
|
||||
}
|
||||
|
||||
/* Selected rows */
|
||||
.ag-row.selected-row {
|
||||
background-color: #e3f2fd !important;
|
||||
color: #1976d2 !important;
|
||||
}
|
||||
|
||||
</style>
|
||||
<script>
|
||||
|
||||
@@ -4,6 +4,7 @@ import CssBaseline from '@mui/material/CssBaseline';
|
||||
import { Container, AppBar, Toolbar, Typography, Button, Box } from '@mui/material';
|
||||
import LoginIcon from '@mui/icons-material/Login';
|
||||
import DashboardIcon from '@mui/icons-material/Dashboard';
|
||||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
import AuthService from './services/AuthService';
|
||||
import DataViewer from './components/DataViewer';
|
||||
import Login from './components/Login';
|
||||
@@ -27,6 +28,7 @@ class App extends Component {
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
loading: true,
|
||||
exportData: null, // { selectedMonth, canExport, onExport }
|
||||
};
|
||||
this.authService = new AuthService();
|
||||
}
|
||||
@@ -68,7 +70,11 @@ class App extends Component {
|
||||
|
||||
handleLogout = () => {
|
||||
localStorage.removeItem('token');
|
||||
this.setState({ isAuthenticated: false, user: null });
|
||||
this.setState({ isAuthenticated: false, user: null, exportData: null });
|
||||
};
|
||||
|
||||
updateExportData = (exportData) => {
|
||||
this.setState({ exportData });
|
||||
};
|
||||
|
||||
render() {
|
||||
@@ -107,6 +113,24 @@ class App extends Component {
|
||||
>
|
||||
Willkommen, {user.name}
|
||||
</Typography>
|
||||
{this.state.exportData && (
|
||||
<Button
|
||||
color="inherit"
|
||||
onClick={this.state.exportData.onExport}
|
||||
disabled={!this.state.exportData.canExport}
|
||||
size="small"
|
||||
sx={{
|
||||
mr: { xs: 1, sm: 2 },
|
||||
minWidth: { xs: 'auto', sm: 'auto' },
|
||||
px: { xs: 1, sm: 2 }
|
||||
}}
|
||||
>
|
||||
<DownloadIcon sx={{ mr: { xs: 0, sm: 1 } }} />
|
||||
<Box component="span" sx={{ display: { xs: 'none', sm: 'inline' } }}>
|
||||
DATEV Export
|
||||
</Box>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
color="inherit"
|
||||
onClick={this.handleLogout}
|
||||
@@ -127,9 +151,9 @@ class App extends Component {
|
||||
</AppBar>
|
||||
|
||||
<Box sx={{ height: 'calc(100vh - 64px)', display: 'flex', flexDirection: 'column' }}>
|
||||
<Container maxWidth="xl" sx={{ mt: 4, flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
<Container maxWidth={false} sx={{ mt: 4, flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', width: '100%' }}>
|
||||
{isAuthenticated ? (
|
||||
<DataViewer user={user} />
|
||||
<DataViewer user={user} onUpdateExportData={this.updateExportData} />
|
||||
) : (
|
||||
<Login onLogin={this.handleLogin} />
|
||||
)}
|
||||
|
||||
@@ -24,6 +24,13 @@ class DataViewer extends Component {
|
||||
|
||||
componentDidMount() {
|
||||
this.loadMonths();
|
||||
this.updateExportData();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
if (prevState.loading !== this.state.loading || prevState.selectedMonth !== this.state.selectedMonth) {
|
||||
this.updateExportData();
|
||||
}
|
||||
}
|
||||
|
||||
loadMonths = async () => {
|
||||
@@ -70,6 +77,7 @@ class DataViewer extends Component {
|
||||
const monthYear = event.target.value;
|
||||
this.setState({ selectedMonth: monthYear });
|
||||
this.loadTransactions(monthYear);
|
||||
this.updateExportData(monthYear);
|
||||
};
|
||||
|
||||
|
||||
@@ -98,6 +106,16 @@ class DataViewer extends Component {
|
||||
}
|
||||
};
|
||||
|
||||
updateExportData = (selectedMonth = this.state.selectedMonth) => {
|
||||
if (this.props.onUpdateExportData) {
|
||||
this.props.onUpdateExportData({
|
||||
selectedMonth,
|
||||
canExport: !!selectedMonth && !this.state.loading,
|
||||
onExport: this.downloadDatev
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { months, selectedMonth, transactions, summary, loading, error } = this.state;
|
||||
|
||||
@@ -126,7 +144,6 @@ class DataViewer extends Component {
|
||||
summary={summary}
|
||||
loading={loading}
|
||||
onMonthChange={this.handleMonthChange}
|
||||
onDownloadDatev={this.downloadDatev}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -95,8 +95,7 @@ class SummaryHeader extends Component {
|
||||
selectedMonth,
|
||||
summary,
|
||||
loading,
|
||||
onMonthChange,
|
||||
onDownloadDatev
|
||||
onMonthChange
|
||||
} = this.props;
|
||||
|
||||
if (!summary) return null;
|
||||
@@ -249,23 +248,19 @@ class SummaryHeader extends Component {
|
||||
<Grid item xs={6} sm={4} md={1}>
|
||||
<Box textAlign="center">
|
||||
<Typography variant="caption" color="textSecondary">JTL ✓</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold', color: '#388e3c', fontSize: { xs: '0.9rem', sm: '1.25rem' } }}>
|
||||
{summary.jtlMatches || 0}
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
color: summary.jtlMatches === undefined ? '#856404' : '#388e3c',
|
||||
fontSize: { xs: '0.9rem', sm: '1.25rem' }
|
||||
}}
|
||||
>
|
||||
{summary.jtlMatches === undefined ? '?' : summary.jtlMatches}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4} md={2}>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<DownloadIcon />}
|
||||
onClick={onDownloadDatev}
|
||||
disabled={!selectedMonth || loading}
|
||||
size="small"
|
||||
sx={{ height: 'fit-content' }}
|
||||
>
|
||||
DATEV Export
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
</Grid>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
@@ -9,9 +9,12 @@ import {
|
||||
Paper,
|
||||
Typography,
|
||||
CircularProgress,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
} from '@mui/material';
|
||||
import { Clear as ClearIcon } from '@mui/icons-material';
|
||||
import { getColumnDefs, defaultColDef, gridOptions } from './config/gridConfig';
|
||||
import { processTransactionData, getRowStyle, getSelectedDisplayName } from './utils/dataUtils';
|
||||
import { processTransactionData, getRowStyle, getRowClass, getSelectedDisplayName } from './utils/dataUtils';
|
||||
|
||||
class TransactionsTable extends Component {
|
||||
constructor(props) {
|
||||
@@ -20,20 +23,59 @@ class TransactionsTable extends Component {
|
||||
this.state = {
|
||||
columnDefs: getColumnDefs(),
|
||||
defaultColDef: defaultColDef,
|
||||
gridOptions: gridOptions
|
||||
gridOptions: gridOptions,
|
||||
totalRows: 0,
|
||||
displayedRows: 0,
|
||||
selectedRows: new Set() // Track selected row IDs
|
||||
};
|
||||
|
||||
// Ref for header checkbox to update it directly
|
||||
this.headerCheckboxRef = null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
componentDidMount() {
|
||||
// Add window resize listener for auto-resizing columns
|
||||
this.handleResize = () => {
|
||||
if (this.gridApi) {
|
||||
// Small delay to ensure DOM has updated
|
||||
setTimeout(() => {
|
||||
this.gridApi.sizeColumnsToFit();
|
||||
}, 100);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('resize', this.handleResize);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
// Clean up event listeners
|
||||
if (this.handleResize) {
|
||||
window.removeEventListener('resize', this.handleResize);
|
||||
}
|
||||
|
||||
if (this.gridApi) {
|
||||
this.gridApi.removeEventListener('modelUpdated', this.onModelUpdated);
|
||||
this.gridApi.removeEventListener('filterChanged', this.onFilterChanged);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
// Update data without recreating grid when transactions change
|
||||
if (prevProps.transactions !== this.props.transactions && this.gridApi) {
|
||||
const processedTransactions = this.props.transactions ? processTransactionData(this.props.transactions) : [];
|
||||
// Use setGridOption to update data while preserving grid state
|
||||
this.gridApi.setGridOption('rowData', processedTransactions);
|
||||
// Update total rows count and displayed rows
|
||||
this.setState({
|
||||
totalRows: processedTransactions.length,
|
||||
displayedRows: processedTransactions.length
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,12 +93,26 @@ class TransactionsTable extends Component {
|
||||
this.gridApi = params.api;
|
||||
this.gridColumnApi = params.columnApi;
|
||||
|
||||
// Store reference to this component for header access
|
||||
params.api.fibdashComponent = this;
|
||||
params.api.setHeaderCheckboxRef = (ref) => {
|
||||
this.headerCheckboxRef = ref;
|
||||
};
|
||||
|
||||
// Set initial data if available
|
||||
if (this.props.transactions) {
|
||||
const processedTransactions = processTransactionData(this.props.transactions);
|
||||
this.gridApi.setGridOption('rowData', processedTransactions);
|
||||
this.setState({
|
||||
totalRows: processedTransactions.length,
|
||||
displayedRows: processedTransactions.length
|
||||
});
|
||||
}
|
||||
|
||||
// Add event listeners for row count updates
|
||||
params.api.addEventListener('modelUpdated', this.onModelUpdated);
|
||||
params.api.addEventListener('filterChanged', this.onFilterChanged);
|
||||
|
||||
// Auto-size columns to fit content
|
||||
params.api.sizeColumnsToFit();
|
||||
|
||||
@@ -64,23 +120,143 @@ class TransactionsTable extends Component {
|
||||
this.gridInitialized = true;
|
||||
};
|
||||
|
||||
onModelUpdated = () => {
|
||||
if (this.gridApi) {
|
||||
const displayedRows = this.gridApi.getDisplayedRowCount();
|
||||
this.setState({ displayedRows });
|
||||
}
|
||||
};
|
||||
|
||||
onFilterChanged = () => {
|
||||
if (this.gridApi) {
|
||||
const displayedRows = this.gridApi.getDisplayedRowCount();
|
||||
this.setState({ displayedRows });
|
||||
}
|
||||
};
|
||||
|
||||
hasActiveFilters = () => {
|
||||
if (!this.gridApi) return false;
|
||||
const filterModel = this.gridApi.getFilterModel();
|
||||
return filterModel && Object.keys(filterModel).length > 0;
|
||||
};
|
||||
|
||||
clearAllFilters = () => {
|
||||
if (this.gridApi) {
|
||||
this.gridApi.setFilterModel(null);
|
||||
}
|
||||
};
|
||||
|
||||
onCellClicked = (event) => {
|
||||
// Skip selection for specific cells
|
||||
const field = event.colDef.field;
|
||||
|
||||
// Don't select on selection column or document column (last cell)
|
||||
// Allow IBAN column to trigger selection
|
||||
if (field === 'selection' || field === 'documents') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Toggle row selection
|
||||
const rowData = event.data;
|
||||
if (!rowData) return;
|
||||
|
||||
const rowId = rowData.id || event.rowIndex;
|
||||
const isCurrentlySelected = this.state.selectedRows.has(rowId);
|
||||
|
||||
// Toggle selection
|
||||
this.onSelectionChange(rowId, rowData, !isCurrentlySelected);
|
||||
};
|
||||
|
||||
// Custom selection methods
|
||||
onSelectionChange = (rowId, rowData, isSelected) => {
|
||||
const selectedRows = new Set(this.state.selectedRows);
|
||||
|
||||
if (isSelected) {
|
||||
selectedRows.add(rowId);
|
||||
} else {
|
||||
selectedRows.delete(rowId);
|
||||
}
|
||||
|
||||
this.setState({ selectedRows }, () => {
|
||||
// Update only grid context, avoid column definition changes that cause resizing
|
||||
if (this.gridApi) {
|
||||
this.gridApi.setGridOption('context', {
|
||||
selectedRows: selectedRows,
|
||||
onSelectionChange: this.onSelectionChange,
|
||||
onSelectAll: this.onSelectAll,
|
||||
totalRows: this.state.totalRows,
|
||||
displayedRows: this.state.displayedRows
|
||||
});
|
||||
|
||||
// Refresh only the selection column cells
|
||||
this.gridApi.refreshCells({ columns: ['selection'], force: true });
|
||||
|
||||
// Refresh row styles to show selection background
|
||||
this.gridApi.redrawRows();
|
||||
|
||||
// Update header checkbox directly via ref
|
||||
if (this.headerCheckboxRef) {
|
||||
this.headerCheckboxRef.updateState(selectedRows, this.state.displayedRows);
|
||||
}
|
||||
}
|
||||
});
|
||||
console.log('Selected rows:', Array.from(selectedRows));
|
||||
};
|
||||
|
||||
onSelectAll = (selectAll) => {
|
||||
const selectedRows = new Set();
|
||||
|
||||
if (selectAll && this.gridApi) {
|
||||
// Select all displayed rows
|
||||
this.gridApi.forEachNodeAfterFilter((node) => {
|
||||
if (node.data) {
|
||||
const rowId = node.data.id || node.rowIndex;
|
||||
selectedRows.add(rowId);
|
||||
}
|
||||
});
|
||||
}
|
||||
// If selectAll is false, selectedRows stays empty (clears all)
|
||||
|
||||
this.setState({ selectedRows }, () => {
|
||||
// Update only grid context, avoid column definition changes that cause resizing
|
||||
if (this.gridApi) {
|
||||
this.gridApi.setGridOption('context', {
|
||||
selectedRows: selectedRows,
|
||||
onSelectionChange: this.onSelectionChange,
|
||||
onSelectAll: this.onSelectAll,
|
||||
totalRows: this.state.totalRows,
|
||||
displayedRows: this.state.displayedRows
|
||||
});
|
||||
|
||||
// Refresh only the selection column cells
|
||||
this.gridApi.refreshCells({ columns: ['selection'], force: true });
|
||||
|
||||
// Refresh row styles to show selection background
|
||||
this.gridApi.redrawRows();
|
||||
|
||||
// Update header checkbox directly via ref
|
||||
if (this.headerCheckboxRef) {
|
||||
this.headerCheckboxRef.updateState(selectedRows, this.state.displayedRows);
|
||||
}
|
||||
}
|
||||
});
|
||||
console.log('Selected rows:', Array.from(selectedRows));
|
||||
};
|
||||
|
||||
|
||||
|
||||
render() {
|
||||
const { selectedMonth, loading } = this.props;
|
||||
const { totalRows, displayedRows, selectedRows } = this.state;
|
||||
|
||||
return (
|
||||
<Paper elevation={2} sx={{
|
||||
m: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}>
|
||||
<Box sx={{ p: 1, flexShrink: 0 }}>
|
||||
<Typography variant="h6" component="h2" gutterBottom sx={{ m: 0 }}>
|
||||
Transaktionen für {getSelectedDisplayName(selectedMonth)}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ flex: 1, width: '100%', minHeight: 0, position: 'relative' }}>
|
||||
{loading && (
|
||||
@@ -103,16 +279,27 @@ class TransactionsTable extends Component {
|
||||
)}
|
||||
<div style={{ height: '100%', width: '100%' }}>
|
||||
<AgGridReact
|
||||
key="transactions-grid" // Stable key prevents recreation
|
||||
key="transactions-grid"
|
||||
columnDefs={this.state.columnDefs}
|
||||
// Remove rowData prop - data is set via API in componentDidUpdate
|
||||
defaultColDef={this.state.defaultColDef}
|
||||
gridOptions={this.state.gridOptions}
|
||||
onGridReady={this.onGridReady}
|
||||
getRowStyle={getRowStyle}
|
||||
getRowStyle={(params) => getRowStyle(params, this.state.selectedRows)}
|
||||
getRowClass={(params) => getRowClass(params, this.state.selectedRows)}
|
||||
suppressRowTransform={true}
|
||||
// Use new theming system
|
||||
theme={themeQuartz}
|
||||
// Pass selection state through context
|
||||
context={{
|
||||
selectedRows: this.state.selectedRows,
|
||||
onSelectionChange: this.onSelectionChange,
|
||||
onSelectAll: this.onSelectAll,
|
||||
totalRows: this.state.totalRows,
|
||||
displayedRows: this.state.displayedRows
|
||||
}}
|
||||
// Add row click selection
|
||||
onCellClicked={this.onCellClicked}
|
||||
// Virtualization settings for performance
|
||||
rowBuffer={10}
|
||||
suppressRowVirtualisation={false}
|
||||
@@ -126,6 +313,134 @@ class TransactionsTable extends Component {
|
||||
/>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
{/* Status Bar */}
|
||||
<Box sx={{
|
||||
flexShrink: 0,
|
||||
px: 2,
|
||||
py: 1,
|
||||
borderTop: '1px solid #e0e0e0',
|
||||
backgroundColor: '#f8f9fa',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
{displayedRows === totalRows
|
||||
? `${totalRows} Zeilen angezeigt`
|
||||
: `${displayedRows} von ${totalRows} Zeilen angezeigt`
|
||||
}
|
||||
</Typography>
|
||||
{selectedRows.size > 0 && (
|
||||
<Tooltip title="Auswahl löschen">
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
cursor: 'pointer',
|
||||
px: 1,
|
||||
py: 0.5,
|
||||
borderRadius: 1,
|
||||
'&:hover': {
|
||||
backgroundColor: 'primary.light',
|
||||
'& .selection-text': {
|
||||
color: 'white'
|
||||
},
|
||||
'& .clear-selection-icon': {
|
||||
color: 'white'
|
||||
}
|
||||
}
|
||||
}}
|
||||
onClick={() => {
|
||||
const emptySelection = new Set();
|
||||
this.setState({ selectedRows: emptySelection }, () => {
|
||||
if (this.gridApi) {
|
||||
// Update grid context
|
||||
this.gridApi.setGridOption('context', {
|
||||
selectedRows: emptySelection,
|
||||
onSelectionChange: this.onSelectionChange,
|
||||
onSelectAll: this.onSelectAll,
|
||||
totalRows: this.state.totalRows,
|
||||
displayedRows: this.state.displayedRows
|
||||
});
|
||||
|
||||
// Refresh cell checkboxes
|
||||
this.gridApi.refreshCells({ columns: ['selection'], force: true });
|
||||
|
||||
// Refresh row styles to clear selection backgrounds
|
||||
this.gridApi.redrawRows();
|
||||
|
||||
// Update header checkbox via ref
|
||||
if (this.headerCheckboxRef) {
|
||||
this.headerCheckboxRef.updateState(emptySelection, this.state.displayedRows);
|
||||
}
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="primary"
|
||||
sx={{ fontWeight: 'medium' }}
|
||||
className="selection-text"
|
||||
>
|
||||
{selectedRows.size} ausgewählt
|
||||
</Typography>
|
||||
<ClearIcon
|
||||
sx={{
|
||||
fontSize: 16,
|
||||
color: 'primary.main'
|
||||
}}
|
||||
className="clear-selection-icon"
|
||||
/>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
{this.hasActiveFilters() && (
|
||||
<Tooltip title="Alle Filter löschen">
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
cursor: 'pointer',
|
||||
px: 1,
|
||||
py: 0.5,
|
||||
borderRadius: 1,
|
||||
'&:hover': {
|
||||
backgroundColor: 'primary.light',
|
||||
'& .filter-text': {
|
||||
color: 'white'
|
||||
},
|
||||
'& .clear-icon': {
|
||||
color: 'white'
|
||||
}
|
||||
}
|
||||
}}
|
||||
onClick={this.clearAllFilters}
|
||||
>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="primary"
|
||||
sx={{ fontWeight: 'medium' }}
|
||||
className="filter-text"
|
||||
>
|
||||
Filter aktiv
|
||||
</Typography>
|
||||
<ClearIcon
|
||||
sx={{
|
||||
fontSize: 16,
|
||||
color: 'primary.main'
|
||||
}}
|
||||
className="clear-icon"
|
||||
/>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
411
client/src/components/cellRenderers/DocumentRenderer.js
Normal file
411
client/src/components/cellRenderers/DocumentRenderer.js
Normal file
@@ -0,0 +1,411 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
IconButton,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemIcon,
|
||||
Typography,
|
||||
Divider,
|
||||
Tabs,
|
||||
Tab,
|
||||
Alert
|
||||
} from '@mui/material';
|
||||
import {
|
||||
PictureAsPdf as PdfIcon,
|
||||
Link as LinkIcon,
|
||||
Description as DocumentIcon,
|
||||
OpenInNew as OpenIcon,
|
||||
ContentCopy as CopyIcon
|
||||
} from '@mui/icons-material';
|
||||
import { AgGridReact } from 'ag-grid-react';
|
||||
|
||||
const DocumentRenderer = (params) => {
|
||||
// Check for pdfs and links regardless of transaction source
|
||||
const pdfs = params.data.pdfs || [];
|
||||
const links = params.data.links || [];
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [tabValue, setTabValue] = useState(0);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// Always show something clickable, even if no documents
|
||||
const hasDocuments = pdfs.length > 0 || links.length > 0;
|
||||
|
||||
// Combine PDFs and links since links are also PDFs
|
||||
const allDocuments = [
|
||||
...pdfs.map(pdf => ({ ...pdf, type: 'pdf' })),
|
||||
...links.map(link => ({ ...link, type: 'link' }))
|
||||
];
|
||||
|
||||
const totalCount = allDocuments.length;
|
||||
|
||||
const handleClick = () => {
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setDialogOpen(false);
|
||||
setTabValue(0); // Reset to first tab when closing
|
||||
setError(null); // Clear any errors when closing
|
||||
};
|
||||
|
||||
const handleTabChange = (event, newValue) => {
|
||||
setTabValue(newValue);
|
||||
};
|
||||
|
||||
const handleCopyDatevLink = (datevlink) => {
|
||||
if (datevlink) {
|
||||
navigator.clipboard.writeText(datevlink);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenPdf = async (document) => {
|
||||
let endpoint = '';
|
||||
|
||||
if (document.type === 'pdf' && document.kUmsatzBeleg) {
|
||||
// PDF from tUmsatzBeleg table
|
||||
endpoint = `/data/pdf/umsatzbeleg/${document.kUmsatzBeleg}`;
|
||||
} else if (document.type === 'link' && document.kPdfObjekt) {
|
||||
// PDF from tPdfObjekt table (linked documents)
|
||||
endpoint = `/data/pdf/pdfobject/${document.kPdfObjekt}`;
|
||||
} else {
|
||||
console.error('Unable to determine PDF URL for document:', document);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Create an authenticated API call
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await fetch(`/api${endpoint}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
// Get the PDF blob
|
||||
const blob = await response.blob();
|
||||
|
||||
// Create a blob URL and open it in a new tab
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const newWindow = window.open(blobUrl, '_blank');
|
||||
|
||||
// Clean up the blob URL after a delay to allow the browser to load it
|
||||
setTimeout(() => {
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
}, 1000);
|
||||
|
||||
// If window.open was blocked, try alternative approach
|
||||
if (!newWindow) {
|
||||
// Create a temporary download link
|
||||
const a = document.createElement('a');
|
||||
a.href = blobUrl;
|
||||
a.target = '_blank';
|
||||
a.download = `document_${document.kUmsatzBeleg || document.kPdfObjekt}.pdf`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 1000);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error opening PDF:', error);
|
||||
setError('Fehler beim Öffnen des PDFs. Bitte versuchen Sie es erneut.');
|
||||
}
|
||||
};
|
||||
|
||||
// Extract line items from document extraction data
|
||||
const extractLineItems = () => {
|
||||
const lineItems = [];
|
||||
|
||||
allDocuments.forEach((doc, docIndex) => {
|
||||
if (doc.extraction) {
|
||||
try {
|
||||
const extractionData = JSON.parse(doc.extraction);
|
||||
if (extractionData.net_amounts_and_tax) {
|
||||
extractionData.net_amounts_and_tax.forEach((item, itemIndex) => {
|
||||
lineItems.push({
|
||||
id: `${docIndex}-${itemIndex}`,
|
||||
document: doc.type === 'pdf' ? 'PDF Dokument' : 'Verknüpftes Dokument',
|
||||
documentIndex: docIndex + 1,
|
||||
netAmount: item.net_amount || 0,
|
||||
taxRate: item.tax_rate || 0,
|
||||
taxAmount: item.tax_amount || 0,
|
||||
grossAmount: (item.net_amount || 0) + (item.tax_amount || 0),
|
||||
currency: extractionData.currency || 'EUR',
|
||||
invoiceNumber: extractionData.invoice_number || '',
|
||||
date: extractionData.date || '',
|
||||
sender: extractionData.sender || ''
|
||||
});
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing extraction data:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return lineItems;
|
||||
};
|
||||
|
||||
const lineItems = extractLineItems();
|
||||
|
||||
// AG Grid column definitions for line items
|
||||
const columnDefs = [
|
||||
{
|
||||
headerName: 'Dok',
|
||||
field: 'documentIndex',
|
||||
width: 50,
|
||||
cellStyle: { textAlign: 'center' }
|
||||
},
|
||||
{
|
||||
headerName: 'Rechnungsnr.',
|
||||
field: 'invoiceNumber',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
headerName: 'Datum',
|
||||
field: 'date',
|
||||
width: 100
|
||||
},
|
||||
{
|
||||
headerName: 'Absender',
|
||||
field: 'sender',
|
||||
width: 150,
|
||||
tooltipField: 'sender'
|
||||
},
|
||||
{
|
||||
headerName: 'Netto',
|
||||
field: 'netAmount',
|
||||
width: 80,
|
||||
type: 'rightAligned',
|
||||
valueFormatter: (params) => {
|
||||
return new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: params.data.currency || 'EUR'
|
||||
}).format(params.value);
|
||||
}
|
||||
},
|
||||
{
|
||||
headerName: 'MwSt %',
|
||||
field: 'taxRate',
|
||||
width: 70,
|
||||
type: 'rightAligned',
|
||||
valueFormatter: (params) => `${params.value}%`
|
||||
},
|
||||
{
|
||||
headerName: 'MwSt',
|
||||
field: 'taxAmount',
|
||||
width: 80,
|
||||
type: 'rightAligned',
|
||||
valueFormatter: (params) => {
|
||||
return new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: params.data.currency || 'EUR'
|
||||
}).format(params.value);
|
||||
}
|
||||
},
|
||||
{
|
||||
headerName: 'Brutto',
|
||||
field: 'grossAmount',
|
||||
width: 90,
|
||||
type: 'rightAligned',
|
||||
valueFormatter: (params) => {
|
||||
return new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: params.data.currency || 'EUR'
|
||||
}).format(params.value);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const defaultColDef = {
|
||||
sortable: true,
|
||||
filter: false,
|
||||
resizable: true,
|
||||
suppressSizeToFit: false
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
gap: 0.5,
|
||||
alignItems: 'center',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.04)'
|
||||
}
|
||||
}}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{hasDocuments ? (
|
||||
<>
|
||||
<PdfIcon sx={{ fontSize: 14, color: '#d32f2f' }} />
|
||||
{totalCount > 1 && (
|
||||
<Box sx={{
|
||||
fontSize: '8px',
|
||||
color: '#d32f2f',
|
||||
fontWeight: 'bold',
|
||||
position: 'relative',
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '50%',
|
||||
width: 12,
|
||||
height: 12,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
border: '1px solid #d32f2f',
|
||||
marginLeft: '-6px'
|
||||
}}>
|
||||
{totalCount}
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Box sx={{
|
||||
fontSize: '8px',
|
||||
color: '#999',
|
||||
fontWeight: 'normal',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
—
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Dialog open={dialogOpen} onClose={handleClose} maxWidth="lg" fullWidth>
|
||||
<DialogTitle>
|
||||
{hasDocuments ? `Dokumente (${totalCount})` : 'Dokumentinformationen'}
|
||||
</DialogTitle>
|
||||
<DialogContent sx={{ p: 0 }}>
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ m: 2 }} onClose={() => setError(null)}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Tabs value={tabValue} onChange={handleTabChange}>
|
||||
<Tab label="Dokumente" />
|
||||
<Tab label={`Buchungen (${lineItems.length})`} />
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
{tabValue === 0 && (
|
||||
<Box sx={{ p: 2 }}>
|
||||
{hasDocuments ? (
|
||||
<List>
|
||||
{allDocuments.map((doc, index) => (
|
||||
<React.Fragment key={index}>
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
<PdfIcon sx={{ color: '#d32f2f' }} />
|
||||
</ListItemIcon>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="subtitle2">
|
||||
{doc.type === 'pdf' ? 'PDF Dokument' : 'Verknüpftes Dokument'} #{index + 1}
|
||||
</Typography>
|
||||
{doc.datevlink && (
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
DATEV Link: {doc.datevlink}
|
||||
</Typography>
|
||||
)}
|
||||
{doc.note && (
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
Notiz: {doc.note}
|
||||
</Typography>
|
||||
)}
|
||||
<Box sx={{ mt: 1, display: 'flex', gap: 1 }}>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<OpenIcon />}
|
||||
onClick={() => handleOpenPdf(doc)}
|
||||
variant="outlined"
|
||||
>
|
||||
PDF öffnen
|
||||
</Button>
|
||||
{doc.datevlink && (
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<CopyIcon />}
|
||||
onClick={() => handleCopyDatevLink(doc.datevlink)}
|
||||
variant="outlined"
|
||||
>
|
||||
DATEV Link kopieren
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</ListItem>
|
||||
{index < allDocuments.length - 1 && <Divider />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</List>
|
||||
) : (
|
||||
<Box sx={{ textAlign: 'center', py: 4 }}>
|
||||
<PdfIcon sx={{ fontSize: 48, color: '#ccc', mb: 2 }} />
|
||||
<Typography variant="h6" color="textSecondary" gutterBottom>
|
||||
Keine Dokumente verfügbar
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
Für diese Transaktion sind keine PDF-Dokumente oder Verknüpfungen vorhanden.
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{tabValue === 1 && (
|
||||
<Box sx={{ p: 2, height: 400 }}>
|
||||
{lineItems.length > 0 ? (
|
||||
<div style={{ height: '100%', width: '100%' }}>
|
||||
<AgGridReact
|
||||
columnDefs={columnDefs}
|
||||
rowData={lineItems}
|
||||
defaultColDef={defaultColDef}
|
||||
suppressRowTransform={true}
|
||||
rowHeight={35}
|
||||
headerHeight={35}
|
||||
domLayout="normal"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Box sx={{ textAlign: 'center', py: 4 }}>
|
||||
<Typography variant="h6" color="textSecondary" gutterBottom>
|
||||
Keine Buchungsdaten verfügbar
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
{hasDocuments
|
||||
? 'In den vorhandenen Dokumenten wurden keine Buchungsdaten gefunden.'
|
||||
: 'Keine Dokumente vorhanden, daher keine Buchungsdaten verfügbar.'
|
||||
}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleClose}>Schließen</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocumentRenderer;
|
||||
@@ -2,6 +2,30 @@ import React from 'react';
|
||||
|
||||
const JtlRenderer = (params) => {
|
||||
const hasJTL = params.value;
|
||||
|
||||
// Handle undefined state (database unavailable)
|
||||
if (hasJTL === undefined) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
|
||||
<div style={{
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: '#fff3cd',
|
||||
border: '1px solid #ffc107',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '8px',
|
||||
color: '#856404',
|
||||
fontWeight: 'bold'
|
||||
}}>
|
||||
?
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const backgroundColor = hasJTL ? '#388e3c' : '#f5f5f5';
|
||||
const border = hasJTL ? 'none' : '1px solid #ccc';
|
||||
|
||||
|
||||
@@ -1,9 +1,39 @@
|
||||
import React from 'react';
|
||||
|
||||
const RecipientRenderer = (params) => {
|
||||
const isIbanColumn = params.colDef.field === 'Kontonummer/IBAN';
|
||||
const value = params.value;
|
||||
|
||||
const handleClick = (event) => {
|
||||
if (isIbanColumn && value && params.api) {
|
||||
// Stop event propagation to prevent row selection
|
||||
event.stopPropagation();
|
||||
|
||||
// Apply filter to IBAN column using the custom IbanSelectionFilter format
|
||||
const currentFilterModel = params.api.getFilterModel();
|
||||
params.api.setFilterModel({
|
||||
...currentFilterModel,
|
||||
'Kontonummer/IBAN': {
|
||||
filterType: 'iban-selection',
|
||||
values: [value]
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<span style={{ fontSize: '0.7rem', lineHeight: '1.2' }}>
|
||||
{params.value}
|
||||
<span
|
||||
style={{
|
||||
fontSize: '0.7rem',
|
||||
lineHeight: '1.2',
|
||||
cursor: isIbanColumn && value ? 'pointer' : 'default',
|
||||
color: isIbanColumn && value ? '#1976d2' : 'inherit',
|
||||
textDecoration: isIbanColumn && value ? 'underline' : 'none'
|
||||
}}
|
||||
onClick={isIbanColumn && value ? handleClick : undefined}
|
||||
title={isIbanColumn && value ? `Nach IBAN "${value}" filtern` : undefined}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
50
client/src/components/cellRenderers/SelectionRenderer.js
Normal file
50
client/src/components/cellRenderers/SelectionRenderer.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import React from 'react';
|
||||
import { Checkbox } from '@mui/material';
|
||||
|
||||
const SelectionRenderer = (params) => {
|
||||
const { data } = params;
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
// Create a unique row ID (using row index or a unique field)
|
||||
const rowId = data.id || params.rowIndex;
|
||||
|
||||
// Get selection state and methods from grid context
|
||||
const context = params.context || {};
|
||||
const { selectedRows, onSelectionChange } = context;
|
||||
const isSelected = selectedRows && selectedRows.has && selectedRows.has(rowId);
|
||||
|
||||
const handleChange = (event) => {
|
||||
event.stopPropagation(); // Prevent row click events
|
||||
if (onSelectionChange) {
|
||||
onSelectionChange(rowId, data, event.target.checked);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
width: '100%'
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()} // Prevent row click events
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected || false}
|
||||
onChange={handleChange}
|
||||
size="small"
|
||||
sx={{
|
||||
padding: 0,
|
||||
'& .MuiSvgIcon-root': {
|
||||
fontSize: 18
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SelectionRenderer;
|
||||
@@ -1,10 +1,14 @@
|
||||
import CheckboxFilter from '../filters/CheckboxFilter';
|
||||
import IbanSelectionFilter from '../filters/IbanSelectionFilter';
|
||||
import TextHeaderWithFilter from '../headers/TextHeaderWithFilter';
|
||||
import SelectionHeader from '../headers/SelectionHeader';
|
||||
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';
|
||||
import DocumentRenderer from '../cellRenderers/DocumentRenderer';
|
||||
import SelectionRenderer from '../cellRenderers/SelectionRenderer';
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return '';
|
||||
@@ -16,6 +20,21 @@ const formatDate = (dateString) => {
|
||||
};
|
||||
|
||||
export const getColumnDefs = () => [
|
||||
{
|
||||
headerName: '',
|
||||
field: 'selection',
|
||||
width: 50,
|
||||
pinned: 'left',
|
||||
sortable: false,
|
||||
filter: false,
|
||||
resizable: false,
|
||||
suppressMenu: true,
|
||||
cellRenderer: SelectionRenderer,
|
||||
headerComponent: SelectionHeader,
|
||||
headerComponentParams: {
|
||||
// These will be set by the parent component
|
||||
}
|
||||
},
|
||||
{
|
||||
headerName: 'Datum',
|
||||
field: 'Buchungstag',
|
||||
@@ -37,7 +56,7 @@ export const getColumnDefs = () => [
|
||||
cellRenderer: DescriptionRenderer
|
||||
},
|
||||
{
|
||||
headerName: 'Empfänger/Zahler',
|
||||
headerName: 'Name',
|
||||
field: 'Beguenstigter/Zahlungspflichtiger',
|
||||
width: 200,
|
||||
sortable: true,
|
||||
@@ -45,6 +64,25 @@ export const getColumnDefs = () => [
|
||||
tooltipField: 'Beguenstigter/Zahlungspflichtiger',
|
||||
cellRenderer: RecipientRenderer
|
||||
},
|
||||
{
|
||||
headerName: 'IBAN',
|
||||
field: 'Kontonummer/IBAN',
|
||||
width: 180,
|
||||
sortable: true,
|
||||
filter: IbanSelectionFilter,
|
||||
headerComponent: TextHeaderWithFilter,
|
||||
tooltipField: 'Kontonummer/IBAN',
|
||||
cellRenderer: RecipientRenderer
|
||||
},
|
||||
{
|
||||
headerName: 'BIC',
|
||||
field: 'BIC (SWIFT-Code)',
|
||||
width: 120,
|
||||
sortable: true,
|
||||
headerComponent: TextHeaderWithFilter,
|
||||
tooltipField: 'BIC',
|
||||
cellRenderer: RecipientRenderer
|
||||
},
|
||||
{
|
||||
headerName: 'Betrag',
|
||||
field: 'numericAmount',
|
||||
@@ -61,7 +99,8 @@ export const getColumnDefs = () => [
|
||||
field: 'typeText',
|
||||
width: 70,
|
||||
cellRenderer: TypeRenderer,
|
||||
sortable: true,
|
||||
sortable: false,
|
||||
suppressSorting: true,
|
||||
filter: CheckboxFilter,
|
||||
filterParams: {
|
||||
filterOptions: [
|
||||
@@ -97,7 +136,8 @@ export const getColumnDefs = () => [
|
||||
field: 'hasJTL',
|
||||
width: 70,
|
||||
cellRenderer: JtlRenderer,
|
||||
sortable: true,
|
||||
sortable: false,
|
||||
suppressSorting: true,
|
||||
filter: CheckboxFilter,
|
||||
filterParams: {
|
||||
filterOptions: [
|
||||
@@ -128,11 +168,36 @@ export const getColumnDefs = () => [
|
||||
border: '1px solid #ccc'
|
||||
},
|
||||
condition: (fieldValue) => fieldValue === false
|
||||
},
|
||||
{
|
||||
value: 'undefined',
|
||||
label: 'Unbekannt',
|
||||
color: 'warning',
|
||||
dotStyle: {
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
backgroundColor: '#fff3cd',
|
||||
border: '1px solid #ffc107',
|
||||
fontSize: '8px',
|
||||
color: '#856404',
|
||||
fontWeight: 'bold',
|
||||
content: '?'
|
||||
},
|
||||
condition: (fieldValue) => fieldValue === undefined
|
||||
}
|
||||
]
|
||||
},
|
||||
floatingFilter: false,
|
||||
headerComponent: TextHeaderWithFilter
|
||||
},
|
||||
{
|
||||
headerName: 'Dokumente',
|
||||
field: 'documents',
|
||||
width: 90,
|
||||
cellRenderer: DocumentRenderer,
|
||||
sortable: false,
|
||||
filter: false,
|
||||
headerComponent: TextHeaderWithFilter
|
||||
}
|
||||
];
|
||||
|
||||
@@ -146,10 +211,7 @@ export const defaultColDef = {
|
||||
|
||||
export const gridOptions = {
|
||||
animateRows: true,
|
||||
rowSelection: {
|
||||
mode: 'singleRow',
|
||||
enableClickSelection: true
|
||||
},
|
||||
rowSelection: false,
|
||||
rowBuffer: 10,
|
||||
// Enable virtualization (default behavior)
|
||||
suppressRowVirtualisation: false,
|
||||
@@ -161,5 +223,9 @@ export const gridOptions = {
|
||||
headerHeight: 40,
|
||||
// Pagination (optional - can be removed for infinite scrolling)
|
||||
pagination: false,
|
||||
paginationPageSize: 100
|
||||
paginationPageSize: 100,
|
||||
// Disable cell selection
|
||||
suppressCellSelection: true,
|
||||
suppressRowClickSelection: true,
|
||||
suppressCellFocus: true
|
||||
};
|
||||
209
client/src/components/filters/IbanSelectionFilter.js
Normal file
209
client/src/components/filters/IbanSelectionFilter.js
Normal file
@@ -0,0 +1,209 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import {
|
||||
FormControl,
|
||||
Select,
|
||||
MenuItem,
|
||||
Checkbox,
|
||||
ListItemText,
|
||||
Box,
|
||||
Chip,
|
||||
Button
|
||||
} from '@mui/material';
|
||||
|
||||
export default class IbanSelectionFilter {
|
||||
constructor() {
|
||||
this.state = {
|
||||
selectedValues: [],
|
||||
availableValues: []
|
||||
};
|
||||
|
||||
// Create the DOM element that AG Grid expects
|
||||
this.eGui = document.createElement('div');
|
||||
this.eGui.style.minWidth = '250px';
|
||||
this.eGui.style.padding = '8px';
|
||||
}
|
||||
|
||||
init(params) {
|
||||
this.params = params;
|
||||
this.updateAvailableValues();
|
||||
this.renderReactComponent();
|
||||
}
|
||||
|
||||
getGui() {
|
||||
return this.eGui;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.reactRoot) {
|
||||
this.reactRoot.unmount();
|
||||
this.reactRoot = null;
|
||||
}
|
||||
}
|
||||
|
||||
updateAvailableValues() {
|
||||
if (!this.params || !this.params.api) return;
|
||||
|
||||
// Get all row data
|
||||
const allRowData = [];
|
||||
this.params.api.forEachNode(node => {
|
||||
if (node.data) {
|
||||
allRowData.push(node.data);
|
||||
}
|
||||
});
|
||||
|
||||
// Create a map of IBAN to names, then get unique combinations
|
||||
const ibanToNameMap = new Map();
|
||||
allRowData.forEach(row => {
|
||||
const iban = row['Kontonummer/IBAN'];
|
||||
const name = row['Beguenstigter/Zahlungspflichtiger'];
|
||||
if (iban && iban.trim() !== '' && iban !== '0000000000') {
|
||||
if (!ibanToNameMap.has(iban)) {
|
||||
ibanToNameMap.set(iban, name || '');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Convert to array of objects with iban and name
|
||||
this.availableValues = Array.from(ibanToNameMap.entries()).map(([iban, name]) => ({
|
||||
iban,
|
||||
name: name || 'Unbekannt'
|
||||
}));
|
||||
}
|
||||
|
||||
isFilterActive() {
|
||||
return this.state.selectedValues.length > 0;
|
||||
}
|
||||
|
||||
doesFilterPass(params) {
|
||||
const { selectedValues } = this.state;
|
||||
if (selectedValues.length === 0) return true;
|
||||
|
||||
const value = params.data['Kontonummer/IBAN'];
|
||||
return selectedValues.includes(value);
|
||||
}
|
||||
|
||||
getModel() {
|
||||
if (!this.isFilterActive()) return null;
|
||||
|
||||
return {
|
||||
filterType: 'iban-selection',
|
||||
values: this.state.selectedValues
|
||||
};
|
||||
}
|
||||
|
||||
setModel(model) {
|
||||
if (!model) {
|
||||
this.state.selectedValues = [];
|
||||
} else {
|
||||
this.state.selectedValues = model.values || [];
|
||||
}
|
||||
this.renderReactComponent();
|
||||
}
|
||||
|
||||
handleSelectionChange = (selectedValues) => {
|
||||
this.state.selectedValues = selectedValues;
|
||||
this.renderReactComponent();
|
||||
|
||||
// Notify AG Grid that filter changed
|
||||
if (this.params && this.params.filterChangedCallback) {
|
||||
this.params.filterChangedCallback();
|
||||
}
|
||||
};
|
||||
|
||||
clearFilter = () => {
|
||||
this.state.selectedValues = [];
|
||||
this.renderReactComponent();
|
||||
|
||||
if (this.params && this.params.filterChangedCallback) {
|
||||
this.params.filterChangedCallback();
|
||||
}
|
||||
};
|
||||
|
||||
renderReactComponent() {
|
||||
if (!this.reactRoot) {
|
||||
this.reactRoot = createRoot(this.eGui);
|
||||
}
|
||||
|
||||
const FilterComponent = () => (
|
||||
<Box sx={{ minWidth: 250 }} className="ag-filter-custom">
|
||||
<FormControl fullWidth size="small">
|
||||
<Select
|
||||
multiple
|
||||
value={this.state.selectedValues}
|
||||
onChange={(event) => this.handleSelectionChange(event.target.value)}
|
||||
displayEmpty
|
||||
renderValue={(selected) => {
|
||||
if (selected.length === 0) {
|
||||
return <em>Alle IBANs</em>;
|
||||
}
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{selected.slice(0, 2).map((value) => {
|
||||
const ibanData = this.availableValues.find(item => item.iban === value);
|
||||
return (
|
||||
<Chip
|
||||
key={value}
|
||||
label={ibanData ? `${ibanData.name.slice(0, 15)}...` : value.slice(-4)}
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{selected.length > 2 && (
|
||||
<Chip
|
||||
label={`+${selected.length - 2} mehr`}
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}}
|
||||
MenuProps={{
|
||||
PaperProps: {
|
||||
style: {
|
||||
maxHeight: 300,
|
||||
width: 300,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{this.availableValues.map((item) => (
|
||||
<MenuItem key={item.iban} value={item.iban}>
|
||||
<Checkbox
|
||||
checked={this.state.selectedValues.indexOf(item.iban) > -1}
|
||||
size="small"
|
||||
/>
|
||||
<ListItemText
|
||||
primary={item.name}
|
||||
secondary={item.iban}
|
||||
primaryTypographyProps={{ fontSize: '0.8rem' }}
|
||||
secondaryTypographyProps={{ fontSize: '0.7rem', color: 'text.secondary' }}
|
||||
/>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{this.state.selectedValues.length > 0 && (
|
||||
<Box sx={{ mt: 1, textAlign: 'right' }}>
|
||||
<Button
|
||||
onClick={this.clearFilter}
|
||||
size="small"
|
||||
variant="text"
|
||||
color="primary"
|
||||
sx={{ fontSize: '0.75rem' }}
|
||||
>
|
||||
Filter löschen
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
this.reactRoot.render(<FilterComponent />);
|
||||
}
|
||||
}
|
||||
64
client/src/components/headers/SelectionHeader.js
Normal file
64
client/src/components/headers/SelectionHeader.js
Normal file
@@ -0,0 +1,64 @@
|
||||
import React, { useState, useEffect, useImperativeHandle, forwardRef } from 'react';
|
||||
import { Checkbox } from '@mui/material';
|
||||
|
||||
const SelectionHeader = forwardRef((params, ref) => {
|
||||
const [checkboxState, setCheckboxState] = useState({ checked: false, indeterminate: false });
|
||||
|
||||
// Expose updateState method to parent via ref
|
||||
useImperativeHandle(ref, () => ({
|
||||
updateState: (selectedRows, displayedRows) => {
|
||||
const allSelected = displayedRows > 0 && selectedRows.size >= displayedRows;
|
||||
const someSelected = selectedRows.size > 0;
|
||||
const indeterminate = someSelected && !allSelected;
|
||||
setCheckboxState({ checked: allSelected, indeterminate });
|
||||
}
|
||||
}));
|
||||
|
||||
// Register this component with parent on mount
|
||||
useEffect(() => {
|
||||
if (params.api?.setHeaderCheckboxRef) {
|
||||
params.api.setHeaderCheckboxRef(ref.current || { updateState: (selectedRows, displayedRows) => {
|
||||
const allSelected = displayedRows > 0 && selectedRows.size >= displayedRows;
|
||||
const someSelected = selectedRows.size > 0;
|
||||
const indeterminate = someSelected && !allSelected;
|
||||
setCheckboxState({ checked: allSelected, indeterminate });
|
||||
}});
|
||||
}
|
||||
}, [params.api, ref]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
width: '100%'
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={checkboxState.checked}
|
||||
indeterminate={checkboxState.indeterminate}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
|
||||
const parentComponent = params.api?.fibdashComponent;
|
||||
if (parentComponent && parentComponent.onSelectAll) {
|
||||
const { selectedRows } = parentComponent.state;
|
||||
const shouldSelectAll = selectedRows.size === 0;
|
||||
parentComponent.onSelectAll(shouldSelectAll);
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
padding: 0,
|
||||
'& .MuiSvgIcon-root': {
|
||||
fontSize: 18
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default SelectionHeader;
|
||||
@@ -43,6 +43,17 @@ class HeaderComponent extends Component {
|
||||
}
|
||||
|
||||
onFilterChanged = () => {
|
||||
// Check if this column's filter was cleared and reset local state
|
||||
if (this.props.params && this.props.params.api && this.props.params.column) {
|
||||
const colId = this.props.params.column.colId;
|
||||
const filterModel = this.props.params.api.getFilterModel();
|
||||
|
||||
// If this column no longer has a filter, clear the local filterValue
|
||||
if (!filterModel || !filterModel[colId]) {
|
||||
this.setState({ filterValue: '' });
|
||||
}
|
||||
}
|
||||
|
||||
// Force re-render to update filter icon color
|
||||
this.forceUpdate();
|
||||
};
|
||||
@@ -134,6 +145,9 @@ class HeaderComponent extends Component {
|
||||
const isTextColumn = column.colDef.field === 'description' ||
|
||||
column.colDef.field === 'Beguenstigter/Zahlungspflichtiger';
|
||||
const showTextFilter = isTextColumn;
|
||||
|
||||
// Check if sorting is disabled for this column
|
||||
const isSortingDisabled = column.colDef.sortable === false || column.colDef.suppressSorting === true;
|
||||
|
||||
return (
|
||||
<Box sx={{
|
||||
@@ -153,44 +167,46 @@ class HeaderComponent extends Component {
|
||||
fontWeight: 600,
|
||||
fontSize: '13px',
|
||||
color: '#212529',
|
||||
cursor: 'pointer',
|
||||
cursor: isSortingDisabled ? 'default' : 'pointer',
|
||||
minWidth: 'fit-content',
|
||||
marginRight: '6px',
|
||||
userSelect: 'none',
|
||||
'&:hover': {
|
||||
color: '#0969da'
|
||||
color: isSortingDisabled ? '#212529' : '#0969da'
|
||||
}
|
||||
}}
|
||||
onClick={(e) => this.onSortRequested(sortDirection === 'asc' ? 'desc' : 'asc', e)}
|
||||
onClick={isSortingDisabled ? undefined : (e) => this.onSortRequested(sortDirection === 'asc' ? 'desc' : 'asc', e)}
|
||||
>
|
||||
{displayName}
|
||||
</Typography>
|
||||
|
||||
{/* Sort Icons - Always visible */}
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
marginRight: '6px',
|
||||
gap: 0
|
||||
}}>
|
||||
<SortUpIcon sx={{
|
||||
fontSize: '10px',
|
||||
color: sortDirection === 'asc' ? '#0969da' : '#ccc',
|
||||
opacity: sortDirection === 'asc' ? 1 : 0.5,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
onClick={(e) => this.onSortRequested('asc', e)}
|
||||
/>
|
||||
<SortDownIcon sx={{
|
||||
fontSize: '10px',
|
||||
color: sortDirection === 'desc' ? '#0969da' : '#ccc',
|
||||
opacity: sortDirection === 'desc' ? 1 : 0.5,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
onClick={(e) => this.onSortRequested('desc', e)}
|
||||
/>
|
||||
</Box>
|
||||
{/* Sort Icons - Only visible when sorting is enabled */}
|
||||
{!isSortingDisabled && (
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
marginRight: '6px',
|
||||
gap: 0
|
||||
}}>
|
||||
<SortUpIcon sx={{
|
||||
fontSize: '10px',
|
||||
color: sortDirection === 'asc' ? '#0969da' : '#ccc',
|
||||
opacity: sortDirection === 'asc' ? 1 : 0.5,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
onClick={(e) => this.onSortRequested('asc', e)}
|
||||
/>
|
||||
<SortDownIcon sx={{
|
||||
fontSize: '10px',
|
||||
color: sortDirection === 'desc' ? '#0969da' : '#ccc',
|
||||
opacity: sortDirection === 'desc' ? 1 : 0.5,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
onClick={(e) => this.onSortRequested('desc', e)}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Filter Input - only for text columns */}
|
||||
{showTextFilter && (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export const processTransactionData = (transactions) => {
|
||||
return transactions.map(transaction => ({
|
||||
return transactions.map((transaction, index) => ({
|
||||
...transaction,
|
||||
id: `row-${index}-${transaction.Buchungstag}-${transaction.numericAmount}`, // Unique ID
|
||||
description: transaction['Verwendungszweck'] || transaction['Buchungstext'],
|
||||
type: transaction.numericAmount >= 0 ? 'Income' : 'Expense',
|
||||
isIncome: transaction.numericAmount >= 0,
|
||||
@@ -8,16 +9,39 @@ export const processTransactionData = (transactions) => {
|
||||
}));
|
||||
};
|
||||
|
||||
export const getRowStyle = (params) => {
|
||||
export const getRowStyle = (params, selectedRows) => {
|
||||
const rowId = params.data?.id || params.rowIndex;
|
||||
const isSelected = selectedRows && selectedRows.has && selectedRows.has(rowId);
|
||||
|
||||
console.log('getRowStyle called for row:', rowId, 'isSelected:', isSelected, 'selectedRows size:', selectedRows?.size);
|
||||
|
||||
if (params.data.isJTLOnly) {
|
||||
return {
|
||||
backgroundColor: '#ffebee',
|
||||
backgroundColor: isSelected ? '#e3f2fd' : '#ffebee',
|
||||
borderLeft: '4px solid #f44336'
|
||||
};
|
||||
}
|
||||
|
||||
if (isSelected) {
|
||||
console.log('Row is selected but using CSS class instead:', rowId);
|
||||
return null; // Don't apply inline styles for selected rows
|
||||
}
|
||||
|
||||
// Return null to allow CSS classes (ag-row-odd/ag-row-even) to handle alternating colors
|
||||
return null;
|
||||
};
|
||||
|
||||
export const getRowClass = (params, selectedRows) => {
|
||||
const rowId = params.data?.id || params.rowIndex;
|
||||
const isSelected = selectedRows && selectedRows.has && selectedRows.has(rowId);
|
||||
|
||||
if (isSelected) {
|
||||
return 'selected-row';
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
export const getMonthName = (monthYear) => {
|
||||
if (!monthYear) return '';
|
||||
const [year, month] = monthYear.split('-');
|
||||
|
||||
@@ -1,16 +1,5 @@
|
||||
const checkAuthorizedEmail = (req, res, next) => {
|
||||
const authorizedEmails = process.env.AUTHORIZED_EMAILS;
|
||||
|
||||
// If no authorized emails are configured, deny all users
|
||||
if (!authorizedEmails || authorizedEmails.trim() === '') {
|
||||
return res.status(403).json({
|
||||
error: 'Access denied',
|
||||
message: 'No authorized users configured. Contact administrator.'
|
||||
});
|
||||
}
|
||||
|
||||
const emailList = authorizedEmails.split(',').map(email => email.trim().toLowerCase());
|
||||
const userEmail = req.user?.email?.toLowerCase();
|
||||
const checkAuthorizedEmail = async (req, res, next) => {
|
||||
const userEmail = req.user?.email;
|
||||
|
||||
if (!userEmail) {
|
||||
return res.status(401).json({
|
||||
@@ -19,27 +8,75 @@ const checkAuthorizedEmail = (req, res, next) => {
|
||||
});
|
||||
}
|
||||
|
||||
if (!emailList.includes(userEmail)) {
|
||||
return res.status(403).json({
|
||||
error: 'Access denied',
|
||||
message: 'Your email address is not authorized to access this application'
|
||||
try {
|
||||
const authorized = await isEmailAuthorized(userEmail);
|
||||
|
||||
if (!authorized) {
|
||||
return res.status(403).json({
|
||||
error: 'Access denied',
|
||||
message: 'Your email address is not authorized to access this application'
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error('Authorization check failed:', error);
|
||||
return res.status(500).json({
|
||||
error: 'Authorization check failed',
|
||||
message: 'Unable to verify authorization. Please try again.'
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
const isEmailAuthorized = (email) => {
|
||||
const isEmailAuthorized = async (email) => {
|
||||
const authorizedEmails = process.env.AUTHORIZED_EMAILS;
|
||||
const userEmail = email.toLowerCase();
|
||||
|
||||
// If no authorized emails are configured, deny all users
|
||||
if (!authorizedEmails || authorizedEmails.trim() === '') {
|
||||
return false;
|
||||
console.log(`🔍 Checking authorization for email: ${userEmail}`);
|
||||
|
||||
// First check environment variable
|
||||
if (authorizedEmails && authorizedEmails.trim() !== '') {
|
||||
const emailList = authorizedEmails.split(',').map(e => e.trim().toLowerCase());
|
||||
if (emailList.includes(userEmail)) {
|
||||
console.log(`✅ Email authorized via AUTHORIZED_EMAILS environment variable`);
|
||||
return true;
|
||||
}
|
||||
console.log(`❌ Email not found in AUTHORIZED_EMAILS environment variable`);
|
||||
} else {
|
||||
console.log(`⚠️ No AUTHORIZED_EMAILS configured, checking database...`);
|
||||
}
|
||||
|
||||
const emailList = authorizedEmails.split(',').map(e => e.trim().toLowerCase());
|
||||
const userEmail = email.toLowerCase();
|
||||
return emailList.includes(userEmail);
|
||||
// Then check database
|
||||
console.log(`🗄️ Checking database authorization for: ${userEmail}`);
|
||||
try {
|
||||
const { executeQuery } = require('../config/database');
|
||||
|
||||
const query = `
|
||||
SELECT TOP 1 1 as authorized
|
||||
FROM dbo.tAdresse
|
||||
WHERE cMail = @email
|
||||
AND kKunde IN (
|
||||
SELECT [kKunde]
|
||||
FROM [Kunde].[tKundeEigenesFeld]
|
||||
WHERE kAttribut = 219 OR kAttribut = 220 AND nWertInt = 1
|
||||
)
|
||||
`;
|
||||
|
||||
const result = await executeQuery(query, { email: userEmail });
|
||||
const isAuthorized = result.recordset && result.recordset.length > 0;
|
||||
|
||||
if (isAuthorized) {
|
||||
console.log(`✅ Email authorized via database (tKundeEigenesFeld with kAttribut 219/220)`);
|
||||
return true;
|
||||
} else {
|
||||
console.log(`❌ Email not authorized via database`);
|
||||
return false;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error(`💥 Database authorization check failed for ${userEmail}:`, error.message);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -35,7 +35,7 @@ router.post('/google', async (req, res) => {
|
||||
console.log(`👤 Google token verified for: ${email}`);
|
||||
|
||||
// Check if email is authorized
|
||||
const authorized = isEmailAuthorized(email);
|
||||
const authorized = await isEmailAuthorized(email);
|
||||
console.log(`🔒 Email authorization check for ${email}: ${authorized ? 'ALLOWED' : 'DENIED'}`);
|
||||
|
||||
if (!authorized) {
|
||||
@@ -46,50 +46,15 @@ router.post('/google', async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
// Check if user exists in database (optional - auth works without DB)
|
||||
let user;
|
||||
try {
|
||||
// Only try database operations if DB is configured
|
||||
if (process.env.DB_SERVER) {
|
||||
const userResult = await executeQuery(
|
||||
'SELECT * FROM Users WHERE email = @email',
|
||||
{ email }
|
||||
);
|
||||
|
||||
if (userResult.recordset.length > 0) {
|
||||
// User exists, update last login
|
||||
user = userResult.recordset[0];
|
||||
await executeQuery(
|
||||
'UPDATE Users SET last_login = GETDATE(), picture = @picture WHERE id = @id',
|
||||
{ picture, id: user.id }
|
||||
);
|
||||
} else {
|
||||
// Create new user
|
||||
const insertResult = await executeQuery(
|
||||
`INSERT INTO Users (google_id, email, name, picture, created_at, last_login)
|
||||
OUTPUT INSERTED.*
|
||||
VALUES (@googleId, @email, @name, @picture, GETDATE(), GETDATE())`,
|
||||
{ googleId, email, name, picture }
|
||||
);
|
||||
user = insertResult.recordset[0];
|
||||
}
|
||||
console.log('✅ Database operations completed successfully');
|
||||
} else {
|
||||
console.log('⚠️ No database configured, using fallback user object');
|
||||
throw new Error('No database configured');
|
||||
}
|
||||
} catch (dbError) {
|
||||
console.error('Database error during authentication:', dbError.message);
|
||||
// Fallback: create user object without database
|
||||
user = {
|
||||
id: googleId,
|
||||
email,
|
||||
name,
|
||||
picture,
|
||||
google_id: googleId,
|
||||
};
|
||||
console.log('✅ Using fallback user object (no database)');
|
||||
}
|
||||
// Create user object from Google data (no database storage needed)
|
||||
const user = {
|
||||
id: googleId,
|
||||
email,
|
||||
name,
|
||||
picture,
|
||||
google_id: googleId,
|
||||
};
|
||||
console.log('✅ User object created from Google authentication');
|
||||
|
||||
// Generate JWT token
|
||||
const jwtToken = generateToken(user);
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
const express = require('express');
|
||||
const { authenticateToken } = require('../middleware/auth');
|
||||
const { executeQuery } = require('../config/database');
|
||||
const { checkAuthorizedEmail } = require('../middleware/emailAuth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -18,45 +16,15 @@ router.get('/', authenticateToken, async (req, res) => {
|
||||
recentActivity: []
|
||||
};
|
||||
|
||||
try {
|
||||
// Only try database operations if configured
|
||||
if (process.env.DB_SERVER) {
|
||||
// Try to fetch real data from database
|
||||
const userCountResult = await executeQuery('SELECT COUNT(*) as count FROM Users');
|
||||
const userCount = userCountResult.recordset[0]?.count || 0;
|
||||
|
||||
// Update stats with real data
|
||||
dashboardData.stats[0].value = userCount.toString();
|
||||
|
||||
// Fetch recent activity
|
||||
const activityResult = await executeQuery(`
|
||||
SELECT TOP 10
|
||||
CONCAT('User ', name, ' logged in') as description,
|
||||
FORMAT(last_login, 'yyyy-MM-dd HH:mm') as timestamp
|
||||
FROM Users
|
||||
WHERE last_login IS NOT NULL
|
||||
ORDER BY last_login DESC
|
||||
`);
|
||||
|
||||
dashboardData.recentActivity = activityResult.recordset || [];
|
||||
console.log('✅ Dashboard data loaded from database');
|
||||
} else {
|
||||
console.log('⚠️ No database configured, using mock dashboard data');
|
||||
// Update with mock data
|
||||
dashboardData.stats[0].value = '1';
|
||||
dashboardData.stats[1].value = '$0';
|
||||
dashboardData.stats[2].value = '0';
|
||||
dashboardData.stats[3].value = '0%';
|
||||
dashboardData.recentActivity = [
|
||||
{ description: 'System started without database', timestamp: new Date().toISOString().slice(0, 16) }
|
||||
];
|
||||
}
|
||||
|
||||
} catch (dbError) {
|
||||
console.error('Database query error in dashboard:', dbError.message);
|
||||
// Keep fallback data
|
||||
console.log('✅ Using fallback dashboard data');
|
||||
}
|
||||
// Use mock data since we don't store user information in database
|
||||
dashboardData.stats[0].value = 'N/A';
|
||||
dashboardData.stats[1].value = '$0';
|
||||
dashboardData.stats[2].value = '0';
|
||||
dashboardData.stats[3].value = '0%';
|
||||
dashboardData.recentActivity = [
|
||||
{ description: 'System operational', timestamp: new Date().toISOString().slice(0, 16) }
|
||||
];
|
||||
console.log('✅ Dashboard loaded with mock data (no user storage)');
|
||||
|
||||
res.json(dashboardData);
|
||||
|
||||
@@ -69,29 +37,12 @@ router.get('/', authenticateToken, async (req, res) => {
|
||||
// Get user-specific data
|
||||
router.get('/user', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
|
||||
let userData = {
|
||||
const userData = {
|
||||
profile: req.user,
|
||||
preferences: {},
|
||||
activity: []
|
||||
};
|
||||
|
||||
try {
|
||||
// Fetch user preferences from database
|
||||
const prefsResult = await executeQuery(
|
||||
'SELECT * FROM UserPreferences WHERE user_id = @userId',
|
||||
{ userId }
|
||||
);
|
||||
|
||||
if (prefsResult.recordset.length > 0) {
|
||||
userData.preferences = prefsResult.recordset[0];
|
||||
}
|
||||
|
||||
} catch (dbError) {
|
||||
console.error('Database query error for user data:', dbError);
|
||||
}
|
||||
|
||||
res.json(userData);
|
||||
|
||||
} catch (error) {
|
||||
|
||||
@@ -103,10 +103,51 @@ const getJTLTransactions = async () => {
|
||||
`;
|
||||
|
||||
const result = await executeQuery(query);
|
||||
return result.recordset || [];
|
||||
const transactions = result.recordset || [];
|
||||
|
||||
// Get PDF documents for each transaction
|
||||
const pdfQuery = `SELECT kUmsatzBeleg, kZahlungsabgleichUmsatz, textContent, markDown, extraction, datevlink FROM tUmsatzBeleg`;
|
||||
const pdfResult = await executeQuery(pdfQuery);
|
||||
|
||||
for(const item of pdfResult.recordset){
|
||||
for(const transaction of transactions){
|
||||
if(item.kZahlungsabgleichUmsatz == transaction.kZahlungsabgleichUmsatz){
|
||||
if(!transaction.pdfs) transaction.pdfs = [];
|
||||
transaction.pdfs.push({
|
||||
kUmsatzBeleg: item.kUmsatzBeleg,
|
||||
content: item.textContent,
|
||||
markDown: item.markDown,
|
||||
extraction: item.extraction,
|
||||
datevlink: item.datevlink
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get links for each transaction
|
||||
const linksQuery = `
|
||||
SELECT kZahlungsabgleichUmsatzLink, kZahlungsabgleichUmsatz, linktarget, linktype, note,
|
||||
tPdfObjekt.kPdfObjekt, tPdfObjekt.textContent, tPdfObjekt.markDown,
|
||||
tPdfObjekt.extraction
|
||||
FROM tZahlungsabgleichUmsatzLink
|
||||
LEFT JOIN tPdfObjekt ON (tZahlungsabgleichUmsatzLink.linktarget = tPdfObjekt.kLieferantenbestellung)
|
||||
WHERE linktype = 'kLieferantenBestellung'
|
||||
`;
|
||||
const linksResult = await executeQuery(linksQuery);
|
||||
|
||||
for(const item of linksResult.recordset){
|
||||
for(const transaction of transactions){
|
||||
if(item.kZahlungsabgleichUmsatz == transaction.kZahlungsabgleichUmsatz){
|
||||
if(!transaction.links) transaction.links = [];
|
||||
transaction.links.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return transactions;
|
||||
} catch (error) {
|
||||
console.error('Error fetching JTL transactions:', error);
|
||||
return [];
|
||||
throw error; // Re-throw the error so the caller knows the database is unavailable
|
||||
}
|
||||
};
|
||||
|
||||
@@ -156,10 +197,13 @@ router.get('/transactions/:timeRange', authenticateToken, async (req, res) => {
|
||||
|
||||
// Get JTL transactions for comparison
|
||||
let jtlTransactions = [];
|
||||
let jtlDatabaseAvailable = false;
|
||||
try {
|
||||
jtlTransactions = await getJTLTransactions();
|
||||
jtlDatabaseAvailable = true;
|
||||
} catch (error) {
|
||||
console.log('JTL database not available, continuing without JTL data');
|
||||
jtlDatabaseAvailable = false;
|
||||
}
|
||||
|
||||
// Filter JTL transactions for the selected time period
|
||||
@@ -213,9 +257,13 @@ router.get('/transactions/:timeRange', authenticateToken, async (req, res) => {
|
||||
|
||||
return {
|
||||
...transaction,
|
||||
hasJTL: !!jtlMatch,
|
||||
hasJTL: jtlDatabaseAvailable ? !!jtlMatch : undefined,
|
||||
jtlId: jtlMatch ? jtlMatch.kZahlungsabgleichUmsatz : null,
|
||||
isFromCSV: true
|
||||
isFromCSV: true,
|
||||
jtlDatabaseAvailable,
|
||||
// Include document data from JTL match
|
||||
pdfs: jtlMatch ? jtlMatch.pdfs || [] : [],
|
||||
links: jtlMatch ? jtlMatch.links || [] : []
|
||||
};
|
||||
});
|
||||
|
||||
@@ -258,7 +306,10 @@ router.get('/transactions/:timeRange', authenticateToken, async (req, res) => {
|
||||
hasJTL: true,
|
||||
jtlId: jtl.kZahlungsabgleichUmsatz,
|
||||
isFromCSV: false,
|
||||
isJTLOnly: true
|
||||
isJTLOnly: true,
|
||||
// Include document data from JTL transaction
|
||||
pdfs: jtl.pdfs || [],
|
||||
links: jtl.links || []
|
||||
}));
|
||||
|
||||
// Combine CSV and JTL-only transactions
|
||||
@@ -275,10 +326,11 @@ router.get('/transactions/:timeRange', authenticateToken, async (req, res) => {
|
||||
.filter(t => t.numericAmount < 0)
|
||||
.reduce((sum, t) => sum + Math.abs(t.numericAmount), 0),
|
||||
netAmount: allTransactions.reduce((sum, t) => sum + t.numericAmount, 0),
|
||||
jtlMatches: allTransactions.filter(t => t.hasJTL && t.isFromCSV).length,
|
||||
jtlMissing: allTransactions.filter(t => !t.hasJTL && t.isFromCSV).length,
|
||||
jtlOnly: allTransactions.filter(t => t.isJTLOnly).length,
|
||||
csvOnly: allTransactions.filter(t => !t.hasJTL && t.isFromCSV).length
|
||||
jtlMatches: jtlDatabaseAvailable ? allTransactions.filter(t => t.hasJTL === true && t.isFromCSV).length : undefined,
|
||||
jtlMissing: jtlDatabaseAvailable ? allTransactions.filter(t => t.hasJTL === false && t.isFromCSV).length : undefined,
|
||||
jtlOnly: jtlDatabaseAvailable ? allTransactions.filter(t => t.isJTLOnly).length : undefined,
|
||||
csvOnly: jtlDatabaseAvailable ? allTransactions.filter(t => t.hasJTL === false && t.isFromCSV).length : undefined,
|
||||
jtlDatabaseAvailable
|
||||
};
|
||||
|
||||
res.json({
|
||||
@@ -440,4 +492,70 @@ router.get('/datev/:timeRange', authenticateToken, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Get PDF from tUmsatzBeleg
|
||||
router.get('/pdf/umsatzbeleg/:kUmsatzBeleg', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { kUmsatzBeleg } = req.params;
|
||||
const { executeQuery } = require('../config/database');
|
||||
|
||||
const query = `
|
||||
SELECT content, datevlink
|
||||
FROM dbo.tUmsatzBeleg
|
||||
WHERE kUmsatzBeleg = @kUmsatzBeleg AND content IS NOT NULL
|
||||
`;
|
||||
|
||||
const result = await executeQuery(query, {
|
||||
kUmsatzBeleg: parseInt(kUmsatzBeleg)
|
||||
});
|
||||
|
||||
if (!result.recordset || result.recordset.length === 0) {
|
||||
return res.status(404).json({ error: 'PDF not found' });
|
||||
}
|
||||
|
||||
const pdfData = result.recordset[0];
|
||||
const filename = `Umsatzbeleg_${kUmsatzBeleg}_${pdfData.datevlink || 'document'}.pdf`;
|
||||
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
|
||||
res.send(pdfData.content);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching PDF from tUmsatzBeleg:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch PDF' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get PDF from tPdfObjekt
|
||||
router.get('/pdf/pdfobject/:kPdfObjekt', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { kPdfObjekt } = req.params;
|
||||
const { executeQuery } = require('../config/database');
|
||||
|
||||
const query = `
|
||||
SELECT content, datevlink, kLieferantenbestellung
|
||||
FROM dbo.tPdfObjekt
|
||||
WHERE kPdfObjekt = @kPdfObjekt AND content IS NOT NULL
|
||||
`;
|
||||
|
||||
const result = await executeQuery(query, {
|
||||
kPdfObjekt: parseInt(kPdfObjekt)
|
||||
});
|
||||
|
||||
if (!result.recordset || result.recordset.length === 0) {
|
||||
return res.status(404).json({ error: 'PDF not found' });
|
||||
}
|
||||
|
||||
const pdfData = result.recordset[0];
|
||||
const filename = `PdfObjekt_${kPdfObjekt}_LB${pdfData.kLieferantenbestellung}_${pdfData.datevlink || 'document'}.pdf`;
|
||||
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
|
||||
res.send(pdfData.content);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching PDF from tPdfObjekt:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch PDF' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -72,6 +72,24 @@ module.exports = {
|
||||
resolve: {
|
||||
extensions: ['.js', '.jsx'],
|
||||
},
|
||||
devServer: {
|
||||
port: 5001,
|
||||
host: '0.0.0.0',
|
||||
allowedHosts: 'all',
|
||||
historyApiFallback: true,
|
||||
hot: false,
|
||||
liveReload: false,
|
||||
client: false,
|
||||
static: {
|
||||
directory: path.join(__dirname, 'client/public'),
|
||||
},
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:5000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
performance: {
|
||||
maxAssetSize: 512000,
|
||||
maxEntrypointSize: 512000,
|
||||
|
||||
Reference in New Issue
Block a user