Implement DATEV export functionality in DataViewer and enhance TransactionsTable with selection features and improved row styling. Update environment variables and add devServer configuration in webpack for better development experience.
This commit is contained in:
@@ -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',
|
||||
@@ -50,6 +69,7 @@ export const getColumnDefs = () => [
|
||||
field: 'Kontonummer/IBAN',
|
||||
width: 180,
|
||||
sortable: true,
|
||||
filter: IbanSelectionFilter,
|
||||
headerComponent: TextHeaderWithFilter,
|
||||
tooltipField: 'Kontonummer/IBAN',
|
||||
cellRenderer: RecipientRenderer
|
||||
@@ -79,7 +99,8 @@ export const getColumnDefs = () => [
|
||||
field: 'typeText',
|
||||
width: 70,
|
||||
cellRenderer: TypeRenderer,
|
||||
sortable: true,
|
||||
sortable: false,
|
||||
suppressSorting: true,
|
||||
filter: CheckboxFilter,
|
||||
filterParams: {
|
||||
filterOptions: [
|
||||
@@ -115,7 +136,8 @@ export const getColumnDefs = () => [
|
||||
field: 'hasJTL',
|
||||
width: 70,
|
||||
cellRenderer: JtlRenderer,
|
||||
sortable: true,
|
||||
sortable: false,
|
||||
suppressSorting: true,
|
||||
filter: CheckboxFilter,
|
||||
filterParams: {
|
||||
filterOptions: [
|
||||
@@ -146,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
|
||||
}
|
||||
];
|
||||
|
||||
@@ -164,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,
|
||||
@@ -179,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);
|
||||
|
||||
@@ -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