Compare commits

...

2 Commits

Author SHA1 Message Date
sebseb7
d60da0a7aa Refactor CSVImportDialog to CSVImportPanel and enhance UI components
- Renamed CSVImportDialog component to CSVImportPanel for clarity.
- Replaced Dialog with Paper component for improved layout.
- Removed unused code and comments to streamline the component.
- Updated import result messages for better user feedback.
- Enhanced button styles and layout for a more user-friendly interface.
- Added new API route for importing DATEV Beleglinks to the database, including validation and error handling.
2025-08-05 10:17:54 +02:00
sebseb7
46c9e9b97d 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.
2025-08-05 09:25:32 +02:00
9 changed files with 1347 additions and 236 deletions

4
.kilocode/rules/mssql.md Normal file
View File

@@ -0,0 +1,4 @@
# mssql.md
sqlcmd -C -S tcp:192.168.56.1,1497 -U app -P 'readonly' -d eazybusiness -W

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

View File

@@ -1,9 +1,5 @@
import React, { Component } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Typography,
Box,
@@ -11,82 +7,87 @@ import {
CircularProgress,
LinearProgress,
Chip,
Tabs,
Tab,
Divider,
Paper,
} from '@mui/material';
import {
CloudUpload as UploadIcon,
CheckCircle as SuccessIcon,
Error as ErrorIcon,
Link as LinkIcon,
AccountBalance as AccountIcon,
InfoOutlined as InfoIcon,
} from '@mui/icons-material';
import AuthService from '../services/AuthService';
class CSVImportDialog extends Component {
const IMPORT_TYPES = {
BANKING: 'BANKING',
DATEV_LINKS: 'DATEV_LINKS',
};
class CSVImportPanel extends Component {
constructor(props) {
super(props);
this.state = {
file: null,
csvData: null,
headers: null,
// common
activeTab: IMPORT_TYPES.BANKING,
importing: false,
imported: false,
importResult: null,
error: null,
// drag/drop visual
dragOver: false,
// banking state
file: null,
csvData: null,
headers: null,
// datev links state
datevFile: null,
datevCsvData: null,
datevHeaders: null,
};
this.authService = new AuthService();
this.fileInputRef = React.createRef();
this.datevFileInputRef = React.createRef();
}
handleFileSelect = (event) => {
const file = event.target.files[0];
if (file) {
this.processFile(file);
}
// 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,
});
};
handleDrop = (event) => {
event.preventDefault();
this.setState({ dragOver: false });
const file = event.dataTransfer.files[0];
if (file) {
this.processFile(file);
}
};
handleDragOver = (event) => {
event.preventDefault();
this.setState({ dragOver: true });
};
handleDragLeave = () => {
this.setState({ dragOver: false });
};
processFile = (file) => {
if (!file.name.toLowerCase().endsWith('.csv')) {
this.setState({ error: 'Bitte wählen Sie eine CSV-Datei aus' });
return;
}
this.setState({ file, error: null, csvData: null, headers: null });
const reader = new FileReader();
reader.onload = (e) => {
try {
const text = e.target.result;
// Generic CSV parser (semicolon with quotes)
parseCSV = (text) => {
const lines = text.split('\n').filter(line => line.trim());
if (lines.length < 2) {
this.setState({ error: 'CSV-Datei muss mindestens eine Kopfzeile und eine Datenzeile enthalten' });
return;
throw new Error('CSV-Datei muss mindestens eine Kopfzeile und eine Datenzeile enthalten');
}
// 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 === '"') {
@@ -101,7 +102,6 @@ class CSVImportDialog extends Component {
result.push(current.trim());
return result;
};
const headers = parseCSVLine(lines[0]);
const dataRows = lines.slice(1).map(line => {
const values = parseCSVLine(line);
@@ -111,26 +111,86 @@ class CSVImportDialog extends Component {
});
return row;
});
return { headers, dataRows };
};
this.setState({
csvData: dataRows,
headers,
error: null
});
} catch (error) {
console.error('Error parsing CSV:', error);
this.setState({ error: 'Fehler beim Lesen der CSV-Datei' });
// Banking file handlers
handleFileSelect = (event) => {
const file = event.target.files[0];
if (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) => {
event.preventDefault();
this.setState({ dragOver: false });
const file = event.dataTransfer.files[0];
if (file) {
// route to active tab
this.processFile(file, this.state.activeTab);
}
};
handleDragOver = (event) => {
event.preventDefault();
this.setState({ dragOver: true });
};
handleDragLeave = () => {
this.setState({ dragOver: false });
};
processFile = (file, type) => {
if (!file.name.toLowerCase().endsWith('.csv')) {
this.setState({ error: 'Bitte wählen Sie eine CSV-Datei aus' });
return;
}
const reader = new FileReader();
reader.onload = (e) => {
try {
const text = e.target.result;
const { headers, dataRows } = this.parseCSV(text);
if (type === IMPORT_TYPES.BANKING) {
this.setState({
file,
csvData: dataRows,
headers,
error: null,
});
} else {
this.setState({
datevFile: file,
datevCsvData: dataRows,
datevHeaders: headers,
error: null,
});
}
} catch (err) {
console.error('Error parsing CSV:', err);
this.setState({ error: err.message || 'Fehler beim Lesen der CSV-Datei' });
}
};
reader.readAsText(file, 'UTF-8');
};
handleImport = async () => {
const { csvData, headers, file } = this.state;
const {
activeTab,
file, csvData, headers,
datevFile, datevCsvData, datevHeaders,
} = this.state;
if (!csvData || csvData.length === 0) {
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' });
return;
}
@@ -138,14 +198,30 @@ class CSVImportDialog extends Component {
this.setState({ importing: true, error: null });
try {
const response = await this.authService.apiCall('/data/import-csv-transactions', {
method: 'POST',
body: JSON.stringify({
let endpoint = '';
let payload = {};
if (isBanking) {
endpoint = '/data/import-csv-transactions';
payload = {
transactions: csvData,
headers: headers,
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) {
@@ -153,72 +229,73 @@ class CSVImportDialog extends Component {
this.setState({
importing: false,
imported: true,
importResult: result
importResult: result,
});
if (this.props.onImportSuccess) {
this.props.onImportSuccess(result);
}
} else {
let errorText = 'Import fehlgeschlagen';
try {
const errorData = await response.json();
errorText = errorData.error || errorText;
} catch (_) {}
this.setState({
importing: false,
error: errorData.error || 'Import fehlgeschlagen'
error: errorText,
});
}
} catch (error) {
console.error('Import error:', error);
this.setState({
importing: false,
error: 'Netzwerkfehler beim Import'
error: 'Netzwerkfehler beim Import',
});
}
};
handleClose = () => {
this.setState({
file: null,
csvData: null,
headers: null,
// common
importing: false,
imported: false,
importResult: null,
error: null,
});
dragOver: false,
if (this.props.onClose) {
this.props.onClose();
// banking
file: null,
csvData: null,
headers: null,
// datev
datevFile: null,
datevCsvData: null,
datevHeaders: null,
});
};
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();
}
};
render() {
const { open } = this.props;
const {
file,
csvData,
headers,
importing,
imported,
importResult,
error,
dragOver
} = this.state;
return (
<Dialog
open={open}
onClose={!importing ? this.handleClose : undefined}
maxWidth="md"
fullWidth
>
<DialogTitle>
CSV Transaktionen Importieren
</DialogTitle>
<DialogContent>
{!imported ? (
<>
{/* File Upload Area */}
<Box
sx={{
border: '2px dashed',
@@ -233,60 +310,103 @@ class CSVImportDialog extends Component {
onDrop={this.handleDrop}
onDragOver={this.handleDragOver}
onDragLeave={this.handleDragLeave}
onClick={() => this.fileInputRef.current?.click()}
onClick={onClickPick}
>
<input
type="file"
accept=".csv"
onChange={this.handleFileSelect}
ref={this.fileInputRef}
onChange={isBanking ? this.handleFileSelect : this.handleDatevFileSelect}
ref={isBanking ? this.fileInputRef : this.datevFileInputRef}
style={{ display: 'none' }}
/>
<UploadIcon sx={{ fontSize: 48, color: 'grey.400', mb: 2 }} />
{isBanking ? (
<AccountIcon sx={{ fontSize: 48, color: 'grey.400', mb: 2 }} />
) : (
<LinkIcon sx={{ fontSize: 48, color: 'grey.400', mb: 2 }} />
)}
<Typography variant="h6" gutterBottom>
CSV-Datei hier ablegen oder klicken zum Auswählen
{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>
{file && (
{currentFile && (
<Box sx={{ mb: 2 }}>
<Typography variant="subtitle2" gutterBottom>
Ausgewählte Datei:
</Typography>
<Chip label={file.name} color="primary" />
<Chip label={currentFile.name} color="primary" />
</Box>
)}
{headers && (
{currentHeaders && (
<Box sx={{ mb: 2 }}>
<Typography variant="subtitle2" gutterBottom>
Erkannte Spalten ({headers.length}):
Erkannte Spalten ({currentHeaders.length}):
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{headers.slice(0, 10).map((header, index) => (
{currentHeaders.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" />
{currentHeaders.length > 10 && (
<Chip label={`+${currentHeaders.length - 10} weitere`} size="small" />
)}
</Box>
</Box>
)}
{csvData && (
{currentData && (
<Box sx={{ mb: 2 }}>
<Typography variant="subtitle2" gutterBottom>
Gefundene Transaktionen: {csvData.length}
{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() {
const {
activeTab,
importing,
imported,
importResult,
error,
csvData,
datevCsvData,
} = this.state;
const isBanking = activeTab === IMPORT_TYPES.BANKING;
const hasData = isBanking ? csvData : datevCsvData;
return (
<Paper sx={{ p: 3 }}>
<Typography variant="h5" gutterBottom>
CSV Import
</Typography>
<Tabs
value={activeTab}
onChange={this.handleTabChange}
variant="fullWidth"
sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}
>
<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>
<Box>
{!imported ? (
<>
{this.renderUploadPanel({ isBanking })}
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
@@ -298,13 +418,12 @@ class CSVImportDialog extends Component {
<Box sx={{ mb: 2 }}>
<LinearProgress />
<Typography variant="body2" sx={{ mt: 1, textAlign: 'center' }}>
Importiere Transaktionen...
{isBanking ? 'Importiere Transaktionen...' : 'Importiere DATEV Beleglinks...'}
</Typography>
</Box>
)}
</>
) : (
/* Import Success */
<Box sx={{ textAlign: 'center', py: 2 }}>
<SuccessIcon sx={{ fontSize: 64, color: 'success.main', mb: 2 }} />
<Typography variant="h6" gutterBottom>
@@ -314,11 +433,21 @@ class CSVImportDialog extends Component {
{importResult && (
<Box sx={{ mt: 2 }}>
<Typography variant="body1" gutterBottom>
<strong>Importiert:</strong> {importResult.imported} Transaktionen
<strong>Hinzugefügt:</strong> {importResult.imported} {isBanking ? 'Transaktionen' : 'Datevlinks'}
</Typography>
{importResult.skipped > 0 && (
<Typography variant="body1" color="info.main">
<strong>Übersprungen:</strong> {importResult.skipped} Zeilen (bereits vorhanden, unbekanntes Format, etc.)
</Typography>
)}
{importResult.errors > 0 && (
<Typography variant="body1" color="warning.main">
<strong>Fehler:</strong> {importResult.errors} Zeilen übersprungen
<strong>Fehler:</strong> {importResult.errors} Zeilen konnten nicht verarbeitet werden
</Typography>
)}
{importResult.message && (
<Typography variant="body2" color="textSecondary" sx={{ mt: 1 }}>
{importResult.message}
</Typography>
)}
<Typography variant="body2" color="textSecondary" sx={{ mt: 1 }}>
@@ -328,26 +457,31 @@ class CSVImportDialog extends Component {
)}
</Box>
)}
</DialogContent>
<DialogActions>
<Button onClick={this.handleClose} disabled={importing}>
{imported ? 'Schließen' : 'Abbrechen'}
</Button>
{!imported && csvData && (
{!imported && hasData && (
<Box sx={{ mt: 3, textAlign: 'center' }}>
<Button
onClick={this.handleImport}
variant="contained"
disabled={importing || !csvData}
size="large"
disabled={importing || !hasData}
startIcon={importing ? <CircularProgress size={16} /> : <UploadIcon />}
>
{importing ? 'Importiere...' : 'Importieren'}
</Button>
</Box>
)}
</DialogActions>
</Dialog>
{imported && (
<Box sx={{ mt: 3, textAlign: 'center' }}>
<Button onClick={this.handleClose} variant="outlined" size="large">
Neuer Import
</Button>
</Box>
)}
</Box>
</Paper>
);
}
}
export default CSVImportDialog;
export default CSVImportPanel;

View File

@@ -15,7 +15,7 @@ import {
import KreditorTable from './admin/KreditorTable';
import KontoTable from './admin/KontoTable';
import BUTable from './admin/BUTable';
import CSVImportDialog from './CSVImportDialog';
import CSVImportPanel from './CSVImportDialog';
class TableManagement extends Component {
constructor(props) {
@@ -90,9 +90,7 @@ class TableManagement extends Component {
<Typography variant="body2" color="text.secondary" paragraph>
Hier können Sie CSV-Dateien von Ihrer Bank importieren. Die Daten werden in die Datenbank gespeichert und können dann Banking-Konten zugeordnet werden.
</Typography>
<CSVImportDialog
open={true}
onClose={() => {}} // Always open in this tab
<CSVImportPanel
user={user}
/>
</Box>

View File

@@ -29,6 +29,7 @@ import {
import { AgGridReact } from 'ag-grid-react';
import KreditorSelector from '../KreditorSelector';
import BankingKreditorSelector from '../BankingKreditorSelector';
import AccountingItemsManager from '../AccountingItemsManager';
const DocumentRenderer = (params) => {
// Check for pdfs and links regardless of transaction source
@@ -466,9 +467,17 @@ const DocumentRenderer = (params) => {
)}
{tabValue === 1 && (
<Box sx={{ p: 2, height: 500 }}>
{lineItems.length > 0 ? (
<div style={{ height: '100%', width: '100%' }}>
<Box sx={{ p: 2 }}>
{/* Accounting Items Manager */}
<AccountingItemsManager transaction={params.data} />
{/* Document Line Items (if any) */}
{lineItems.length > 0 && (
<Box sx={{ mt: 3 }}>
<Typography variant="h6" gutterBottom>
Erkannte Positionen aus Dokumenten
</Typography>
<div style={{ height: '300px', width: '100%' }}>
<AgGridReact
columnDefs={columnDefs}
rowData={lineItems}
@@ -479,17 +488,6 @@ const DocumentRenderer = (params) => {
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>

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

View File

@@ -370,4 +370,203 @@ router.get('/csv-import-batches', authenticateToken, async (req, res) => {
}
});
// Import DATEV Beleglinks to database
router.post('/import-datev-beleglinks', authenticateToken, async (req, res) => {
try {
const { executeQuery } = require('../../config/database');
const { beleglinks, filename, batchId, headers } = req.body;
if (!beleglinks || !Array.isArray(beleglinks)) {
return res.status(400).json({ error: 'Beleglinks array is required' });
}
// Expected DATEV CSV headers from the example
const expectedHeaders = [
'Belegart', 'Geschäftspartner-Name', 'Geschäftspartner-Konto', 'Rechnungsbetrag', 'WKZ',
'Rechnungs-Nr.', 'Interne Re.-Nr.', 'Rechnungsdatum', 'BU', 'Konto', 'Konto-Bezeichnung',
'Ware/Leistung', 'Zahlungszuordnung', 'Kontoumsatzzuordnung', 'Gebucht', 'Festgeschrieben',
'Kopie', 'Eingangsdatum', 'Bezahlt', 'BezahltAm', 'Geschäftspartner-Ort', 'Skonto-Betrag 1',
'Fällig mit Skonto 1', 'Skonto 1 in %', 'Skonto-Betrag 2', 'Fällig mit Skonto 2',
'Skonto 2 in %', 'Fällig ohne Skonto', 'Steuer in %', 'USt-IdNr.', 'Kunden-Nr.',
'KOST 1', 'KOST 2', 'KOST-Menge', 'Kurs', 'Nachricht', 'Freier Text', 'IBAN', 'BIC',
'Bankkonto-Nr.', 'BLZ', 'Notiz', 'Land', 'Personalnummer', 'Nachname', 'Vorname',
'Belegkategorie', 'Bezeichnung', 'Abrechnungsmonat', 'Gültig bis', 'Prüfungsrelevant',
'Ablageort', 'Belegtyp', 'Herkunft', 'Leistungsdatum', 'Buchungstext', 'Beleg-ID',
'Zahlungsbedingung', 'Geheftet', 'Gegenkonto', 'keine Überweisung/Lastschrift erstellen',
'Aufgeteilt', 'Bereitgestellt', 'Freigegeben', 'FreigegebenAm', 'Erweiterte Belegdaten fehlen',
'Periode fehlt', 'Rechnungsdaten beim Import fehlen'
];
if (beleglinks.length === 0) {
return res.status(400).json({ error: 'No beleglink data found' });
}
const importBatchId = batchId || 'datev_import_' + Date.now();
let successCount = 0;
let errorCount = 0;
let updateCount = 0;
let insertCount = 0;
let skippedCount = 0;
const errors = [];
for (let i = 0; i < beleglinks.length; i++) {
const beleglink = beleglinks[i];
try {
// Skip empty rows or rows without Beleg-ID
const belegId = beleglink['Beleg-ID'];
if (!belegId || belegId.trim() === '') {
console.log(`Skipping row ${i + 1}: No Beleg-ID found`);
skippedCount++;
continue;
}
const validationErrors = [];
// Parse amount if available
let numericAmount = null;
if (beleglink['Rechnungsbetrag']) {
const amountStr = beleglink['Rechnungsbetrag'].toString().replace(/[^\d,.-]/g, '');
const normalizedAmount = amountStr.replace(',', '.');
numericAmount = parseFloat(normalizedAmount) || null;
}
// Parse date if available
let parsedDate = null;
if (beleglink['Rechnungsdatum']) {
const dateStr = beleglink['Rechnungsdatum'].trim();
const dateParts = dateStr.split(/[.\/\-]/);
if (dateParts.length === 3) {
const day = parseInt(dateParts[0], 10);
const month = parseInt(dateParts[1], 10) - 1;
let year = parseInt(dateParts[2], 10);
if (year < 100) {
year += (year < 50) ? 2000 : 1900;
}
parsedDate = new Date(year, month, day);
if (isNaN(parsedDate.getTime())) {
parsedDate = null;
}
}
}
// First, check if a record with this datevlink already exists
const checkExistingDatevLink = `
SELECT kUmsatzBeleg FROM eazybusiness.dbo.tUmsatzBeleg WHERE datevlink = @datevlink
`;
const existingDatevLink = await executeQuery(checkExistingDatevLink, { datevlink: belegId });
if (existingDatevLink.recordset.length > 0) {
// Record with this datevlink already exists - skip
console.log(`Datevlink already exists, skipping: ${belegId}`);
skippedCount++;
continue;
}
// Extract key from filename in 'Herkunft' column
// Examples: "Rechnung146.pdf" -> key 146 for tRechnung
// "UmsatzBeleg192.pdf" -> key 192 for tUmsatzBeleg
const herkunft = beleglink['Herkunft'];
if (!herkunft || herkunft.trim() === '') {
console.log(`Skipping row ${i + 1}: No filename in Herkunft column`);
skippedCount++;
continue;
}
// Extract the key from filename patterns
let matchFound = false;
// Pattern: UmsatzBeleg{key}.pdf -> match with tUmsatzBeleg.kUmsatzBeleg
const umsatzBelegMatch = herkunft.match(/UmsatzBeleg(\d+)\.pdf/i);
if (umsatzBelegMatch) {
const kUmsatzBeleg = parseInt(umsatzBelegMatch[1], 10);
const updateQuery = `
UPDATE eazybusiness.dbo.tUmsatzBeleg
SET datevlink = @datevlink
WHERE kUmsatzBeleg = @kUmsatzBeleg AND (datevlink IS NULL OR datevlink = '')
`;
const updateResult = await executeQuery(updateQuery, {
datevlink: belegId,
kUmsatzBeleg: kUmsatzBeleg
});
if (updateResult.rowsAffected && updateResult.rowsAffected[0] > 0) {
updateCount++;
console.log(`Added datevlink ${belegId} to tUmsatzBeleg.kUmsatzBeleg: ${kUmsatzBeleg}`);
matchFound = true;
} else {
console.log(`Skipping row ${i + 1}: UmsatzBeleg ${kUmsatzBeleg} nicht gefunden oder datevlink bereits gesetzt`);
skippedCount++;
}
}
// Pattern: Rechnung{key}.pdf -> match with tPdfObjekt.kPdfObjekt
const rechnungMatch = herkunft.match(/Rechnung(\d+)\.pdf/i);
if (!matchFound && rechnungMatch) {
const kPdfObjekt = parseInt(rechnungMatch[1], 10);
const updateQuery = `
UPDATE eazybusiness.dbo.tPdfObjekt
SET datevlink = @datevlink
WHERE kPdfObjekt = @kPdfObjekt AND (datevlink IS NULL OR datevlink = '')
`;
const updateResult = await executeQuery(updateQuery, {
datevlink: belegId,
kPdfObjekt: kPdfObjekt
});
if (updateResult.rowsAffected && updateResult.rowsAffected[0] > 0) {
updateCount++;
console.log(`Added datevlink ${belegId} to tPdfObjekt.kPdfObjekt: ${kPdfObjekt}`);
matchFound = true;
} else {
console.log(`Skipping row ${i + 1}: PdfObjekt ${kPdfObjekt} nicht gefunden oder datevlink bereits gesetzt`);
skippedCount++;
}
}
if (!matchFound) {
console.log(`Skipping row ${i + 1}: Unbekanntes Dateiformat '${herkunft}' (erwartet: UmsatzBeleg{key}.pdf oder Rechnung{key}.pdf)`);
skippedCount++;
continue;
}
successCount++;
} catch (error) {
console.error('Error processing beleglink ' + (i + 1) + ':', error);
errors.push({
row: i + 1,
error: error.message,
beleglink: beleglink
});
errorCount++;
}
}
res.json({
success: true,
batchId: importBatchId,
imported: updateCount, // Number of datevlinks actually added/updated
processed: successCount,
updated: updateCount,
inserted: insertCount,
skipped: skippedCount, // Records skipped (existing datevlinks)
errors: errorCount, // Only actual errors, not skipped records
details: errors.length > 0 ? errors : undefined,
message: `${updateCount} datevlinks hinzugefügt, ${skippedCount} bereits vorhanden, ${errorCount} Fehler`
});
} catch (error) {
console.error('Error importing DATEV beleglinks:', error);
res.status(500).json({ error: 'Failed to import DATEV beleglinks' });
}
});
module.exports = router;

View File

@@ -6,6 +6,7 @@ const datev = require('./datev');
const pdf = require('./pdf');
const kreditors = require('./kreditors');
const bankingTransactions = require('./bankingTransactions');
const accountingItems = require('./accountingItems');
const csvImport = require('./csvImport');
const router = express.Router();
@@ -17,6 +18,7 @@ router.use(datev);
router.use(pdf);
router.use(kreditors);
router.use(bankingTransactions);
router.use(accountingItems);
router.use(csvImport);
module.exports = router;

View File

@@ -108,7 +108,41 @@ router.get('/transactions/:timeRange', authenticateToken, async (req, res) => {
const linksResult = await executeQuery(linksQuery);
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,
parsedDate: new Date(transaction.parsed_date),
hasJTL: Boolean(transaction.hasJTL),
@@ -125,14 +159,8 @@ router.get('/transactions/:timeRange', authenticateToken, async (req, res) => {
id: transaction.assigned_kreditor_id,
kreditorId: transaction.assigned_kreditor_kreditorId
} : null,
pdfs: transaction.jtl_document_data ? [{
content: transaction.jtl_document_data,
kUmsatzBeleg: transaction.kUmsatzBeleg,
datevlink: transaction.datevlink
}] : [],
links: transaction.jtlId ? linksData.filter(link =>
link.kZahlungsabgleichUmsatz === transaction.jtlId
) : []
// Remove duplicate links
links: [...new Set(transaction.links.map(l => JSON.stringify(l)))].map(l => JSON.parse(l))
}));
let filteredTransactions = [];