- Updated error message in CSVImportPanel to include a period for better readability. - Added console logs in the CSV import API route to track the import process and precheck status. - Removed redundant validation for 'Beguenstigter/Zahlungspflichtiger' to streamline error handling during CSV import.
502 lines
14 KiB
JavaScript
502 lines
14 KiB
JavaScript
import React, { Component } from 'react';
|
|
import {
|
|
Button,
|
|
Typography,
|
|
Box,
|
|
Alert,
|
|
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';
|
|
|
|
const IMPORT_TYPES = {
|
|
BANKING: 'BANKING',
|
|
DATEV_LINKS: 'DATEV_LINKS',
|
|
};
|
|
|
|
class CSVImportPanel extends Component {
|
|
constructor(props) {
|
|
super(props);
|
|
this.state = {
|
|
// 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();
|
|
}
|
|
|
|
componentDidMount() {
|
|
// Check if we should navigate to a specific tab
|
|
if (this.props.targetTab) {
|
|
this.setState({ activeTab: this.props.targetTab });
|
|
}
|
|
}
|
|
|
|
componentDidUpdate(prevProps) {
|
|
// Handle targetTab changes
|
|
if (this.props.targetTab !== prevProps.targetTab && this.props.targetTab) {
|
|
this.setState({ activeTab: this.props.targetTab });
|
|
}
|
|
}
|
|
|
|
// 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, 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 {
|
|
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;
|
|
}
|
|
|
|
this.setState({ importing: true, error: null });
|
|
|
|
try {
|
|
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}`,
|
|
};
|
|
} 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,
|
|
});
|
|
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: errorText,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error('Import error:', error);
|
|
this.setState({
|
|
importing: false,
|
|
error: 'Netzwerkfehler beim Import',
|
|
});
|
|
}
|
|
};
|
|
|
|
handleClose = () => {
|
|
this.setState({
|
|
// 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,
|
|
});
|
|
};
|
|
|
|
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>
|
|
|
|
{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 {
|
|
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 }}>
|
|
{error}
|
|
</Alert>
|
|
)}
|
|
|
|
{importing && (
|
|
<Box sx={{ mb: 2 }}>
|
|
<LinearProgress />
|
|
<Typography variant="body2" sx={{ mt: 1, textAlign: 'center' }}>
|
|
{isBanking ? 'Importiere Transaktionen...' : 'Importiere DATEV Beleglinks...'}
|
|
</Typography>
|
|
</Box>
|
|
)}
|
|
</>
|
|
) : (
|
|
<Box sx={{ textAlign: 'center', py: 2 }}>
|
|
<SuccessIcon sx={{ fontSize: 64, color: 'success.main', mb: 2 }} />
|
|
<Typography variant="h6" gutterBottom>
|
|
Import erfolgreich abgeschlossen!
|
|
</Typography>
|
|
|
|
{importResult && (
|
|
<Box sx={{ mt: 2 }}>
|
|
<Typography variant="body1" gutterBottom>
|
|
<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 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 }}>
|
|
Batch-ID: {importResult.batchId}
|
|
</Typography>
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
)}
|
|
{!imported && hasData && (
|
|
<Box sx={{ mt: 3, textAlign: 'center' }}>
|
|
<Button
|
|
onClick={this.handleImport}
|
|
variant="contained"
|
|
size="large"
|
|
disabled={importing || !hasData}
|
|
startIcon={importing ? <CircularProgress size={16} /> : <UploadIcon />}
|
|
>
|
|
{importing ? 'Importiere...' : 'Importieren'}
|
|
</Button>
|
|
</Box>
|
|
)}
|
|
|
|
{imported && (
|
|
<Box sx={{ mt: 3, textAlign: 'center' }}>
|
|
<Button onClick={this.handleClose} variant="outlined" size="large">
|
|
Neuer Import
|
|
</Button>
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
</Paper>
|
|
);
|
|
}
|
|
}
|
|
|
|
export default CSVImportPanel; |