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:
sebseb7
2025-08-05 09:25:32 +02:00
parent 096d4d0530
commit 46c9e9b97d
7 changed files with 1188 additions and 198 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

@@ -11,45 +11,134 @@ import {
CircularProgress,
LinearProgress,
Chip,
Tabs,
Tab,
Divider,
} 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';
const IMPORT_TYPES = {
BANKING: 'BANKING',
DATEV_LINKS: 'DATEV_LINKS',
};
class CSVImportDialog 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();
}
// 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) => {
const file = event.target.files[0];
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) => {
event.preventDefault();
this.setState({ dragOver: false });
const file = event.dataTransfer.files[0];
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 });
};
processFile = (file) => {
processFile = (file, type) => {
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;
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;
}
// 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] || '';
const { headers, dataRows } = this.parseCSV(text);
if (type === IMPORT_TYPES.BANKING) {
this.setState({
file,
csvData: dataRows,
headers,
error: null,
});
return row;
});
this.setState({
csvData: dataRows,
headers,
error: null
});
} catch (error) {
console.error('Error parsing CSV:', error);
this.setState({ error: 'Fehler beim Lesen der CSV-Datei' });
} 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;
if (!csvData || csvData.length === 0) {
const {
activeTab,
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' });
return;
}
@@ -138,52 +201,80 @@ 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) {
const result = await response.json();
this.setState({
importing: false,
imported: true,
importResult: result
this.setState({
importing: false,
imported: true,
importResult: result,
});
if (this.props.onImportSuccess) {
this.props.onImportSuccess(result);
}
} else {
const errorData = await response.json();
this.setState({
importing: false,
error: errorData.error || 'Import fehlgeschlagen'
let errorText = 'Import fehlgeschlagen';
try {
const errorData = await response.json();
errorText = errorData.error || errorText;
} catch (_) {}
this.setState({
importing: false,
error: errorText,
});
}
} catch (error) {
console.error('Import error:', error);
this.setState({
importing: false,
error: 'Netzwerkfehler beim Import'
this.setState({
importing: false,
error: 'Netzwerkfehler beim Import',
});
}
};
handleClose = () => {
this.setState({
file: null,
csvData: null,
headers: null,
// common
importing: false,
imported: false,
importResult: null,
error: null,
dragOver: false,
// banking
file: null,
csvData: null,
headers: null,
// datev
datevFile: null,
datevCsvData: null,
datevHeaders: null,
});
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() {
const { open } = this.props;
const {
file,
csvData,
headers,
importing,
imported,
importResult,
error,
dragOver
const {
activeTab,
importing,
imported,
importResult,
error,
csvData,
datevCsvData,
} = this.state;
const isBanking = activeTab === IMPORT_TYPES.BANKING;
const hasData = isBanking ? csvData : datevCsvData;
return (
<Dialog
open={open}
@@ -211,82 +482,22 @@ class CSVImportDialog extends Component {
maxWidth="md"
fullWidth
>
<DialogTitle>
CSV Transaktionen Importieren
</DialogTitle>
<DialogTitle>CSV Import</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>
{!imported ? (
<>
{/* File Upload Area */}
<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>
)}
{this.renderUploadPanel({ isBanking })}
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
@@ -298,13 +509,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,7 +524,7 @@ class CSVImportDialog extends Component {
{importResult && (
<Box sx={{ mt: 2 }}>
<Typography variant="body1" gutterBottom>
<strong>Importiert:</strong> {importResult.imported} Transaktionen
<strong>Importiert:</strong> {importResult.imported} {isBanking ? 'Transaktionen' : 'Beleglinks'}
</Typography>
{importResult.errors > 0 && (
<Typography variant="body1" color="warning.main">
@@ -334,11 +544,11 @@ class CSVImportDialog extends Component {
<Button onClick={this.handleClose} disabled={importing}>
{imported ? 'Schließen' : 'Abbrechen'}
</Button>
{!imported && csvData && (
{!imported && hasData && (
<Button
onClick={this.handleImport}
variant="contained"
disabled={importing || !csvData}
disabled={importing || !hasData}
startIcon={importing ? <CircularProgress size={16} /> : <UploadIcon />}
>
{importing ? 'Importiere...' : 'Importieren'}

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,30 +467,27 @@ const DocumentRenderer = (params) => {
)}
{tabValue === 1 && (
<Box sx={{ p: 2, height: 500 }}>
{lineItems.length > 0 ? (
<div style={{ height: '100%', width: '100%' }}>
<AgGridReact
columnDefs={columnDefs}
rowData={lineItems}
defaultColDef={defaultColDef}
suppressRowTransform={true}
rowHeight={50}
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.'
}
<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}
defaultColDef={defaultColDef}
suppressRowTransform={true}
rowHeight={50}
headerHeight={35}
domLayout="normal"
/>
</div>
</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

@@ -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 = [];