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

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'}