Compare commits
3 Commits
b9af7694a0
...
58f5bb4b4f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58f5bb4b4f | ||
|
|
429fd70497 | ||
|
|
2a43b7106d |
@@ -34,6 +34,34 @@
|
|||||||
min-width: 200px;
|
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>
|
</style>
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import CssBaseline from '@mui/material/CssBaseline';
|
|||||||
import { Container, AppBar, Toolbar, Typography, Button, Box } from '@mui/material';
|
import { Container, AppBar, Toolbar, Typography, Button, Box } from '@mui/material';
|
||||||
import LoginIcon from '@mui/icons-material/Login';
|
import LoginIcon from '@mui/icons-material/Login';
|
||||||
import DashboardIcon from '@mui/icons-material/Dashboard';
|
import DashboardIcon from '@mui/icons-material/Dashboard';
|
||||||
|
import DownloadIcon from '@mui/icons-material/Download';
|
||||||
import AuthService from './services/AuthService';
|
import AuthService from './services/AuthService';
|
||||||
import DataViewer from './components/DataViewer';
|
import DataViewer from './components/DataViewer';
|
||||||
import Login from './components/Login';
|
import Login from './components/Login';
|
||||||
@@ -27,6 +28,7 @@ class App extends Component {
|
|||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
user: null,
|
user: null,
|
||||||
loading: true,
|
loading: true,
|
||||||
|
exportData: null, // { selectedMonth, canExport, onExport }
|
||||||
};
|
};
|
||||||
this.authService = new AuthService();
|
this.authService = new AuthService();
|
||||||
}
|
}
|
||||||
@@ -68,7 +70,11 @@ class App extends Component {
|
|||||||
|
|
||||||
handleLogout = () => {
|
handleLogout = () => {
|
||||||
localStorage.removeItem('token');
|
localStorage.removeItem('token');
|
||||||
this.setState({ isAuthenticated: false, user: null });
|
this.setState({ isAuthenticated: false, user: null, exportData: null });
|
||||||
|
};
|
||||||
|
|
||||||
|
updateExportData = (exportData) => {
|
||||||
|
this.setState({ exportData });
|
||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
@@ -107,6 +113,24 @@ class App extends Component {
|
|||||||
>
|
>
|
||||||
Willkommen, {user.name}
|
Willkommen, {user.name}
|
||||||
</Typography>
|
</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
|
<Button
|
||||||
color="inherit"
|
color="inherit"
|
||||||
onClick={this.handleLogout}
|
onClick={this.handleLogout}
|
||||||
@@ -127,9 +151,9 @@ class App extends Component {
|
|||||||
</AppBar>
|
</AppBar>
|
||||||
|
|
||||||
<Box sx={{ height: 'calc(100vh - 64px)', display: 'flex', flexDirection: 'column' }}>
|
<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 ? (
|
{isAuthenticated ? (
|
||||||
<DataViewer user={user} />
|
<DataViewer user={user} onUpdateExportData={this.updateExportData} />
|
||||||
) : (
|
) : (
|
||||||
<Login onLogin={this.handleLogin} />
|
<Login onLogin={this.handleLogin} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -24,6 +24,13 @@ class DataViewer extends Component {
|
|||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
this.loadMonths();
|
this.loadMonths();
|
||||||
|
this.updateExportData();
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps, prevState) {
|
||||||
|
if (prevState.loading !== this.state.loading || prevState.selectedMonth !== this.state.selectedMonth) {
|
||||||
|
this.updateExportData();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loadMonths = async () => {
|
loadMonths = async () => {
|
||||||
@@ -70,6 +77,7 @@ class DataViewer extends Component {
|
|||||||
const monthYear = event.target.value;
|
const monthYear = event.target.value;
|
||||||
this.setState({ selectedMonth: monthYear });
|
this.setState({ selectedMonth: monthYear });
|
||||||
this.loadTransactions(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() {
|
render() {
|
||||||
const { months, selectedMonth, transactions, summary, loading, error } = this.state;
|
const { months, selectedMonth, transactions, summary, loading, error } = this.state;
|
||||||
|
|
||||||
@@ -126,7 +144,6 @@ class DataViewer extends Component {
|
|||||||
summary={summary}
|
summary={summary}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
onMonthChange={this.handleMonthChange}
|
onMonthChange={this.handleMonthChange}
|
||||||
onDownloadDatev={this.downloadDatev}
|
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|||||||
@@ -95,8 +95,7 @@ class SummaryHeader extends Component {
|
|||||||
selectedMonth,
|
selectedMonth,
|
||||||
summary,
|
summary,
|
||||||
loading,
|
loading,
|
||||||
onMonthChange,
|
onMonthChange
|
||||||
onDownloadDatev
|
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
if (!summary) return null;
|
if (!summary) return null;
|
||||||
@@ -249,23 +248,19 @@ class SummaryHeader extends Component {
|
|||||||
<Grid item xs={6} sm={4} md={1}>
|
<Grid item xs={6} sm={4} md={1}>
|
||||||
<Box textAlign="center">
|
<Box textAlign="center">
|
||||||
<Typography variant="caption" color="textSecondary">JTL ✓</Typography>
|
<Typography variant="caption" color="textSecondary">JTL ✓</Typography>
|
||||||
<Typography variant="h6" sx={{ fontWeight: 'bold', color: '#388e3c', fontSize: { xs: '0.9rem', sm: '1.25rem' } }}>
|
<Typography
|
||||||
{summary.jtlMatches || 0}
|
variant="h6"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: summary.jtlMatches === undefined ? '#856404' : '#388e3c',
|
||||||
|
fontSize: { xs: '0.9rem', sm: '1.25rem' }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{summary.jtlMatches === undefined ? '?' : summary.jtlMatches}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Grid>
|
</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>
|
</Grid>
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,9 +9,12 @@ import {
|
|||||||
Paper,
|
Paper,
|
||||||
Typography,
|
Typography,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
|
IconButton,
|
||||||
|
Tooltip,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
|
import { Clear as ClearIcon } from '@mui/icons-material';
|
||||||
import { getColumnDefs, defaultColDef, gridOptions } from './config/gridConfig';
|
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 {
|
class TransactionsTable extends Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
@@ -20,20 +23,59 @@ class TransactionsTable extends Component {
|
|||||||
this.state = {
|
this.state = {
|
||||||
columnDefs: getColumnDefs(),
|
columnDefs: getColumnDefs(),
|
||||||
defaultColDef: defaultColDef,
|
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) {
|
componentDidUpdate(prevProps) {
|
||||||
// Update data without recreating grid when transactions change
|
// Update data without recreating grid when transactions change
|
||||||
if (prevProps.transactions !== this.props.transactions && this.gridApi) {
|
if (prevProps.transactions !== this.props.transactions && this.gridApi) {
|
||||||
const processedTransactions = this.props.transactions ? processTransactionData(this.props.transactions) : [];
|
const processedTransactions = this.props.transactions ? processTransactionData(this.props.transactions) : [];
|
||||||
// Use setGridOption to update data while preserving grid state
|
// Use setGridOption to update data while preserving grid state
|
||||||
this.gridApi.setGridOption('rowData', processedTransactions);
|
this.gridApi.setGridOption('rowData', processedTransactions);
|
||||||
|
// 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.gridApi = params.api;
|
||||||
this.gridColumnApi = params.columnApi;
|
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
|
// Set initial data if available
|
||||||
if (this.props.transactions) {
|
if (this.props.transactions) {
|
||||||
const processedTransactions = processTransactionData(this.props.transactions);
|
const processedTransactions = processTransactionData(this.props.transactions);
|
||||||
this.gridApi.setGridOption('rowData', processedTransactions);
|
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
|
// Auto-size columns to fit content
|
||||||
params.api.sizeColumnsToFit();
|
params.api.sizeColumnsToFit();
|
||||||
|
|
||||||
@@ -64,23 +120,143 @@ class TransactionsTable extends Component {
|
|||||||
this.gridInitialized = true;
|
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() {
|
render() {
|
||||||
const { selectedMonth, loading } = this.props;
|
const { selectedMonth, loading } = this.props;
|
||||||
|
const { totalRows, displayedRows, selectedRows } = this.state;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper elevation={2} sx={{
|
<Paper elevation={2} sx={{
|
||||||
m: 0,
|
m: 0,
|
||||||
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column'
|
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' }}>
|
<Box sx={{ flex: 1, width: '100%', minHeight: 0, position: 'relative' }}>
|
||||||
{loading && (
|
{loading && (
|
||||||
@@ -103,16 +279,27 @@ class TransactionsTable extends Component {
|
|||||||
)}
|
)}
|
||||||
<div style={{ height: '100%', width: '100%' }}>
|
<div style={{ height: '100%', width: '100%' }}>
|
||||||
<AgGridReact
|
<AgGridReact
|
||||||
key="transactions-grid" // Stable key prevents recreation
|
key="transactions-grid"
|
||||||
columnDefs={this.state.columnDefs}
|
columnDefs={this.state.columnDefs}
|
||||||
// Remove rowData prop - data is set via API in componentDidUpdate
|
// Remove rowData prop - data is set via API in componentDidUpdate
|
||||||
defaultColDef={this.state.defaultColDef}
|
defaultColDef={this.state.defaultColDef}
|
||||||
gridOptions={this.state.gridOptions}
|
gridOptions={this.state.gridOptions}
|
||||||
onGridReady={this.onGridReady}
|
onGridReady={this.onGridReady}
|
||||||
getRowStyle={getRowStyle}
|
getRowStyle={(params) => getRowStyle(params, this.state.selectedRows)}
|
||||||
|
getRowClass={(params) => getRowClass(params, this.state.selectedRows)}
|
||||||
suppressRowTransform={true}
|
suppressRowTransform={true}
|
||||||
// Use new theming system
|
// Use new theming system
|
||||||
theme={themeQuartz}
|
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
|
// Virtualization settings for performance
|
||||||
rowBuffer={10}
|
rowBuffer={10}
|
||||||
suppressRowVirtualisation={false}
|
suppressRowVirtualisation={false}
|
||||||
@@ -126,6 +313,134 @@ class TransactionsTable extends Component {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Box>
|
</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>
|
</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 JtlRenderer = (params) => {
|
||||||
const hasJTL = params.value;
|
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 backgroundColor = hasJTL ? '#388e3c' : '#f5f5f5';
|
||||||
const border = hasJTL ? 'none' : '1px solid #ccc';
|
const border = hasJTL ? 'none' : '1px solid #ccc';
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,39 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
const RecipientRenderer = (params) => {
|
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 (
|
return (
|
||||||
<span style={{ fontSize: '0.7rem', lineHeight: '1.2' }}>
|
<span
|
||||||
{params.value}
|
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>
|
</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 CheckboxFilter from '../filters/CheckboxFilter';
|
||||||
|
import IbanSelectionFilter from '../filters/IbanSelectionFilter';
|
||||||
import TextHeaderWithFilter from '../headers/TextHeaderWithFilter';
|
import TextHeaderWithFilter from '../headers/TextHeaderWithFilter';
|
||||||
|
import SelectionHeader from '../headers/SelectionHeader';
|
||||||
import AmountRenderer from '../cellRenderers/AmountRenderer';
|
import AmountRenderer from '../cellRenderers/AmountRenderer';
|
||||||
import TypeRenderer from '../cellRenderers/TypeRenderer';
|
import TypeRenderer from '../cellRenderers/TypeRenderer';
|
||||||
import JtlRenderer from '../cellRenderers/JtlRenderer';
|
import JtlRenderer from '../cellRenderers/JtlRenderer';
|
||||||
import DescriptionRenderer from '../cellRenderers/DescriptionRenderer';
|
import DescriptionRenderer from '../cellRenderers/DescriptionRenderer';
|
||||||
import RecipientRenderer from '../cellRenderers/RecipientRenderer';
|
import RecipientRenderer from '../cellRenderers/RecipientRenderer';
|
||||||
|
import DocumentRenderer from '../cellRenderers/DocumentRenderer';
|
||||||
|
import SelectionRenderer from '../cellRenderers/SelectionRenderer';
|
||||||
|
|
||||||
const formatDate = (dateString) => {
|
const formatDate = (dateString) => {
|
||||||
if (!dateString) return '';
|
if (!dateString) return '';
|
||||||
@@ -16,6 +20,21 @@ const formatDate = (dateString) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const getColumnDefs = () => [
|
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',
|
headerName: 'Datum',
|
||||||
field: 'Buchungstag',
|
field: 'Buchungstag',
|
||||||
@@ -37,7 +56,7 @@ export const getColumnDefs = () => [
|
|||||||
cellRenderer: DescriptionRenderer
|
cellRenderer: DescriptionRenderer
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
headerName: 'Empfänger/Zahler',
|
headerName: 'Name',
|
||||||
field: 'Beguenstigter/Zahlungspflichtiger',
|
field: 'Beguenstigter/Zahlungspflichtiger',
|
||||||
width: 200,
|
width: 200,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
@@ -45,6 +64,25 @@ export const getColumnDefs = () => [
|
|||||||
tooltipField: 'Beguenstigter/Zahlungspflichtiger',
|
tooltipField: 'Beguenstigter/Zahlungspflichtiger',
|
||||||
cellRenderer: RecipientRenderer
|
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',
|
headerName: 'Betrag',
|
||||||
field: 'numericAmount',
|
field: 'numericAmount',
|
||||||
@@ -61,7 +99,8 @@ export const getColumnDefs = () => [
|
|||||||
field: 'typeText',
|
field: 'typeText',
|
||||||
width: 70,
|
width: 70,
|
||||||
cellRenderer: TypeRenderer,
|
cellRenderer: TypeRenderer,
|
||||||
sortable: true,
|
sortable: false,
|
||||||
|
suppressSorting: true,
|
||||||
filter: CheckboxFilter,
|
filter: CheckboxFilter,
|
||||||
filterParams: {
|
filterParams: {
|
||||||
filterOptions: [
|
filterOptions: [
|
||||||
@@ -97,7 +136,8 @@ export const getColumnDefs = () => [
|
|||||||
field: 'hasJTL',
|
field: 'hasJTL',
|
||||||
width: 70,
|
width: 70,
|
||||||
cellRenderer: JtlRenderer,
|
cellRenderer: JtlRenderer,
|
||||||
sortable: true,
|
sortable: false,
|
||||||
|
suppressSorting: true,
|
||||||
filter: CheckboxFilter,
|
filter: CheckboxFilter,
|
||||||
filterParams: {
|
filterParams: {
|
||||||
filterOptions: [
|
filterOptions: [
|
||||||
@@ -128,11 +168,36 @@ export const getColumnDefs = () => [
|
|||||||
border: '1px solid #ccc'
|
border: '1px solid #ccc'
|
||||||
},
|
},
|
||||||
condition: (fieldValue) => fieldValue === false
|
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,
|
floatingFilter: false,
|
||||||
headerComponent: TextHeaderWithFilter
|
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 = {
|
export const gridOptions = {
|
||||||
animateRows: true,
|
animateRows: true,
|
||||||
rowSelection: {
|
rowSelection: false,
|
||||||
mode: 'singleRow',
|
|
||||||
enableClickSelection: true
|
|
||||||
},
|
|
||||||
rowBuffer: 10,
|
rowBuffer: 10,
|
||||||
// Enable virtualization (default behavior)
|
// Enable virtualization (default behavior)
|
||||||
suppressRowVirtualisation: false,
|
suppressRowVirtualisation: false,
|
||||||
@@ -161,5 +223,9 @@ export const gridOptions = {
|
|||||||
headerHeight: 40,
|
headerHeight: 40,
|
||||||
// Pagination (optional - can be removed for infinite scrolling)
|
// Pagination (optional - can be removed for infinite scrolling)
|
||||||
pagination: false,
|
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 = () => {
|
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
|
// Force re-render to update filter icon color
|
||||||
this.forceUpdate();
|
this.forceUpdate();
|
||||||
};
|
};
|
||||||
@@ -135,6 +146,9 @@ class HeaderComponent extends Component {
|
|||||||
column.colDef.field === 'Beguenstigter/Zahlungspflichtiger';
|
column.colDef.field === 'Beguenstigter/Zahlungspflichtiger';
|
||||||
const showTextFilter = isTextColumn;
|
const showTextFilter = isTextColumn;
|
||||||
|
|
||||||
|
// Check if sorting is disabled for this column
|
||||||
|
const isSortingDisabled = column.colDef.sortable === false || column.colDef.suppressSorting === true;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{
|
<Box sx={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
@@ -153,44 +167,46 @@ class HeaderComponent extends Component {
|
|||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
fontSize: '13px',
|
fontSize: '13px',
|
||||||
color: '#212529',
|
color: '#212529',
|
||||||
cursor: 'pointer',
|
cursor: isSortingDisabled ? 'default' : 'pointer',
|
||||||
minWidth: 'fit-content',
|
minWidth: 'fit-content',
|
||||||
marginRight: '6px',
|
marginRight: '6px',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
'&:hover': {
|
'&: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}
|
{displayName}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
{/* Sort Icons - Always visible */}
|
{/* Sort Icons - Only visible when sorting is enabled */}
|
||||||
<Box sx={{
|
{!isSortingDisabled && (
|
||||||
display: 'flex',
|
<Box sx={{
|
||||||
flexDirection: 'column',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
flexDirection: 'column',
|
||||||
marginRight: '6px',
|
alignItems: 'center',
|
||||||
gap: 0
|
marginRight: '6px',
|
||||||
}}>
|
gap: 0
|
||||||
<SortUpIcon sx={{
|
}}>
|
||||||
fontSize: '10px',
|
<SortUpIcon sx={{
|
||||||
color: sortDirection === 'asc' ? '#0969da' : '#ccc',
|
fontSize: '10px',
|
||||||
opacity: sortDirection === 'asc' ? 1 : 0.5,
|
color: sortDirection === 'asc' ? '#0969da' : '#ccc',
|
||||||
cursor: 'pointer'
|
opacity: sortDirection === 'asc' ? 1 : 0.5,
|
||||||
}}
|
cursor: 'pointer'
|
||||||
onClick={(e) => this.onSortRequested('asc', e)}
|
}}
|
||||||
/>
|
onClick={(e) => this.onSortRequested('asc', e)}
|
||||||
<SortDownIcon sx={{
|
/>
|
||||||
fontSize: '10px',
|
<SortDownIcon sx={{
|
||||||
color: sortDirection === 'desc' ? '#0969da' : '#ccc',
|
fontSize: '10px',
|
||||||
opacity: sortDirection === 'desc' ? 1 : 0.5,
|
color: sortDirection === 'desc' ? '#0969da' : '#ccc',
|
||||||
cursor: 'pointer'
|
opacity: sortDirection === 'desc' ? 1 : 0.5,
|
||||||
}}
|
cursor: 'pointer'
|
||||||
onClick={(e) => this.onSortRequested('desc', e)}
|
}}
|
||||||
/>
|
onClick={(e) => this.onSortRequested('desc', e)}
|
||||||
</Box>
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Filter Input - only for text columns */}
|
{/* Filter Input - only for text columns */}
|
||||||
{showTextFilter && (
|
{showTextFilter && (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export const processTransactionData = (transactions) => {
|
export const processTransactionData = (transactions) => {
|
||||||
return transactions.map(transaction => ({
|
return transactions.map((transaction, index) => ({
|
||||||
...transaction,
|
...transaction,
|
||||||
|
id: `row-${index}-${transaction.Buchungstag}-${transaction.numericAmount}`, // Unique ID
|
||||||
description: transaction['Verwendungszweck'] || transaction['Buchungstext'],
|
description: transaction['Verwendungszweck'] || transaction['Buchungstext'],
|
||||||
type: transaction.numericAmount >= 0 ? 'Income' : 'Expense',
|
type: transaction.numericAmount >= 0 ? 'Income' : 'Expense',
|
||||||
isIncome: transaction.numericAmount >= 0,
|
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) {
|
if (params.data.isJTLOnly) {
|
||||||
return {
|
return {
|
||||||
backgroundColor: '#ffebee',
|
backgroundColor: isSelected ? '#e3f2fd' : '#ffebee',
|
||||||
borderLeft: '4px solid #f44336'
|
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;
|
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) => {
|
export const getMonthName = (monthYear) => {
|
||||||
if (!monthYear) return '';
|
if (!monthYear) return '';
|
||||||
const [year, month] = monthYear.split('-');
|
const [year, month] = monthYear.split('-');
|
||||||
|
|||||||
@@ -1,16 +1,5 @@
|
|||||||
const checkAuthorizedEmail = (req, res, next) => {
|
const checkAuthorizedEmail = async (req, res, next) => {
|
||||||
const authorizedEmails = process.env.AUTHORIZED_EMAILS;
|
const userEmail = req.user?.email;
|
||||||
|
|
||||||
// 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();
|
|
||||||
|
|
||||||
if (!userEmail) {
|
if (!userEmail) {
|
||||||
return res.status(401).json({
|
return res.status(401).json({
|
||||||
@@ -19,27 +8,75 @@ const checkAuthorizedEmail = (req, res, next) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!emailList.includes(userEmail)) {
|
try {
|
||||||
return res.status(403).json({
|
const authorized = await isEmailAuthorized(userEmail);
|
||||||
error: 'Access denied',
|
|
||||||
message: 'Your email address is not authorized to access this application'
|
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 authorizedEmails = process.env.AUTHORIZED_EMAILS;
|
||||||
|
const userEmail = email.toLowerCase();
|
||||||
|
|
||||||
// If no authorized emails are configured, deny all users
|
console.log(`🔍 Checking authorization for email: ${userEmail}`);
|
||||||
if (!authorizedEmails || authorizedEmails.trim() === '') {
|
|
||||||
return false;
|
// 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());
|
// Then check database
|
||||||
const userEmail = email.toLowerCase();
|
console.log(`🗄️ Checking database authorization for: ${userEmail}`);
|
||||||
return emailList.includes(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 = {
|
module.exports = {
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ router.post('/google', async (req, res) => {
|
|||||||
console.log(`👤 Google token verified for: ${email}`);
|
console.log(`👤 Google token verified for: ${email}`);
|
||||||
|
|
||||||
// Check if email is authorized
|
// Check if email is authorized
|
||||||
const authorized = isEmailAuthorized(email);
|
const authorized = await isEmailAuthorized(email);
|
||||||
console.log(`🔒 Email authorization check for ${email}: ${authorized ? 'ALLOWED' : 'DENIED'}`);
|
console.log(`🔒 Email authorization check for ${email}: ${authorized ? 'ALLOWED' : 'DENIED'}`);
|
||||||
|
|
||||||
if (!authorized) {
|
if (!authorized) {
|
||||||
@@ -46,50 +46,15 @@ router.post('/google', async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user exists in database (optional - auth works without DB)
|
// Create user object from Google data (no database storage needed)
|
||||||
let user;
|
const user = {
|
||||||
try {
|
id: googleId,
|
||||||
// Only try database operations if DB is configured
|
email,
|
||||||
if (process.env.DB_SERVER) {
|
name,
|
||||||
const userResult = await executeQuery(
|
picture,
|
||||||
'SELECT * FROM Users WHERE email = @email',
|
google_id: googleId,
|
||||||
{ email }
|
};
|
||||||
);
|
console.log('✅ User object created from Google authentication');
|
||||||
|
|
||||||
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)');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate JWT token
|
// Generate JWT token
|
||||||
const jwtToken = generateToken(user);
|
const jwtToken = generateToken(user);
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { authenticateToken } = require('../middleware/auth');
|
const { authenticateToken } = require('../middleware/auth');
|
||||||
const { executeQuery } = require('../config/database');
|
|
||||||
const { checkAuthorizedEmail } = require('../middleware/emailAuth');
|
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -18,45 +16,15 @@ router.get('/', authenticateToken, async (req, res) => {
|
|||||||
recentActivity: []
|
recentActivity: []
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
// Use mock data since we don't store user information in database
|
||||||
// Only try database operations if configured
|
dashboardData.stats[0].value = 'N/A';
|
||||||
if (process.env.DB_SERVER) {
|
dashboardData.stats[1].value = '$0';
|
||||||
// Try to fetch real data from database
|
dashboardData.stats[2].value = '0';
|
||||||
const userCountResult = await executeQuery('SELECT COUNT(*) as count FROM Users');
|
dashboardData.stats[3].value = '0%';
|
||||||
const userCount = userCountResult.recordset[0]?.count || 0;
|
dashboardData.recentActivity = [
|
||||||
|
{ description: 'System operational', timestamp: new Date().toISOString().slice(0, 16) }
|
||||||
// Update stats with real data
|
];
|
||||||
dashboardData.stats[0].value = userCount.toString();
|
console.log('✅ Dashboard loaded with mock data (no user storage)');
|
||||||
|
|
||||||
// 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');
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json(dashboardData);
|
res.json(dashboardData);
|
||||||
|
|
||||||
@@ -69,29 +37,12 @@ router.get('/', authenticateToken, async (req, res) => {
|
|||||||
// Get user-specific data
|
// Get user-specific data
|
||||||
router.get('/user', authenticateToken, async (req, res) => {
|
router.get('/user', authenticateToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const userId = req.user.id;
|
const userData = {
|
||||||
|
|
||||||
let userData = {
|
|
||||||
profile: req.user,
|
profile: req.user,
|
||||||
preferences: {},
|
preferences: {},
|
||||||
activity: []
|
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);
|
res.json(userData);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -103,10 +103,51 @@ const getJTLTransactions = async () => {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
const result = await executeQuery(query);
|
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) {
|
} catch (error) {
|
||||||
console.error('Error fetching JTL transactions:', 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
|
// Get JTL transactions for comparison
|
||||||
let jtlTransactions = [];
|
let jtlTransactions = [];
|
||||||
|
let jtlDatabaseAvailable = false;
|
||||||
try {
|
try {
|
||||||
jtlTransactions = await getJTLTransactions();
|
jtlTransactions = await getJTLTransactions();
|
||||||
|
jtlDatabaseAvailable = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log('JTL database not available, continuing without JTL data');
|
console.log('JTL database not available, continuing without JTL data');
|
||||||
|
jtlDatabaseAvailable = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter JTL transactions for the selected time period
|
// Filter JTL transactions for the selected time period
|
||||||
@@ -213,9 +257,13 @@ router.get('/transactions/:timeRange', authenticateToken, async (req, res) => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...transaction,
|
...transaction,
|
||||||
hasJTL: !!jtlMatch,
|
hasJTL: jtlDatabaseAvailable ? !!jtlMatch : undefined,
|
||||||
jtlId: jtlMatch ? jtlMatch.kZahlungsabgleichUmsatz : null,
|
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,
|
hasJTL: true,
|
||||||
jtlId: jtl.kZahlungsabgleichUmsatz,
|
jtlId: jtl.kZahlungsabgleichUmsatz,
|
||||||
isFromCSV: false,
|
isFromCSV: false,
|
||||||
isJTLOnly: true
|
isJTLOnly: true,
|
||||||
|
// Include document data from JTL transaction
|
||||||
|
pdfs: jtl.pdfs || [],
|
||||||
|
links: jtl.links || []
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Combine CSV and JTL-only transactions
|
// Combine CSV and JTL-only transactions
|
||||||
@@ -275,10 +326,11 @@ router.get('/transactions/:timeRange', authenticateToken, async (req, res) => {
|
|||||||
.filter(t => t.numericAmount < 0)
|
.filter(t => t.numericAmount < 0)
|
||||||
.reduce((sum, t) => sum + Math.abs(t.numericAmount), 0),
|
.reduce((sum, t) => sum + Math.abs(t.numericAmount), 0),
|
||||||
netAmount: allTransactions.reduce((sum, t) => sum + t.numericAmount, 0),
|
netAmount: allTransactions.reduce((sum, t) => sum + t.numericAmount, 0),
|
||||||
jtlMatches: allTransactions.filter(t => t.hasJTL && t.isFromCSV).length,
|
jtlMatches: jtlDatabaseAvailable ? allTransactions.filter(t => t.hasJTL === true && t.isFromCSV).length : undefined,
|
||||||
jtlMissing: allTransactions.filter(t => !t.hasJTL && t.isFromCSV).length,
|
jtlMissing: jtlDatabaseAvailable ? allTransactions.filter(t => t.hasJTL === false && t.isFromCSV).length : undefined,
|
||||||
jtlOnly: allTransactions.filter(t => t.isJTLOnly).length,
|
jtlOnly: jtlDatabaseAvailable ? allTransactions.filter(t => t.isJTLOnly).length : undefined,
|
||||||
csvOnly: allTransactions.filter(t => !t.hasJTL && t.isFromCSV).length
|
csvOnly: jtlDatabaseAvailable ? allTransactions.filter(t => t.hasJTL === false && t.isFromCSV).length : undefined,
|
||||||
|
jtlDatabaseAvailable
|
||||||
};
|
};
|
||||||
|
|
||||||
res.json({
|
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;
|
module.exports = router;
|
||||||
@@ -72,6 +72,24 @@ module.exports = {
|
|||||||
resolve: {
|
resolve: {
|
||||||
extensions: ['.js', '.jsx'],
|
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: {
|
performance: {
|
||||||
maxAssetSize: 512000,
|
maxAssetSize: 512000,
|
||||||
maxEntrypointSize: 512000,
|
maxEntrypointSize: 512000,
|
||||||
|
|||||||
Reference in New Issue
Block a user