From 46c9e9b97db1577ab5aac80f9125896d0cd11c3e Mon Sep 17 00:00:00 2001 From: sebseb7 Date: Tue, 5 Aug 2025 09:25:32 +0200 Subject: [PATCH] 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. --- .kilocode/rules/mssql.md | 4 + .../src/components/AccountingItemsManager.js | 508 ++++++++++++++++ client/src/components/CSVImportDialog.js | 542 ++++++++++++------ .../cellRenderers/DocumentRenderer.js | 44 +- src/routes/data/accountingItems.js | 240 ++++++++ src/routes/data/index.js | 2 + src/routes/data/transactions.js | 46 +- 7 files changed, 1188 insertions(+), 198 deletions(-) create mode 100644 .kilocode/rules/mssql.md create mode 100644 client/src/components/AccountingItemsManager.js create mode 100644 src/routes/data/accountingItems.js 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 + + + + + {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 */} + this.setState({ showCreateDialog: false })} maxWidth="sm" fullWidth> + Neuen Buchungsposten erstellen + + + this.setState({ + newItem: { ...newItem, umsatz_brutto: e.target.value } + })} + required + fullWidth + /> + + + Soll/Haben + + + + + + Konto + + + + + + + BU (Steuercode) + + + + this.setState({ + newItem: { ...newItem, buchungstext: e.target.value } + })} + fullWidth + multiline + rows={2} + /> + + + + + + + + + {/* Create Konto Dialog */} + this.setState({ showCreateKontoDialog: false })} maxWidth="xs" fullWidth> + Neues Konto erstellen + + + this.setState({ + newKonto: { ...newKonto, konto: e.target.value } + })} + required + fullWidth + /> + this.setState({ + newKonto: { ...newKonto, name: e.target.value } + })} + required + fullWidth + /> + + + + + + + +
+ ); + } +} + +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 ( - - CSV Transaktionen Importieren - + CSV Import + + + } label="Banking Umsätze" /> + } label="DATEV Beleglinks" /> + {!imported ? ( <> - {/* File Upload Area */} - this.fileInputRef.current?.click()} - > - - - - - CSV-Datei hier ablegen oder klicken zum Auswählen - - - Unterstützte Formate: .csv (Semikolon-getrennt) - - - - {file && ( - - - Ausgewählte Datei: - - - - )} - - {headers && ( - - - Erkannte Spalten ({headers.length}): - - - {headers.slice(0, 10).map((header, index) => ( - - ))} - {headers.length > 10 && ( - - )} - - - )} - - {csvData && ( - - - Gefundene Transaktionen: {csvData.length} - - - Die Daten werden validiert und in die Datenbank importiert. - - - )} + {this.renderUploadPanel({ isBanking })} {error && ( @@ -298,13 +509,12 @@ class CSVImportDialog extends Component { - Importiere Transaktionen... + {isBanking ? 'Importiere Transaktionen...' : 'Importiere DATEV Beleglinks...'} )} ) : ( - /* Import Success */ @@ -314,7 +524,7 @@ class CSVImportDialog extends Component { {importResult && ( - Importiert: {importResult.imported} Transaktionen + Importiert: {importResult.imported} {isBanking ? 'Transaktionen' : 'Beleglinks'} {importResult.errors > 0 && ( @@ -334,11 +544,11 @@ class CSVImportDialog extends Component { - {!imported && csvData && ( + {!imported && hasData && (