Add Accounting Items Management and SQL Integration
- Introduced AccountingItemsManager component for managing accounting entries within transactions. - Implemented API routes for creating, retrieving, updating, and deleting accounting items. - Added SQL queries to handle accounting items linked to transactions, supporting both numeric and string transaction IDs. - Enhanced CSV import functionality to include new accounting item handling. - Created mssql.md documentation for SQL command usage related to accounting items.
This commit is contained in:
4
.kilocode/rules/mssql.md
Normal file
4
.kilocode/rules/mssql.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# mssql.md
|
||||||
|
|
||||||
|
sqlcmd -C -S tcp:192.168.56.1,1497 -U app -P 'readonly' -d eazybusiness -W
|
||||||
|
|
||||||
508
client/src/components/AccountingItemsManager.js
Normal file
508
client/src/components/AccountingItemsManager.js
Normal file
@@ -0,0 +1,508 @@
|
|||||||
|
import React, { Component } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Button,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
Paper,
|
||||||
|
TextField,
|
||||||
|
Select,
|
||||||
|
MenuItem,
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
IconButton,
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
Alert,
|
||||||
|
Chip
|
||||||
|
} from '@mui/material';
|
||||||
|
import {
|
||||||
|
Add as AddIcon,
|
||||||
|
Delete as DeleteIcon,
|
||||||
|
Edit as EditIcon,
|
||||||
|
Save as SaveIcon,
|
||||||
|
Cancel as CancelIcon
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
import AuthService from '../services/AuthService';
|
||||||
|
|
||||||
|
class AccountingItemsManager extends Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
accountingItems: [],
|
||||||
|
kontos: [],
|
||||||
|
bus: [],
|
||||||
|
loading: true,
|
||||||
|
editingItem: null,
|
||||||
|
showCreateDialog: false,
|
||||||
|
showCreateKontoDialog: false,
|
||||||
|
newItem: {
|
||||||
|
umsatz_brutto: '',
|
||||||
|
soll_haben_kz: 'S',
|
||||||
|
konto: '',
|
||||||
|
bu: '',
|
||||||
|
rechnungsnummer: '',
|
||||||
|
buchungstext: ''
|
||||||
|
},
|
||||||
|
newKonto: {
|
||||||
|
konto: '',
|
||||||
|
name: ''
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
saving: false
|
||||||
|
};
|
||||||
|
|
||||||
|
this.authService = new AuthService();
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.loadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
loadData = async () => {
|
||||||
|
try {
|
||||||
|
// Load accounting items for this transaction
|
||||||
|
await this.loadAccountingItems();
|
||||||
|
|
||||||
|
// Load Konto and BU options
|
||||||
|
await Promise.all([
|
||||||
|
this.loadKontos(),
|
||||||
|
this.loadBUs()
|
||||||
|
]);
|
||||||
|
|
||||||
|
this.setState({ loading: false });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading data:', error);
|
||||||
|
this.setState({
|
||||||
|
error: 'Fehler beim Laden der Daten',
|
||||||
|
loading: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadAccountingItems = async () => {
|
||||||
|
const { transaction } = this.props;
|
||||||
|
if (!transaction?.id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.authService.apiCall(`/data/accounting-items/${transaction.id}`);
|
||||||
|
if (response && response.ok) {
|
||||||
|
const items = await response.json();
|
||||||
|
this.setState({ accountingItems: items });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading accounting items:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadKontos = async () => {
|
||||||
|
try {
|
||||||
|
const response = await this.authService.apiCall('/data/kontos');
|
||||||
|
if (response && response.ok) {
|
||||||
|
const kontos = await response.json();
|
||||||
|
this.setState({ kontos });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading kontos:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadBUs = async () => {
|
||||||
|
try {
|
||||||
|
const response = await this.authService.apiCall('/data/bus');
|
||||||
|
if (response && response.ok) {
|
||||||
|
const bus = await response.json();
|
||||||
|
this.setState({ bus });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading BUs:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleCreateItem = () => {
|
||||||
|
const { transaction } = this.props;
|
||||||
|
this.setState({
|
||||||
|
showCreateDialog: true,
|
||||||
|
newItem: {
|
||||||
|
umsatz_brutto: Math.abs(transaction.numericAmount || 0).toString(),
|
||||||
|
soll_haben_kz: (transaction.numericAmount || 0) >= 0 ? 'H' : 'S',
|
||||||
|
konto: '',
|
||||||
|
bu: '',
|
||||||
|
rechnungsnummer: '',
|
||||||
|
buchungstext: transaction.description || ''
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
handleSaveItem = async () => {
|
||||||
|
const { transaction } = this.props;
|
||||||
|
const { newItem } = this.state;
|
||||||
|
|
||||||
|
if (!newItem.umsatz_brutto || !newItem.konto) {
|
||||||
|
this.setState({ error: 'Betrag und Konto sind erforderlich' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setState({ saving: true, error: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const itemData = {
|
||||||
|
...newItem,
|
||||||
|
transaction_id: transaction.isFromCSV ? null : transaction.id,
|
||||||
|
csv_transaction_id: transaction.isFromCSV ? transaction.id : null,
|
||||||
|
buchungsdatum: transaction.parsed_date || new Date().toISOString().split('T')[0]
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await this.authService.apiCall('/data/accounting-items', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(itemData)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response && response.ok) {
|
||||||
|
await this.loadAccountingItems();
|
||||||
|
this.setState({
|
||||||
|
showCreateDialog: false,
|
||||||
|
saving: false,
|
||||||
|
newItem: {
|
||||||
|
umsatz_brutto: '',
|
||||||
|
soll_haben_kz: 'S',
|
||||||
|
konto: '',
|
||||||
|
bu: '',
|
||||||
|
rechnungsnummer: '',
|
||||||
|
buchungstext: ''
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const errorData = await response.json();
|
||||||
|
this.setState({
|
||||||
|
error: errorData.error || 'Fehler beim Speichern',
|
||||||
|
saving: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving accounting item:', error);
|
||||||
|
this.setState({
|
||||||
|
error: 'Fehler beim Speichern',
|
||||||
|
saving: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleCreateKonto = async () => {
|
||||||
|
const { newKonto } = this.state;
|
||||||
|
|
||||||
|
if (!newKonto.konto || !newKonto.name) {
|
||||||
|
this.setState({ error: 'Konto-Nummer und Name sind erforderlich' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setState({ saving: true, error: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.authService.apiCall('/data/kontos', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(newKonto)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response && response.ok) {
|
||||||
|
await this.loadKontos();
|
||||||
|
this.setState({
|
||||||
|
showCreateKontoDialog: false,
|
||||||
|
saving: false,
|
||||||
|
newKonto: { konto: '', name: '' }
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const errorData = await response.json();
|
||||||
|
this.setState({
|
||||||
|
error: errorData.error || 'Fehler beim Erstellen des Kontos',
|
||||||
|
saving: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating konto:', error);
|
||||||
|
this.setState({
|
||||||
|
error: 'Fehler beim Erstellen des Kontos',
|
||||||
|
saving: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleDeleteItem = async (itemId) => {
|
||||||
|
if (!window.confirm('Buchungsposten wirklich löschen?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.authService.apiCall(`/data/accounting-items/${itemId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response && response.ok) {
|
||||||
|
await this.loadAccountingItems();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting accounting item:', error);
|
||||||
|
this.setState({ error: 'Fehler beim Löschen' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
calculateTotal = () => {
|
||||||
|
return this.state.accountingItems.reduce((sum, item) => {
|
||||||
|
const amount = parseFloat(item.umsatz_brutto) || 0;
|
||||||
|
return sum + (item.soll_haben_kz === 'S' ? amount : -amount);
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { transaction } = this.props;
|
||||||
|
const {
|
||||||
|
accountingItems,
|
||||||
|
kontos,
|
||||||
|
bus,
|
||||||
|
loading,
|
||||||
|
showCreateDialog,
|
||||||
|
showCreateKontoDialog,
|
||||||
|
newItem,
|
||||||
|
newKonto,
|
||||||
|
error,
|
||||||
|
saving
|
||||||
|
} = this.state;
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <Typography>Lade Buchungsdaten...</Typography>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const transactionAmount = transaction.numericAmount || 0;
|
||||||
|
const currentTotal = this.calculateTotal();
|
||||||
|
const isBalanced = Math.abs(currentTotal - Math.abs(transactionAmount)) < 0.01;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||||
|
<Typography variant="h6">
|
||||||
|
Buchungsposten
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<AddIcon />}
|
||||||
|
onClick={this.handleCreateItem}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
Hinzufügen
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 2 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box sx={{ mb: 2, p: 2, bgcolor: isBalanced ? '#e8f5e8' : '#fff3e0', borderRadius: 1 }}>
|
||||||
|
<Typography variant="body2">
|
||||||
|
<strong>Transaktionsbetrag:</strong> {Math.abs(transactionAmount).toFixed(2)} €
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2">
|
||||||
|
<strong>Summe Buchungsposten:</strong> {Math.abs(currentTotal).toFixed(2)} €
|
||||||
|
</Typography>
|
||||||
|
<Chip
|
||||||
|
label={isBalanced ? "✅ Ausgeglichen" : "⚠️ Nicht ausgeglichen"}
|
||||||
|
color={isBalanced ? "success" : "warning"}
|
||||||
|
size="small"
|
||||||
|
sx={{ mt: 1 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<TableContainer component={Paper}>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Betrag</TableCell>
|
||||||
|
<TableCell>S/H</TableCell>
|
||||||
|
<TableCell>Konto</TableCell>
|
||||||
|
<TableCell>BU</TableCell>
|
||||||
|
<TableCell>Buchungstext</TableCell>
|
||||||
|
<TableCell>Aktionen</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{accountingItems.map((item) => (
|
||||||
|
<TableRow key={item.id}>
|
||||||
|
<TableCell>{parseFloat(item.umsatz_brutto).toFixed(2)} €</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip
|
||||||
|
label={item.soll_haben_kz}
|
||||||
|
color={item.soll_haben_kz === 'S' ? 'primary' : 'secondary'}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{item.konto} - {item.konto_name}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{item.bu ? `${item.bu} - ${item.bu_name}` : '-'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{item.buchungstext || '-'}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => this.handleDeleteItem(item.id)}
|
||||||
|
color="error"
|
||||||
|
>
|
||||||
|
<DeleteIcon />
|
||||||
|
</IconButton>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{accountingItems.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} align="center">
|
||||||
|
<Typography color="textSecondary">
|
||||||
|
Keine Buchungsposten vorhanden
|
||||||
|
</Typography>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
|
||||||
|
{/* Create Item Dialog */}
|
||||||
|
<Dialog open={showCreateDialog} onClose={() => this.setState({ showCreateDialog: false })} maxWidth="sm" fullWidth>
|
||||||
|
<DialogTitle>Neuen Buchungsposten erstellen</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
|
||||||
|
<TextField
|
||||||
|
label="Betrag"
|
||||||
|
type="number"
|
||||||
|
value={newItem.umsatz_brutto}
|
||||||
|
onChange={(e) => this.setState({
|
||||||
|
newItem: { ...newItem, umsatz_brutto: e.target.value }
|
||||||
|
})}
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>Soll/Haben</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={newItem.soll_haben_kz}
|
||||||
|
onChange={(e) => this.setState({
|
||||||
|
newItem: { ...newItem, soll_haben_kz: e.target.value }
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<MenuItem value="S">Soll (S)</MenuItem>
|
||||||
|
<MenuItem value="H">Haben (H)</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-end' }}>
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>Konto</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={newItem.konto}
|
||||||
|
onChange={(e) => this.setState({
|
||||||
|
newItem: { ...newItem, konto: e.target.value }
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{kontos.map((konto) => (
|
||||||
|
<MenuItem key={konto.id} value={konto.konto}>
|
||||||
|
{konto.konto} - {konto.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => this.setState({ showCreateKontoDialog: true })}
|
||||||
|
sx={{ minWidth: 'auto', px: 1 }}
|
||||||
|
>
|
||||||
|
<AddIcon />
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>BU (Steuercode)</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={newItem.bu}
|
||||||
|
onChange={(e) => this.setState({
|
||||||
|
newItem: { ...newItem, bu: e.target.value }
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<MenuItem value="">Kein BU</MenuItem>
|
||||||
|
{bus.map((bu) => (
|
||||||
|
<MenuItem key={bu.id} value={bu.bu}>
|
||||||
|
{bu.bu} - {bu.name} ({bu.vst}%)
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Buchungstext"
|
||||||
|
value={newItem.buchungstext}
|
||||||
|
onChange={(e) => this.setState({
|
||||||
|
newItem: { ...newItem, buchungstext: e.target.value }
|
||||||
|
})}
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => this.setState({ showCreateDialog: false })}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button onClick={this.handleSaveItem} variant="contained" disabled={saving}>
|
||||||
|
{saving ? 'Speichern...' : 'Speichern'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Create Konto Dialog */}
|
||||||
|
<Dialog open={showCreateKontoDialog} onClose={() => this.setState({ showCreateKontoDialog: false })} maxWidth="xs" fullWidth>
|
||||||
|
<DialogTitle>Neues Konto erstellen</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
|
||||||
|
<TextField
|
||||||
|
label="Konto-Nummer"
|
||||||
|
value={newKonto.konto}
|
||||||
|
onChange={(e) => this.setState({
|
||||||
|
newKonto: { ...newKonto, konto: e.target.value }
|
||||||
|
})}
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Konto-Name"
|
||||||
|
value={newKonto.name}
|
||||||
|
onChange={(e) => this.setState({
|
||||||
|
newKonto: { ...newKonto, name: e.target.value }
|
||||||
|
})}
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => this.setState({ showCreateKontoDialog: false })}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button onClick={this.handleCreateKonto} variant="contained" disabled={saving}>
|
||||||
|
{saving ? 'Erstellen...' : 'Erstellen'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AccountingItemsManager;
|
||||||
@@ -11,45 +11,134 @@ import {
|
|||||||
CircularProgress,
|
CircularProgress,
|
||||||
LinearProgress,
|
LinearProgress,
|
||||||
Chip,
|
Chip,
|
||||||
|
Tabs,
|
||||||
|
Tab,
|
||||||
|
Divider,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import {
|
import {
|
||||||
CloudUpload as UploadIcon,
|
CloudUpload as UploadIcon,
|
||||||
CheckCircle as SuccessIcon,
|
CheckCircle as SuccessIcon,
|
||||||
Error as ErrorIcon,
|
Error as ErrorIcon,
|
||||||
|
Link as LinkIcon,
|
||||||
|
AccountBalance as AccountIcon,
|
||||||
|
InfoOutlined as InfoIcon,
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import AuthService from '../services/AuthService';
|
import AuthService from '../services/AuthService';
|
||||||
|
|
||||||
|
const IMPORT_TYPES = {
|
||||||
|
BANKING: 'BANKING',
|
||||||
|
DATEV_LINKS: 'DATEV_LINKS',
|
||||||
|
};
|
||||||
|
|
||||||
class CSVImportDialog extends Component {
|
class CSVImportDialog extends Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = {
|
this.state = {
|
||||||
file: null,
|
// common
|
||||||
csvData: null,
|
activeTab: IMPORT_TYPES.BANKING,
|
||||||
headers: null,
|
|
||||||
importing: false,
|
importing: false,
|
||||||
imported: false,
|
imported: false,
|
||||||
importResult: null,
|
importResult: null,
|
||||||
error: null,
|
error: null,
|
||||||
|
|
||||||
|
// drag/drop visual
|
||||||
dragOver: false,
|
dragOver: false,
|
||||||
|
|
||||||
|
// banking state
|
||||||
|
file: null,
|
||||||
|
csvData: null,
|
||||||
|
headers: null,
|
||||||
|
|
||||||
|
// datev links state
|
||||||
|
datevFile: null,
|
||||||
|
datevCsvData: null,
|
||||||
|
datevHeaders: null,
|
||||||
};
|
};
|
||||||
this.authService = new AuthService();
|
this.authService = new AuthService();
|
||||||
this.fileInputRef = React.createRef();
|
this.fileInputRef = React.createRef();
|
||||||
|
this.datevFileInputRef = React.createRef();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tab switch resets type-specific state but keeps success state as-is
|
||||||
|
handleTabChange = (_e, value) => {
|
||||||
|
this.setState({
|
||||||
|
activeTab: value,
|
||||||
|
// clear type-specific selections and errors
|
||||||
|
file: null,
|
||||||
|
csvData: null,
|
||||||
|
headers: null,
|
||||||
|
datevFile: null,
|
||||||
|
datevCsvData: null,
|
||||||
|
datevHeaders: null,
|
||||||
|
error: null,
|
||||||
|
dragOver: false,
|
||||||
|
// keep importing false when switching
|
||||||
|
importing: false,
|
||||||
|
// keep imported/result to show success for last action regardless of tab
|
||||||
|
// Alternatively, uncomment next two lines to reset success on tab change:
|
||||||
|
// imported: false,
|
||||||
|
// importResult: null,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generic CSV parser (semicolon with quotes)
|
||||||
|
parseCSV = (text) => {
|
||||||
|
const lines = text.split('\n').filter(line => line.trim());
|
||||||
|
if (lines.length < 2) {
|
||||||
|
throw new Error('CSV-Datei muss mindestens eine Kopfzeile und eine Datenzeile enthalten');
|
||||||
|
}
|
||||||
|
const parseCSVLine = (line) => {
|
||||||
|
const result = [];
|
||||||
|
let current = '';
|
||||||
|
let inQuotes = false;
|
||||||
|
for (let i = 0; i < line.length; i++) {
|
||||||
|
const char = line[i];
|
||||||
|
if (char === '"') {
|
||||||
|
inQuotes = !inQuotes;
|
||||||
|
} else if (char === ';' && !inQuotes) {
|
||||||
|
result.push(current.trim());
|
||||||
|
current = '';
|
||||||
|
} else {
|
||||||
|
current += char;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.push(current.trim());
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
const headers = parseCSVLine(lines[0]);
|
||||||
|
const dataRows = lines.slice(1).map(line => {
|
||||||
|
const values = parseCSVLine(line);
|
||||||
|
const row = {};
|
||||||
|
headers.forEach((header, index) => {
|
||||||
|
row[header] = values[index] || '';
|
||||||
|
});
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
return { headers, dataRows };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Banking file handlers
|
||||||
handleFileSelect = (event) => {
|
handleFileSelect = (event) => {
|
||||||
const file = event.target.files[0];
|
const file = event.target.files[0];
|
||||||
if (file) {
|
if (file) {
|
||||||
this.processFile(file);
|
this.processFile(file, IMPORT_TYPES.BANKING);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// DATEV file handlers
|
||||||
|
handleDatevFileSelect = (event) => {
|
||||||
|
const file = event.target.files[0];
|
||||||
|
if (file) {
|
||||||
|
this.processFile(file, IMPORT_TYPES.DATEV_LINKS);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
handleDrop = (event) => {
|
handleDrop = (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
this.setState({ dragOver: false });
|
this.setState({ dragOver: false });
|
||||||
|
|
||||||
const file = event.dataTransfer.files[0];
|
const file = event.dataTransfer.files[0];
|
||||||
if (file) {
|
if (file) {
|
||||||
this.processFile(file);
|
// route to active tab
|
||||||
|
this.processFile(file, this.state.activeTab);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -62,75 +151,49 @@ class CSVImportDialog extends Component {
|
|||||||
this.setState({ dragOver: false });
|
this.setState({ dragOver: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
processFile = (file) => {
|
processFile = (file, type) => {
|
||||||
if (!file.name.toLowerCase().endsWith('.csv')) {
|
if (!file.name.toLowerCase().endsWith('.csv')) {
|
||||||
this.setState({ error: 'Bitte wählen Sie eine CSV-Datei aus' });
|
this.setState({ error: 'Bitte wählen Sie eine CSV-Datei aus' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setState({ file, error: null, csvData: null, headers: null });
|
|
||||||
|
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = (e) => {
|
reader.onload = (e) => {
|
||||||
try {
|
try {
|
||||||
const text = e.target.result;
|
const text = e.target.result;
|
||||||
const lines = text.split('\n').filter(line => line.trim());
|
const { headers, dataRows } = this.parseCSV(text);
|
||||||
|
if (type === IMPORT_TYPES.BANKING) {
|
||||||
if (lines.length < 2) {
|
this.setState({
|
||||||
this.setState({ error: 'CSV-Datei muss mindestens eine Kopfzeile und eine Datenzeile enthalten' });
|
file,
|
||||||
return;
|
csvData: dataRows,
|
||||||
}
|
headers,
|
||||||
|
error: null,
|
||||||
// Parse CSV (simple parsing - assumes semicolon separator and quoted fields)
|
|
||||||
const parseCSVLine = (line) => {
|
|
||||||
const result = [];
|
|
||||||
let current = '';
|
|
||||||
let inQuotes = false;
|
|
||||||
|
|
||||||
for (let i = 0; i < line.length; i++) {
|
|
||||||
const char = line[i];
|
|
||||||
if (char === '"') {
|
|
||||||
inQuotes = !inQuotes;
|
|
||||||
} else if (char === ';' && !inQuotes) {
|
|
||||||
result.push(current.trim());
|
|
||||||
current = '';
|
|
||||||
} else {
|
|
||||||
current += char;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
result.push(current.trim());
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
const headers = parseCSVLine(lines[0]);
|
|
||||||
const dataRows = lines.slice(1).map(line => {
|
|
||||||
const values = parseCSVLine(line);
|
|
||||||
const row = {};
|
|
||||||
headers.forEach((header, index) => {
|
|
||||||
row[header] = values[index] || '';
|
|
||||||
});
|
});
|
||||||
return row;
|
} else {
|
||||||
});
|
this.setState({
|
||||||
|
datevFile: file,
|
||||||
this.setState({
|
datevCsvData: dataRows,
|
||||||
csvData: dataRows,
|
datevHeaders: headers,
|
||||||
headers,
|
error: null,
|
||||||
error: null
|
});
|
||||||
});
|
}
|
||||||
|
} catch (err) {
|
||||||
} catch (error) {
|
console.error('Error parsing CSV:', err);
|
||||||
console.error('Error parsing CSV:', error);
|
this.setState({ error: err.message || 'Fehler beim Lesen der CSV-Datei' });
|
||||||
this.setState({ error: 'Fehler beim Lesen der CSV-Datei' });
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
reader.readAsText(file, 'UTF-8');
|
reader.readAsText(file, 'UTF-8');
|
||||||
};
|
};
|
||||||
|
|
||||||
handleImport = async () => {
|
handleImport = async () => {
|
||||||
const { csvData, headers, file } = this.state;
|
const {
|
||||||
|
activeTab,
|
||||||
if (!csvData || csvData.length === 0) {
|
file, csvData, headers,
|
||||||
|
datevFile, datevCsvData, datevHeaders,
|
||||||
|
} = this.state;
|
||||||
|
|
||||||
|
const isBanking = activeTab === IMPORT_TYPES.BANKING;
|
||||||
|
const hasData = isBanking ? (csvData && csvData.length > 0) : (datevCsvData && datevCsvData.length > 0);
|
||||||
|
if (!hasData) {
|
||||||
this.setState({ error: 'Keine Daten zum Importieren gefunden' });
|
this.setState({ error: 'Keine Daten zum Importieren gefunden' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -138,52 +201,80 @@ class CSVImportDialog extends Component {
|
|||||||
this.setState({ importing: true, error: null });
|
this.setState({ importing: true, error: null });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await this.authService.apiCall('/data/import-csv-transactions', {
|
let endpoint = '';
|
||||||
method: 'POST',
|
let payload = {};
|
||||||
body: JSON.stringify({
|
if (isBanking) {
|
||||||
|
endpoint = '/data/import-csv-transactions';
|
||||||
|
payload = {
|
||||||
transactions: csvData,
|
transactions: csvData,
|
||||||
headers: headers,
|
headers: headers,
|
||||||
filename: file.name,
|
filename: file.name,
|
||||||
batchId: `import_${Date.now()}_${file.name}`
|
batchId: `import_${Date.now()}_${file.name}`,
|
||||||
})
|
};
|
||||||
|
} else {
|
||||||
|
// Placeholder endpoint for DATEV Beleglinks (adjust when backend is available)
|
||||||
|
endpoint = '/data/import-datev-beleglinks';
|
||||||
|
payload = {
|
||||||
|
beleglinks: datevCsvData,
|
||||||
|
headers: datevHeaders,
|
||||||
|
filename: datevFile.name,
|
||||||
|
batchId: `datev_${Date.now()}_${datevFile.name}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await this.authService.apiCall(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response && response.ok) {
|
if (response && response.ok) {
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
this.setState({
|
this.setState({
|
||||||
importing: false,
|
importing: false,
|
||||||
imported: true,
|
imported: true,
|
||||||
importResult: result
|
importResult: result,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (this.props.onImportSuccess) {
|
if (this.props.onImportSuccess) {
|
||||||
this.props.onImportSuccess(result);
|
this.props.onImportSuccess(result);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const errorData = await response.json();
|
let errorText = 'Import fehlgeschlagen';
|
||||||
this.setState({
|
try {
|
||||||
importing: false,
|
const errorData = await response.json();
|
||||||
error: errorData.error || 'Import fehlgeschlagen'
|
errorText = errorData.error || errorText;
|
||||||
|
} catch (_) {}
|
||||||
|
this.setState({
|
||||||
|
importing: false,
|
||||||
|
error: errorText,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Import error:', error);
|
console.error('Import error:', error);
|
||||||
this.setState({
|
this.setState({
|
||||||
importing: false,
|
importing: false,
|
||||||
error: 'Netzwerkfehler beim Import'
|
error: 'Netzwerkfehler beim Import',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
handleClose = () => {
|
handleClose = () => {
|
||||||
this.setState({
|
this.setState({
|
||||||
file: null,
|
// common
|
||||||
csvData: null,
|
|
||||||
headers: null,
|
|
||||||
importing: false,
|
importing: false,
|
||||||
imported: false,
|
imported: false,
|
||||||
importResult: null,
|
importResult: null,
|
||||||
error: null,
|
error: null,
|
||||||
|
dragOver: false,
|
||||||
|
|
||||||
|
// banking
|
||||||
|
file: null,
|
||||||
|
csvData: null,
|
||||||
|
headers: null,
|
||||||
|
|
||||||
|
// datev
|
||||||
|
datevFile: null,
|
||||||
|
datevCsvData: null,
|
||||||
|
datevHeaders: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (this.props.onClose) {
|
if (this.props.onClose) {
|
||||||
@@ -191,19 +282,199 @@ class CSVImportDialog extends Component {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
renderUploadPanel = ({ isBanking }) => {
|
||||||
|
const {
|
||||||
|
dragOver,
|
||||||
|
file, csvData, headers,
|
||||||
|
datevFile, datevCsvData, datevHeaders,
|
||||||
|
} = this.state;
|
||||||
|
|
||||||
|
const currentFile = isBanking ? file : datevFile;
|
||||||
|
const currentHeaders = isBanking ? headers : datevHeaders;
|
||||||
|
const currentData = isBanking ? csvData : datevCsvData;
|
||||||
|
|
||||||
|
const onClickPick = () => {
|
||||||
|
if (isBanking) {
|
||||||
|
this.fileInputRef.current?.click();
|
||||||
|
} else {
|
||||||
|
this.datevFileInputRef.current?.click();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
border: '2px dashed',
|
||||||
|
borderColor: dragOver ? 'primary.main' : 'grey.300',
|
||||||
|
borderRadius: 2,
|
||||||
|
p: 4,
|
||||||
|
textAlign: 'center',
|
||||||
|
bgcolor: dragOver ? 'action.hover' : 'background.paper',
|
||||||
|
cursor: 'pointer',
|
||||||
|
mb: 2,
|
||||||
|
}}
|
||||||
|
onDrop={this.handleDrop}
|
||||||
|
onDragOver={this.handleDragOver}
|
||||||
|
onDragLeave={this.handleDragLeave}
|
||||||
|
onClick={onClickPick}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".csv"
|
||||||
|
onChange={isBanking ? this.handleFileSelect : this.handleDatevFileSelect}
|
||||||
|
ref={isBanking ? this.fileInputRef : this.datevFileInputRef}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isBanking ? (
|
||||||
|
<AccountIcon sx={{ fontSize: 48, color: 'grey.400', mb: 2 }} />
|
||||||
|
) : (
|
||||||
|
<LinkIcon sx={{ fontSize: 48, color: 'grey.400', mb: 2 }} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
{isBanking ? 'Bankkontoumsätze CSV hier ablegen oder klicken zum Auswählen' : 'DATEV Beleglinks CSV hier ablegen oder klicken zum Auswählen'}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="textSecondary">
|
||||||
|
Unterstützte Formate: .csv (Semikolon-getrennt)
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{!isBanking && (
|
||||||
|
<Box sx={{ mb: 2 }}>
|
||||||
|
<Divider sx={{ mb: 2 }} />
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, mb: 1 }}>
|
||||||
|
<InfoIcon sx={{ color: 'info.main', mt: '2px' }} />
|
||||||
|
<Typography variant="subtitle1">Hinweise zum DATEV Beleglink-Upload</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="body2" paragraph sx={{ color: 'text.secondary' }}>
|
||||||
|
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer posuere, neque at efficitur
|
||||||
|
blandit, sapien libero finibus nunc, a facilisis lacus arcu sed urna. Suspendisse potenti.
|
||||||
|
Phasellus tincidunt, lorem in dictum lacinia, sem tortor ultrices risus, vitae porta odio
|
||||||
|
mauris non neque. Sed vitae nibh dapibus, viverra velit nec, aliquet odio.
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" paragraph sx={{ color: 'text.secondary' }}>
|
||||||
|
Cras lacinia, massa a sagittis placerat, enim dolor fermentum lectus, in pulvinar mi risus ut
|
||||||
|
ipsum. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis
|
||||||
|
egestas. Mauris mattis lorem sit amet risus mattis volutpat. Proin sit amet hendrerit lectus.
|
||||||
|
</Typography>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: '100%',
|
||||||
|
borderRadius: 1,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
display: 'block',
|
||||||
|
mb: 1.5,
|
||||||
|
overflow: 'hidden',
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
component="svg"
|
||||||
|
viewBox="0 0 640 300"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
sx={{ width: '100%', height: 'auto', display: 'block' }}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" style={{ stopColor: '#e3f2fd', stopOpacity: 1 }} />
|
||||||
|
<stop offset="100%" style={{ stopColor: '#e8f5e9', stopOpacity: 1 }} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect x="0" y="0" width="640" height="300" fill="url(#grad)" />
|
||||||
|
<rect x="20" y="20" width="600" height="60" rx="8" fill="#ffffff" stroke="#cfd8dc" />
|
||||||
|
<circle cx="50" cy="50" r="12" fill="#81c784" />
|
||||||
|
<rect x="75" y="38" width="200" height="12" rx="6" fill="#90caf9" />
|
||||||
|
<rect x="75" y="56" width="140" height="10" rx="5" fill="#b0bec5" />
|
||||||
|
|
||||||
|
<rect x="20" y="100" width="600" height="160" rx="8" fill="#ffffff" stroke="#cfd8dc" />
|
||||||
|
<rect x="40" y="120" width="160" height="20" rx="4" fill="#ffe082" />
|
||||||
|
<rect x="40" y="150" width="260" height="12" rx="6" fill="#b0bec5" />
|
||||||
|
<rect x="40" y="170" width="220" height="10" rx="5" fill="#eceff1" />
|
||||||
|
<rect x="40" y="190" width="300" height="10" rx="5" fill="#eceff1" />
|
||||||
|
|
||||||
|
<rect x="330" y="120" width="270" height="120" rx="8" fill="#f1f8e9" stroke="#c5e1a5" />
|
||||||
|
<rect x="350" y="140" width="230" height="12" rx="6" fill="#c5e1a5" />
|
||||||
|
<rect x="350" y="160" width="180" height="10" rx="5" fill="#dcedc8" />
|
||||||
|
<g>
|
||||||
|
<line x1="350" y1="190" x2="580" y2="190" stroke="#aed581" strokeWidth="2" />
|
||||||
|
<line x1="350" y1="200" x2="560" y2="200" stroke="#aed581" strokeWidth="2" />
|
||||||
|
<line x1="350" y1="210" x2="520" y2="210" stroke="#aed581" strokeWidth="2" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g fill="#90a4ae">
|
||||||
|
<rect x="540" y="28" width="20" height="6" rx="3" />
|
||||||
|
<rect x="565" y="28" width="20" height="6" rx="3" />
|
||||||
|
<rect x="590" y="28" width="20" height="6" rx="3" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<text x="320" y="285" textAnchor="middle" fontFamily="Arial, Helvetica, sans-serif" fontSize="12" fill="#90a4ae">
|
||||||
|
DATEV Beleglinks Beispiel – Platzhalter Illustration
|
||||||
|
</text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.disabled' }}>
|
||||||
|
Beispiel-Screenshot (SVG Platzhalter mit Shapes). Ersetzen Sie dieses Bild später durch die endgültige Anleitungsgrafik.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentFile && (
|
||||||
|
<Box sx={{ mb: 2 }}>
|
||||||
|
<Typography variant="subtitle2" gutterBottom>
|
||||||
|
Ausgewählte Datei:
|
||||||
|
</Typography>
|
||||||
|
<Chip label={currentFile.name} color="primary" />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentHeaders && (
|
||||||
|
<Box sx={{ mb: 2 }}>
|
||||||
|
<Typography variant="subtitle2" gutterBottom>
|
||||||
|
Erkannte Spalten ({currentHeaders.length}):
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||||
|
{currentHeaders.slice(0, 10).map((header, index) => (
|
||||||
|
<Chip key={index} label={header} size="small" variant="outlined" />
|
||||||
|
))}
|
||||||
|
{currentHeaders.length > 10 && (
|
||||||
|
<Chip label={`+${currentHeaders.length - 10} weitere`} size="small" />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentData && (
|
||||||
|
<Box sx={{ mb: 2 }}>
|
||||||
|
<Typography variant="subtitle2" gutterBottom>
|
||||||
|
{isBanking ? 'Gefundene Transaktionen' : 'Gefundene Beleglinks'}: {currentData.length}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="textSecondary">
|
||||||
|
Die Daten werden validiert und in die Datenbank importiert.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { open } = this.props;
|
const { open } = this.props;
|
||||||
const {
|
const {
|
||||||
file,
|
activeTab,
|
||||||
csvData,
|
importing,
|
||||||
headers,
|
imported,
|
||||||
importing,
|
importResult,
|
||||||
imported,
|
error,
|
||||||
importResult,
|
csvData,
|
||||||
error,
|
datevCsvData,
|
||||||
dragOver
|
|
||||||
} = this.state;
|
} = this.state;
|
||||||
|
|
||||||
|
const isBanking = activeTab === IMPORT_TYPES.BANKING;
|
||||||
|
const hasData = isBanking ? csvData : datevCsvData;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
open={open}
|
open={open}
|
||||||
@@ -211,82 +482,22 @@ class CSVImportDialog extends Component {
|
|||||||
maxWidth="md"
|
maxWidth="md"
|
||||||
fullWidth
|
fullWidth
|
||||||
>
|
>
|
||||||
<DialogTitle>
|
<DialogTitle>CSV Import</DialogTitle>
|
||||||
CSV Transaktionen Importieren
|
|
||||||
</DialogTitle>
|
<Tabs
|
||||||
|
value={activeTab}
|
||||||
|
onChange={this.handleTabChange}
|
||||||
|
variant="fullWidth"
|
||||||
|
sx={{ borderBottom: 1, borderColor: 'divider' }}
|
||||||
|
>
|
||||||
|
<Tab value={IMPORT_TYPES.BANKING} iconPosition="start" icon={<AccountIcon />} label="Banking Umsätze" />
|
||||||
|
<Tab value={IMPORT_TYPES.DATEV_LINKS} iconPosition="start" icon={<LinkIcon />} label="DATEV Beleglinks" />
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
{!imported ? (
|
{!imported ? (
|
||||||
<>
|
<>
|
||||||
{/* File Upload Area */}
|
{this.renderUploadPanel({ isBanking })}
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
border: '2px dashed',
|
|
||||||
borderColor: dragOver ? 'primary.main' : 'grey.300',
|
|
||||||
borderRadius: 2,
|
|
||||||
p: 4,
|
|
||||||
textAlign: 'center',
|
|
||||||
bgcolor: dragOver ? 'action.hover' : 'background.paper',
|
|
||||||
cursor: 'pointer',
|
|
||||||
mb: 2,
|
|
||||||
}}
|
|
||||||
onDrop={this.handleDrop}
|
|
||||||
onDragOver={this.handleDragOver}
|
|
||||||
onDragLeave={this.handleDragLeave}
|
|
||||||
onClick={() => this.fileInputRef.current?.click()}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept=".csv"
|
|
||||||
onChange={this.handleFileSelect}
|
|
||||||
ref={this.fileInputRef}
|
|
||||||
style={{ display: 'none' }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<UploadIcon sx={{ fontSize: 48, color: 'grey.400', mb: 2 }} />
|
|
||||||
<Typography variant="h6" gutterBottom>
|
|
||||||
CSV-Datei hier ablegen oder klicken zum Auswählen
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" color="textSecondary">
|
|
||||||
Unterstützte Formate: .csv (Semikolon-getrennt)
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{file && (
|
|
||||||
<Box sx={{ mb: 2 }}>
|
|
||||||
<Typography variant="subtitle2" gutterBottom>
|
|
||||||
Ausgewählte Datei:
|
|
||||||
</Typography>
|
|
||||||
<Chip label={file.name} color="primary" />
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{headers && (
|
|
||||||
<Box sx={{ mb: 2 }}>
|
|
||||||
<Typography variant="subtitle2" gutterBottom>
|
|
||||||
Erkannte Spalten ({headers.length}):
|
|
||||||
</Typography>
|
|
||||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
|
||||||
{headers.slice(0, 10).map((header, index) => (
|
|
||||||
<Chip key={index} label={header} size="small" variant="outlined" />
|
|
||||||
))}
|
|
||||||
{headers.length > 10 && (
|
|
||||||
<Chip label={`+${headers.length - 10} weitere`} size="small" />
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{csvData && (
|
|
||||||
<Box sx={{ mb: 2 }}>
|
|
||||||
<Typography variant="subtitle2" gutterBottom>
|
|
||||||
Gefundene Transaktionen: {csvData.length}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" color="textSecondary">
|
|
||||||
Die Daten werden validiert und in die Datenbank importiert.
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<Alert severity="error" sx={{ mb: 2 }}>
|
<Alert severity="error" sx={{ mb: 2 }}>
|
||||||
@@ -298,13 +509,12 @@ class CSVImportDialog extends Component {
|
|||||||
<Box sx={{ mb: 2 }}>
|
<Box sx={{ mb: 2 }}>
|
||||||
<LinearProgress />
|
<LinearProgress />
|
||||||
<Typography variant="body2" sx={{ mt: 1, textAlign: 'center' }}>
|
<Typography variant="body2" sx={{ mt: 1, textAlign: 'center' }}>
|
||||||
Importiere Transaktionen...
|
{isBanking ? 'Importiere Transaktionen...' : 'Importiere DATEV Beleglinks...'}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
/* Import Success */
|
|
||||||
<Box sx={{ textAlign: 'center', py: 2 }}>
|
<Box sx={{ textAlign: 'center', py: 2 }}>
|
||||||
<SuccessIcon sx={{ fontSize: 64, color: 'success.main', mb: 2 }} />
|
<SuccessIcon sx={{ fontSize: 64, color: 'success.main', mb: 2 }} />
|
||||||
<Typography variant="h6" gutterBottom>
|
<Typography variant="h6" gutterBottom>
|
||||||
@@ -314,7 +524,7 @@ class CSVImportDialog extends Component {
|
|||||||
{importResult && (
|
{importResult && (
|
||||||
<Box sx={{ mt: 2 }}>
|
<Box sx={{ mt: 2 }}>
|
||||||
<Typography variant="body1" gutterBottom>
|
<Typography variant="body1" gutterBottom>
|
||||||
<strong>Importiert:</strong> {importResult.imported} Transaktionen
|
<strong>Importiert:</strong> {importResult.imported} {isBanking ? 'Transaktionen' : 'Beleglinks'}
|
||||||
</Typography>
|
</Typography>
|
||||||
{importResult.errors > 0 && (
|
{importResult.errors > 0 && (
|
||||||
<Typography variant="body1" color="warning.main">
|
<Typography variant="body1" color="warning.main">
|
||||||
@@ -334,11 +544,11 @@ class CSVImportDialog extends Component {
|
|||||||
<Button onClick={this.handleClose} disabled={importing}>
|
<Button onClick={this.handleClose} disabled={importing}>
|
||||||
{imported ? 'Schließen' : 'Abbrechen'}
|
{imported ? 'Schließen' : 'Abbrechen'}
|
||||||
</Button>
|
</Button>
|
||||||
{!imported && csvData && (
|
{!imported && hasData && (
|
||||||
<Button
|
<Button
|
||||||
onClick={this.handleImport}
|
onClick={this.handleImport}
|
||||||
variant="contained"
|
variant="contained"
|
||||||
disabled={importing || !csvData}
|
disabled={importing || !hasData}
|
||||||
startIcon={importing ? <CircularProgress size={16} /> : <UploadIcon />}
|
startIcon={importing ? <CircularProgress size={16} /> : <UploadIcon />}
|
||||||
>
|
>
|
||||||
{importing ? 'Importiere...' : 'Importieren'}
|
{importing ? 'Importiere...' : 'Importieren'}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
import { AgGridReact } from 'ag-grid-react';
|
import { AgGridReact } from 'ag-grid-react';
|
||||||
import KreditorSelector from '../KreditorSelector';
|
import KreditorSelector from '../KreditorSelector';
|
||||||
import BankingKreditorSelector from '../BankingKreditorSelector';
|
import BankingKreditorSelector from '../BankingKreditorSelector';
|
||||||
|
import AccountingItemsManager from '../AccountingItemsManager';
|
||||||
|
|
||||||
const DocumentRenderer = (params) => {
|
const DocumentRenderer = (params) => {
|
||||||
// Check for pdfs and links regardless of transaction source
|
// Check for pdfs and links regardless of transaction source
|
||||||
@@ -466,30 +467,27 @@ const DocumentRenderer = (params) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{tabValue === 1 && (
|
{tabValue === 1 && (
|
||||||
<Box sx={{ p: 2, height: 500 }}>
|
<Box sx={{ p: 2 }}>
|
||||||
{lineItems.length > 0 ? (
|
{/* Accounting Items Manager */}
|
||||||
<div style={{ height: '100%', width: '100%' }}>
|
<AccountingItemsManager transaction={params.data} />
|
||||||
<AgGridReact
|
|
||||||
columnDefs={columnDefs}
|
{/* Document Line Items (if any) */}
|
||||||
rowData={lineItems}
|
{lineItems.length > 0 && (
|
||||||
defaultColDef={defaultColDef}
|
<Box sx={{ mt: 3 }}>
|
||||||
suppressRowTransform={true}
|
<Typography variant="h6" gutterBottom>
|
||||||
rowHeight={50}
|
Erkannte Positionen aus Dokumenten
|
||||||
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>
|
</Typography>
|
||||||
|
<div style={{ height: '300px', width: '100%' }}>
|
||||||
|
<AgGridReact
|
||||||
|
columnDefs={columnDefs}
|
||||||
|
rowData={lineItems}
|
||||||
|
defaultColDef={defaultColDef}
|
||||||
|
suppressRowTransform={true}
|
||||||
|
rowHeight={50}
|
||||||
|
headerHeight={35}
|
||||||
|
domLayout="normal"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
240
src/routes/data/accountingItems.js
Normal file
240
src/routes/data/accountingItems.js
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { authenticateToken } = require('../../middleware/auth');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Get accounting items for a specific transaction
|
||||||
|
router.get('/accounting-items/:transactionId', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { executeQuery } = require('../../config/database');
|
||||||
|
const { transactionId } = req.params;
|
||||||
|
|
||||||
|
// Try both numeric and string format (similar to banking transactions)
|
||||||
|
let query, params;
|
||||||
|
const numericId = parseInt(transactionId, 10);
|
||||||
|
|
||||||
|
if (!isNaN(numericId) && numericId.toString() === transactionId) {
|
||||||
|
// It's a numeric ID - check transaction_id column
|
||||||
|
query = `
|
||||||
|
SELECT
|
||||||
|
ai.*,
|
||||||
|
k.name as konto_name,
|
||||||
|
bu.name as bu_name,
|
||||||
|
bu.vst as bu_vst
|
||||||
|
FROM fibdash.AccountingItems ai
|
||||||
|
LEFT JOIN fibdash.Konto k ON ai.konto = k.konto
|
||||||
|
LEFT JOIN fibdash.BU bu ON ai.bu = bu.bu
|
||||||
|
WHERE ai.transaction_id = @transactionId
|
||||||
|
ORDER BY ai.id
|
||||||
|
`;
|
||||||
|
params = { transactionId: numericId };
|
||||||
|
} else {
|
||||||
|
// It's a string ID - check csv_transaction_id column
|
||||||
|
query = `
|
||||||
|
SELECT
|
||||||
|
ai.*,
|
||||||
|
k.name as konto_name,
|
||||||
|
bu.name as bu_name,
|
||||||
|
bu.vst as bu_vst
|
||||||
|
FROM fibdash.AccountingItems ai
|
||||||
|
LEFT JOIN fibdash.Konto k ON ai.konto = k.konto
|
||||||
|
LEFT JOIN fibdash.BU bu ON ai.bu = bu.bu
|
||||||
|
WHERE ai.csv_transaction_id = @transactionId
|
||||||
|
ORDER BY ai.id
|
||||||
|
`;
|
||||||
|
params = { transactionId };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await executeQuery(query, params);
|
||||||
|
res.json(result.recordset);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching accounting items:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch accounting items' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create accounting item for a transaction
|
||||||
|
router.post('/accounting-items', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { executeQuery } = require('../../config/database');
|
||||||
|
const {
|
||||||
|
transaction_id,
|
||||||
|
csv_transaction_id,
|
||||||
|
umsatz_brutto,
|
||||||
|
soll_haben_kz,
|
||||||
|
konto,
|
||||||
|
bu,
|
||||||
|
buchungsdatum,
|
||||||
|
rechnungsnummer,
|
||||||
|
buchungstext
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
if ((!transaction_id && !csv_transaction_id) || !umsatz_brutto || !soll_haben_kz || !konto || !buchungsdatum) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Transaction ID, amount, debit/credit indicator, account, and booking date are required'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let insertQuery, queryParams;
|
||||||
|
|
||||||
|
if (csv_transaction_id) {
|
||||||
|
// For CSV transactions, use placeholder transaction_id
|
||||||
|
insertQuery = `
|
||||||
|
INSERT INTO fibdash.AccountingItems
|
||||||
|
(transaction_id, csv_transaction_id, umsatz_brutto, soll_haben_kz, konto, gegenkonto, bu, buchungsdatum, rechnungsnummer, buchungstext)
|
||||||
|
OUTPUT INSERTED.*
|
||||||
|
VALUES (-1, @csv_transaction_id, @umsatz_brutto, @soll_haben_kz, @konto, '', @bu, @buchungsdatum, @rechnungsnummer, @buchungstext)
|
||||||
|
`;
|
||||||
|
queryParams = {
|
||||||
|
csv_transaction_id,
|
||||||
|
umsatz_brutto,
|
||||||
|
soll_haben_kz,
|
||||||
|
konto,
|
||||||
|
bu: bu || null,
|
||||||
|
buchungsdatum,
|
||||||
|
rechnungsnummer: rechnungsnummer || null,
|
||||||
|
buchungstext: buchungstext || null
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// For regular transactions
|
||||||
|
insertQuery = `
|
||||||
|
INSERT INTO fibdash.AccountingItems
|
||||||
|
(transaction_id, csv_transaction_id, umsatz_brutto, soll_haben_kz, konto, gegenkonto, bu, buchungsdatum, rechnungsnummer, buchungstext)
|
||||||
|
OUTPUT INSERTED.*
|
||||||
|
VALUES (@transaction_id, NULL, @umsatz_brutto, @soll_haben_kz, @konto, '', @bu, @buchungsdatum, @rechnungsnummer, @buchungstext)
|
||||||
|
`;
|
||||||
|
queryParams = {
|
||||||
|
transaction_id,
|
||||||
|
umsatz_brutto,
|
||||||
|
soll_haben_kz,
|
||||||
|
konto,
|
||||||
|
bu: bu || null,
|
||||||
|
buchungsdatum,
|
||||||
|
rechnungsnummer: rechnungsnummer || null,
|
||||||
|
buchungstext: buchungstext || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await executeQuery(insertQuery, queryParams);
|
||||||
|
res.status(201).json(result.recordset[0]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating accounting item:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to create accounting item' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update accounting item
|
||||||
|
router.put('/accounting-items/:id', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { executeQuery } = require('../../config/database');
|
||||||
|
const { id } = req.params;
|
||||||
|
const { umsatz_brutto, soll_haben_kz, konto, bu, rechnungsnummer, buchungstext } = req.body;
|
||||||
|
|
||||||
|
if (!umsatz_brutto || !soll_haben_kz || !konto) {
|
||||||
|
return res.status(400).json({ error: 'Amount, debit/credit indicator, and account are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateQuery = `
|
||||||
|
UPDATE fibdash.AccountingItems
|
||||||
|
SET umsatz_brutto = @umsatz_brutto,
|
||||||
|
soll_haben_kz = @soll_haben_kz,
|
||||||
|
konto = @konto,
|
||||||
|
bu = @bu,
|
||||||
|
rechnungsnummer = @rechnungsnummer,
|
||||||
|
buchungstext = @buchungstext
|
||||||
|
OUTPUT INSERTED.*
|
||||||
|
WHERE id = @id
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await executeQuery(updateQuery, {
|
||||||
|
umsatz_brutto,
|
||||||
|
soll_haben_kz,
|
||||||
|
konto,
|
||||||
|
bu: bu || null,
|
||||||
|
rechnungsnummer: rechnungsnummer || null,
|
||||||
|
buchungstext: buchungstext || null,
|
||||||
|
id: parseInt(id, 10)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.recordset.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Accounting item not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(result.recordset[0]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating accounting item:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to update accounting item' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete accounting item
|
||||||
|
router.delete('/accounting-items/:id', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { executeQuery } = require('../../config/database');
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const deleteQuery = `DELETE FROM fibdash.AccountingItems WHERE id = @id`;
|
||||||
|
await executeQuery(deleteQuery, { id: parseInt(id, 10) });
|
||||||
|
|
||||||
|
res.json({ message: 'Accounting item deleted successfully' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting accounting item:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to delete accounting item' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get all Konto options
|
||||||
|
router.get('/kontos', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { executeQuery } = require('../../config/database');
|
||||||
|
|
||||||
|
const query = `SELECT * FROM fibdash.Konto ORDER BY konto`;
|
||||||
|
const result = await executeQuery(query);
|
||||||
|
|
||||||
|
res.json(result.recordset);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching kontos:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch kontos' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create new Konto
|
||||||
|
router.post('/kontos', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { executeQuery } = require('../../config/database');
|
||||||
|
const { konto, name } = req.body;
|
||||||
|
|
||||||
|
if (!konto || !name) {
|
||||||
|
return res.status(400).json({ error: 'Konto and name are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertQuery = `
|
||||||
|
INSERT INTO fibdash.Konto (konto, name)
|
||||||
|
OUTPUT INSERTED.*
|
||||||
|
VALUES (@konto, @name)
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await executeQuery(insertQuery, { konto, name });
|
||||||
|
res.status(201).json(result.recordset[0]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating konto:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to create konto' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get all BU options
|
||||||
|
router.get('/bus', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { executeQuery } = require('../../config/database');
|
||||||
|
|
||||||
|
const query = `SELECT * FROM fibdash.BU ORDER BY bu`;
|
||||||
|
const result = await executeQuery(query);
|
||||||
|
|
||||||
|
res.json(result.recordset);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching BUs:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch BUs' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -6,6 +6,7 @@ const datev = require('./datev');
|
|||||||
const pdf = require('./pdf');
|
const pdf = require('./pdf');
|
||||||
const kreditors = require('./kreditors');
|
const kreditors = require('./kreditors');
|
||||||
const bankingTransactions = require('./bankingTransactions');
|
const bankingTransactions = require('./bankingTransactions');
|
||||||
|
const accountingItems = require('./accountingItems');
|
||||||
const csvImport = require('./csvImport');
|
const csvImport = require('./csvImport');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -17,6 +18,7 @@ router.use(datev);
|
|||||||
router.use(pdf);
|
router.use(pdf);
|
||||||
router.use(kreditors);
|
router.use(kreditors);
|
||||||
router.use(bankingTransactions);
|
router.use(bankingTransactions);
|
||||||
|
router.use(accountingItems);
|
||||||
router.use(csvImport);
|
router.use(csvImport);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
@@ -108,7 +108,41 @@ router.get('/transactions/:timeRange', authenticateToken, async (req, res) => {
|
|||||||
const linksResult = await executeQuery(linksQuery);
|
const linksResult = await executeQuery(linksQuery);
|
||||||
const linksData = linksResult.recordset || [];
|
const linksData = linksResult.recordset || [];
|
||||||
|
|
||||||
const transactions = result.recordset.map(transaction => ({
|
// Group transactions by ID to handle multiple JTL matches
|
||||||
|
const transactionGroups = {};
|
||||||
|
result.recordset.forEach(row => {
|
||||||
|
const key = row.id;
|
||||||
|
if (!transactionGroups[key]) {
|
||||||
|
transactionGroups[key] = {
|
||||||
|
...row,
|
||||||
|
pdfs: [],
|
||||||
|
links: []
|
||||||
|
};
|
||||||
|
// Remove top-level kUmsatzBeleg and datevlink since they belong in pdfs array
|
||||||
|
delete transactionGroups[key].kUmsatzBeleg;
|
||||||
|
delete transactionGroups[key].datevlink;
|
||||||
|
delete transactionGroups[key].jtl_document_data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add PDF data if present
|
||||||
|
if (row.jtl_document_data) {
|
||||||
|
transactionGroups[key].pdfs.push({
|
||||||
|
content: row.jtl_document_data,
|
||||||
|
kUmsatzBeleg: row.kUmsatzBeleg,
|
||||||
|
datevlink: row.datevlink
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add links data if present
|
||||||
|
if (row.jtlId) {
|
||||||
|
const transactionLinks = linksData.filter(link =>
|
||||||
|
link.kZahlungsabgleichUmsatz === row.jtlId
|
||||||
|
);
|
||||||
|
transactionGroups[key].links.push(...transactionLinks);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const transactions = Object.values(transactionGroups).map(transaction => ({
|
||||||
...transaction,
|
...transaction,
|
||||||
parsedDate: new Date(transaction.parsed_date),
|
parsedDate: new Date(transaction.parsed_date),
|
||||||
hasJTL: Boolean(transaction.hasJTL),
|
hasJTL: Boolean(transaction.hasJTL),
|
||||||
@@ -125,14 +159,8 @@ router.get('/transactions/:timeRange', authenticateToken, async (req, res) => {
|
|||||||
id: transaction.assigned_kreditor_id,
|
id: transaction.assigned_kreditor_id,
|
||||||
kreditorId: transaction.assigned_kreditor_kreditorId
|
kreditorId: transaction.assigned_kreditor_kreditorId
|
||||||
} : null,
|
} : null,
|
||||||
pdfs: transaction.jtl_document_data ? [{
|
// Remove duplicate links
|
||||||
content: transaction.jtl_document_data,
|
links: [...new Set(transaction.links.map(l => JSON.stringify(l)))].map(l => JSON.parse(l))
|
||||||
kUmsatzBeleg: transaction.kUmsatzBeleg,
|
|
||||||
datevlink: transaction.datevlink
|
|
||||||
}] : [],
|
|
||||||
links: transaction.jtlId ? linksData.filter(link =>
|
|
||||||
link.kZahlungsabgleichUmsatz === transaction.jtlId
|
|
||||||
) : []
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
let filteredTransactions = [];
|
let filteredTransactions = [];
|
||||||
|
|||||||
Reference in New Issue
Block a user