Compare commits

...

3 Commits

19 changed files with 1571 additions and 209 deletions

View File

@@ -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>

View File

@@ -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} />
)}

View File

@@ -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>

View File

@@ -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>
);

View File

@@ -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>
);
}

View 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;

View File

@@ -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';

View File

@@ -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>
);
};

View 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;

View File

@@ -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
};

View 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 />);
}
}

View 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;

View File

@@ -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();
};
@@ -135,6 +146,9 @@ class HeaderComponent extends Component {
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={{
width: '100%',
@@ -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 && (

View File

@@ -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('-');

View File

@@ -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 = {

View File

@@ -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);

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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,