diff --git a/.kilocode/rules/mssql.md b/.kilocode/rules/mssql.md
new file mode 100644
index 0000000..b4e9803
--- /dev/null
+++ b/.kilocode/rules/mssql.md
@@ -0,0 +1,4 @@
+# mssql.md
+
+sqlcmd -C -S tcp:192.168.56.1,1497 -U app -P 'readonly' -d eazybusiness -W
+
diff --git a/client/src/components/AccountingItemsManager.js b/client/src/components/AccountingItemsManager.js
new file mode 100644
index 0000000..13285f3
--- /dev/null
+++ b/client/src/components/AccountingItemsManager.js
@@ -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 Lade Buchungsdaten...;
+ }
+
+ const transactionAmount = transaction.numericAmount || 0;
+ const currentTotal = this.calculateTotal();
+ const isBalanced = Math.abs(currentTotal - Math.abs(transactionAmount)) < 0.01;
+
+ return (
+
+
+
+ Buchungsposten
+
+ }
+ onClick={this.handleCreateItem}
+ size="small"
+ >
+ Hinzufügen
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+ Transaktionsbetrag: {Math.abs(transactionAmount).toFixed(2)} €
+
+
+ Summe Buchungsposten: {Math.abs(currentTotal).toFixed(2)} €
+
+
+
+
+
+
+
+
+ Betrag
+ S/H
+ Konto
+ BU
+ Buchungstext
+ Aktionen
+
+
+
+ {accountingItems.map((item) => (
+
+ {parseFloat(item.umsatz_brutto).toFixed(2)} €
+
+
+
+
+ {item.konto} - {item.konto_name}
+
+
+ {item.bu ? `${item.bu} - ${item.bu_name}` : '-'}
+
+ {item.buchungstext || '-'}
+
+ this.handleDeleteItem(item.id)}
+ color="error"
+ >
+
+
+
+
+ ))}
+ {accountingItems.length === 0 && (
+
+
+
+ Keine Buchungsposten vorhanden
+
+
+
+ )}
+
+
+
+
+ {/* Create Item Dialog */}
+
+
+ {/* Create Konto Dialog */}
+
+
+ );
+ }
+}
+
+export default AccountingItemsManager;
\ No newline at end of file
diff --git a/client/src/components/CSVImportDialog.js b/client/src/components/CSVImportDialog.js
index bb7be05..177bffd 100644
--- a/client/src/components/CSVImportDialog.js
+++ b/client/src/components/CSVImportDialog.js
@@ -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 (
+ <>
+
+
+
+ {isBanking ? (
+
+ ) : (
+
+ )}
+
+
+ {isBanking ? 'Bankkontoumsätze CSV hier ablegen oder klicken zum Auswählen' : 'DATEV Beleglinks CSV hier ablegen oder klicken zum Auswählen'}
+
+
+ Unterstützte Formate: .csv (Semikolon-getrennt)
+
+
+
+ {!isBanking && (
+
+
+
+
+ Hinweise zum DATEV Beleglink-Upload
+
+
+ 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.
+
+
+ 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.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ DATEV Beleglinks Beispiel – Platzhalter Illustration
+
+
+
+
+ Beispiel-Screenshot (SVG Platzhalter mit Shapes). Ersetzen Sie dieses Bild später durch die endgültige Anleitungsgrafik.
+
+
+ )}
+
+ {currentFile && (
+
+
+ Ausgewählte Datei:
+
+
+
+ )}
+
+ {currentHeaders && (
+
+
+ Erkannte Spalten ({currentHeaders.length}):
+
+
+ {currentHeaders.slice(0, 10).map((header, index) => (
+
+ ))}
+ {currentHeaders.length > 10 && (
+
+ )}
+
+
+ )}
+
+ {currentData && (
+
+
+ {isBanking ? 'Gefundene Transaktionen' : 'Gefundene Beleglinks'}: {currentData.length}
+
+
+ Die Daten werden validiert und in die Datenbank importiert.
+
+
+ )}
+ >
+ );
+ };
+
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 (