feat(buchhaltung): replace transaction dialog with dedicated form page, enforce full field validation before booking
This commit is contained in:
@@ -45,6 +45,7 @@ import Buchhaltung from './pages/Buchhaltung';
|
|||||||
import BuchhaltungKontoDetail from './pages/BuchhaltungKontoDetail';
|
import BuchhaltungKontoDetail from './pages/BuchhaltungKontoDetail';
|
||||||
import BuchhaltungKontoManage from './pages/BuchhaltungKontoManage';
|
import BuchhaltungKontoManage from './pages/BuchhaltungKontoManage';
|
||||||
import BuchhaltungBankkontoDetail from './pages/BuchhaltungBankkontoDetail';
|
import BuchhaltungBankkontoDetail from './pages/BuchhaltungBankkontoDetail';
|
||||||
|
import BuchhaltungTransaktionForm from './pages/BuchhaltungTransaktionForm';
|
||||||
import Haushaltsplan from './pages/Haushaltsplan';
|
import Haushaltsplan from './pages/Haushaltsplan';
|
||||||
import HaushaltsplanDetail from './pages/HaushaltsplanDetail';
|
import HaushaltsplanDetail from './pages/HaushaltsplanDetail';
|
||||||
import FahrzeugEinstellungen from './pages/FahrzeugEinstellungen';
|
import FahrzeugEinstellungen from './pages/FahrzeugEinstellungen';
|
||||||
@@ -411,6 +412,22 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/buchhaltung/transaktionen/neu"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<BuchhaltungTransaktionForm />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/buchhaltung/transaktionen/:id/bearbeiten"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<BuchhaltungTransaktionForm />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/buchhaltung/konto/:id/verwalten"
|
path="/buchhaltung/konto/:id/verwalten"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -55,7 +55,6 @@ import {
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { buchhaltungApi } from '../services/buchhaltung';
|
import { buchhaltungApi } from '../services/buchhaltung';
|
||||||
import { bestellungApi } from '../services/bestellung';
|
|
||||||
import { configApi, type PdfSettings } from '../services/config';
|
import { configApi, type PdfSettings } from '../services/config';
|
||||||
import { addPdfHeader, addPdfFooter } from '../utils/pdfExport';
|
import { addPdfHeader, addPdfFooter } from '../utils/pdfExport';
|
||||||
import ChatAwareFab from '../components/shared/ChatAwareFab';
|
import ChatAwareFab from '../components/shared/ChatAwareFab';
|
||||||
@@ -68,7 +67,7 @@ import type {
|
|||||||
KontoTreeNode,
|
KontoTreeNode,
|
||||||
KontoTyp,
|
KontoTyp,
|
||||||
Kategorie,
|
Kategorie,
|
||||||
Transaktion, TransaktionFormData, TransaktionFilters,
|
Transaktion, TransaktionFilters,
|
||||||
TransaktionTyp,
|
TransaktionTyp,
|
||||||
|
|
||||||
AusgabenTyp,
|
AusgabenTyp,
|
||||||
@@ -495,147 +494,6 @@ function KontoDialog({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TransaktionDialog({
|
|
||||||
open,
|
|
||||||
onClose,
|
|
||||||
haushaltsjahre,
|
|
||||||
selectedJahrId,
|
|
||||||
onSave,
|
|
||||||
existing,
|
|
||||||
}: {
|
|
||||||
open: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
haushaltsjahre: Haushaltsjahr[];
|
|
||||||
selectedJahrId: number | null;
|
|
||||||
onSave: (data: TransaktionFormData) => void;
|
|
||||||
existing?: Transaktion;
|
|
||||||
}) {
|
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
|
||||||
const [form, setForm] = useState<TransaktionFormData>({
|
|
||||||
haushaltsjahr_id: selectedJahrId || 0,
|
|
||||||
typ: 'ausgabe',
|
|
||||||
betrag: 0,
|
|
||||||
datum: today,
|
|
||||||
konto_id: null,
|
|
||||||
bankkonto_id: null,
|
|
||||||
beschreibung: '',
|
|
||||||
empfaenger_auftraggeber: '',
|
|
||||||
verwendungszweck: '',
|
|
||||||
beleg_nr: '',
|
|
||||||
bestellung_id: null,
|
|
||||||
ausgaben_typ: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: konten = [] } = useQuery({
|
|
||||||
queryKey: ['buchhaltung-konten', form.haushaltsjahr_id],
|
|
||||||
queryFn: () => buchhaltungApi.getKonten(form.haushaltsjahr_id),
|
|
||||||
enabled: form.haushaltsjahr_id > 0,
|
|
||||||
});
|
|
||||||
const { data: bankkonten = [] } = useQuery({ queryKey: ['bankkonten'], queryFn: buchhaltungApi.getBankkonten });
|
|
||||||
const { data: bestellungen = [] } = useQuery({
|
|
||||||
queryKey: ['bestellungen-all'],
|
|
||||||
queryFn: () => bestellungApi.getOrders(),
|
|
||||||
staleTime: 5 * 60 * 1000,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
if (existing) {
|
|
||||||
setForm({
|
|
||||||
haushaltsjahr_id: existing.haushaltsjahr_id,
|
|
||||||
typ: existing.typ as 'einnahme' | 'ausgabe',
|
|
||||||
betrag: Number(existing.betrag),
|
|
||||||
datum: existing.datum.slice(0, 10),
|
|
||||||
konto_id: existing.konto_id,
|
|
||||||
bankkonto_id: existing.bankkonto_id,
|
|
||||||
beschreibung: existing.beschreibung || '',
|
|
||||||
empfaenger_auftraggeber: existing.empfaenger_auftraggeber || '',
|
|
||||||
verwendungszweck: existing.verwendungszweck || '',
|
|
||||||
beleg_nr: existing.beleg_nr || '',
|
|
||||||
bestellung_id: existing.bestellung_id,
|
|
||||||
ausgaben_typ: existing.ausgaben_typ,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setForm(f => ({ ...f, haushaltsjahr_id: selectedJahrId || 0, datum: today }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
|
||||||
<DialogTitle>{existing ? 'Transaktion bearbeiten' : 'Neue Transaktion'}</DialogTitle>
|
|
||||||
<DialogContent>
|
|
||||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
|
||||||
<FormControl fullWidth required>
|
|
||||||
<InputLabel>Haushaltsjahr</InputLabel>
|
|
||||||
<Select value={form.haushaltsjahr_id || ''} label="Haushaltsjahr" onChange={e => setForm(f => ({ ...f, haushaltsjahr_id: Number(e.target.value), konto_id: null }))}>
|
|
||||||
{haushaltsjahre.map(hj => <MenuItem key={hj.id} value={hj.id}>{hj.bezeichnung}</MenuItem>)}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
<FormControl fullWidth required>
|
|
||||||
<InputLabel>Typ</InputLabel>
|
|
||||||
<Select value={form.typ} label="Typ" onChange={e => setForm(f => ({ ...f, typ: e.target.value as 'einnahme' | 'ausgabe' }))}>
|
|
||||||
<MenuItem value="ausgabe">Ausgabe</MenuItem>
|
|
||||||
<MenuItem value="einnahme">Einnahme</MenuItem>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
{form.typ === 'ausgabe' && (() => {
|
|
||||||
const selectedKonto = konten.find(k => k.id === form.konto_id);
|
|
||||||
const isEinfach = selectedKonto && (selectedKonto.budget_typ || 'detailliert') === 'einfach';
|
|
||||||
return !isEinfach ? (
|
|
||||||
<FormControl fullWidth>
|
|
||||||
<InputLabel>Ausgaben-Typ</InputLabel>
|
|
||||||
<Select
|
|
||||||
value={form.ausgaben_typ || ''}
|
|
||||||
onChange={e => setForm(f => ({ ...f, ausgaben_typ: (e.target.value as AusgabenTyp) || null }))}
|
|
||||||
label="Ausgaben-Typ"
|
|
||||||
>
|
|
||||||
<MenuItem value=""><em>Kein Typ</em></MenuItem>
|
|
||||||
<MenuItem value="gwg">GWG</MenuItem>
|
|
||||||
<MenuItem value="anlagen">Anlagen</MenuItem>
|
|
||||||
<MenuItem value="instandhaltung">Instandhaltung</MenuItem>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
) : null;
|
|
||||||
})()}
|
|
||||||
<TextField label="Betrag (€)" type="number" value={form.betrag} onChange={e => setForm(f => ({ ...f, betrag: parseFloat(e.target.value) || 0 }))} inputProps={{ step: '0.01', min: '0' }} required />
|
|
||||||
<TextField label="Datum" type="date" value={form.datum} onChange={e => setForm(f => ({ ...f, datum: e.target.value }))} InputLabelProps={{ shrink: true }} required />
|
|
||||||
<FormControl fullWidth>
|
|
||||||
<InputLabel>Konto</InputLabel>
|
|
||||||
<Select value={form.konto_id ?? ''} label="Konto" onChange={e => setForm(f => ({ ...f, konto_id: e.target.value ? Number(e.target.value) : null }))}>
|
|
||||||
<MenuItem value=""><em>Kein Konto</em></MenuItem>
|
|
||||||
{konten.map(k => <MenuItem key={k.id} value={k.id}>{k.kontonummer} – {k.bezeichnung}</MenuItem>)}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
<FormControl fullWidth>
|
|
||||||
<InputLabel>Bankkonto</InputLabel>
|
|
||||||
<Select value={form.bankkonto_id ?? ''} label="Bankkonto" onChange={e => setForm(f => ({ ...f, bankkonto_id: e.target.value ? Number(e.target.value) : null }))}>
|
|
||||||
<MenuItem value=""><em>Kein Bankkonto</em></MenuItem>
|
|
||||||
{bankkonten.map(bk => <MenuItem key={bk.id} value={bk.id}>{bk.bezeichnung}{bk.iban ? ` (${bk.iban})` : ''}</MenuItem>)}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
<TextField label="Beschreibung" value={form.beschreibung} onChange={e => setForm(f => ({ ...f, beschreibung: e.target.value }))} />
|
|
||||||
<TextField label="Empfänger/Auftraggeber" value={form.empfaenger_auftraggeber} onChange={e => setForm(f => ({ ...f, empfaenger_auftraggeber: e.target.value }))} />
|
|
||||||
<TextField label="Verwendungszweck" value={form.verwendungszweck} onChange={e => setForm(f => ({ ...f, verwendungszweck: e.target.value }))} />
|
|
||||||
<TextField label="Belegnummer" value={form.beleg_nr} onChange={e => setForm(f => ({ ...f, beleg_nr: e.target.value }))} />
|
|
||||||
<FormControl fullWidth>
|
|
||||||
<InputLabel>Bestellung verknüpfen</InputLabel>
|
|
||||||
<Select value={form.bestellung_id ?? ''} label="Bestellung verknüpfen" onChange={e => setForm(f => ({ ...f, bestellung_id: e.target.value ? Number(e.target.value) : null }))}>
|
|
||||||
<MenuItem value=""><em>Keine Bestellung</em></MenuItem>
|
|
||||||
{bestellungen.map(b => <MenuItem key={b.id} value={b.id}>{b.laufende_nummer ? `#${b.laufende_nummer} – ` : ''}{b.bezeichnung}</MenuItem>)}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
</Stack>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button onClick={onClose}>Abbrechen</Button>
|
|
||||||
<Button variant="contained" onClick={() => onSave(form)} disabled={!form.haushaltsjahr_id || !form.betrag || !form.datum}>{existing ? 'Speichern' : 'Erstellen'}</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Tree helpers ─────────────────────────────────────────────────────────────
|
// ─── Tree helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function buildTree(flat: KontoTreeNode[]): KontoTreeNode[] {
|
function buildTree(flat: KontoTreeNode[]): KontoTreeNode[] {
|
||||||
@@ -1136,11 +994,10 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
|||||||
onJahrChange: (id: number) => void;
|
onJahrChange: (id: number) => void;
|
||||||
}) {
|
}) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
const navigate = useNavigate();
|
||||||
const { showSuccess, showError } = useNotification();
|
const { showSuccess, showError } = useNotification();
|
||||||
const { hasPermission } = usePermissionContext();
|
const { hasPermission } = usePermissionContext();
|
||||||
const [filters, setFilters] = useState<TransaktionFilters>({ haushaltsjahr_id: selectedJahrId || undefined });
|
const [filters, setFilters] = useState<TransaktionFilters>({ haushaltsjahr_id: selectedJahrId || undefined });
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
|
||||||
const [editingTx, setEditingTx] = useState<Transaktion | null>(null);
|
|
||||||
const [filterAusgabenTyp, setFilterAusgabenTyp] = useState('');
|
const [filterAusgabenTyp, setFilterAusgabenTyp] = useState('');
|
||||||
const [txSubTab, setTxSubTab] = useState(0);
|
const [txSubTab, setTxSubTab] = useState(0);
|
||||||
|
|
||||||
@@ -1166,18 +1023,6 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
|||||||
queryFn: () => buchhaltungApi.getTransaktionen(filters),
|
queryFn: () => buchhaltungApi.getTransaktionen(filters),
|
||||||
});
|
});
|
||||||
|
|
||||||
const createMut = useMutation({
|
|
||||||
mutationFn: buchhaltungApi.createTransaktion,
|
|
||||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['buchhaltung-transaktionen'] }); setCreateOpen(false); showSuccess('Transaktion erstellt'); },
|
|
||||||
onError: () => showError('Transaktion konnte nicht erstellt werden'),
|
|
||||||
});
|
|
||||||
|
|
||||||
const updateMut = useMutation({
|
|
||||||
mutationFn: ({ id, data }: { id: number; data: Partial<TransaktionFormData> }) => buchhaltungApi.updateTransaktion(id, data),
|
|
||||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['buchhaltung-transaktionen'] }); setEditingTx(null); showSuccess('Transaktion aktualisiert'); },
|
|
||||||
onError: () => showError('Aktualisierung fehlgeschlagen'),
|
|
||||||
});
|
|
||||||
|
|
||||||
const buchenMut = useMutation({
|
const buchenMut = useMutation({
|
||||||
mutationFn: (id: number) => buchhaltungApi.buchenTransaktion(id),
|
mutationFn: (id: number) => buchhaltungApi.buchenTransaktion(id),
|
||||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['buchhaltung-transaktionen'] }); qc.invalidateQueries({ queryKey: ['buchhaltung-stats'] }); showSuccess('Transaktion gebucht'); },
|
onSuccess: () => { qc.invalidateQueries({ queryKey: ['buchhaltung-transaktionen'] }); qc.invalidateQueries({ queryKey: ['buchhaltung-stats'] }); showSuccess('Transaktion gebucht'); },
|
||||||
@@ -1397,15 +1242,28 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Stack direction="row" spacing={0.5} flexWrap="wrap">
|
<Stack direction="row" spacing={0.5} flexWrap="wrap">
|
||||||
{t.status === 'entwurf' && hasPermission('buchhaltung:edit') && (
|
{t.status === 'entwurf' && hasPermission('buchhaltung:edit') && (() => {
|
||||||
<Tooltip title={kontenFlat.length === 0 ? 'Keine Konten konfiguriert' : bankkonten.length === 0 ? 'Keine Bankkonten konfiguriert' : !t.konto_id ? 'Kein Konto ausgewählt' : ''}>
|
const buchenDisabled =
|
||||||
|
kontenFlat.length === 0 || bankkonten.length === 0 ||
|
||||||
|
!t.konto_id || !t.bankkonto_id || !Number(t.betrag) || !t.datum || !t.beschreibung;
|
||||||
|
const buchenTooltip =
|
||||||
|
kontenFlat.length === 0 ? 'Keine Konten konfiguriert' :
|
||||||
|
bankkonten.length === 0 ? 'Keine Bankkonten konfiguriert' :
|
||||||
|
!t.konto_id ? 'Kein Konto ausgewählt' :
|
||||||
|
!t.bankkonto_id ? 'Kein Bankkonto ausgewählt' :
|
||||||
|
!Number(t.betrag) ? 'Kein Betrag angegeben' :
|
||||||
|
!t.datum ? 'Kein Datum angegeben' :
|
||||||
|
!t.beschreibung ? 'Keine Beschreibung angegeben' : '';
|
||||||
|
return (
|
||||||
|
<Tooltip title={buchenTooltip}>
|
||||||
<span>
|
<span>
|
||||||
<Button size="small" variant="outlined" startIcon={<BookmarkAdd fontSize="small" />} disabled={kontenFlat.length === 0 || bankkonten.length === 0 || !t.konto_id} onClick={() => buchenMut.mutate(t.id)}>
|
<Button size="small" variant="outlined" startIcon={<BookmarkAdd fontSize="small" />} disabled={buchenDisabled} onClick={() => buchenMut.mutate(t.id)}>
|
||||||
Buchen
|
Buchen
|
||||||
</Button>
|
</Button>
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
);
|
||||||
|
})()}
|
||||||
{t.status === 'gebucht' && hasPermission('buchhaltung:edit') && (
|
{t.status === 'gebucht' && hasPermission('buchhaltung:edit') && (
|
||||||
<Button size="small" variant="outlined" color="warning" startIcon={<Cancel fontSize="small" />} onClick={() => stornoMut.mutate(t.id)}>
|
<Button size="small" variant="outlined" color="warning" startIcon={<Cancel fontSize="small" />} onClick={() => stornoMut.mutate(t.id)}>
|
||||||
Stornieren
|
Stornieren
|
||||||
@@ -1413,7 +1271,7 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
|||||||
)}
|
)}
|
||||||
{t.status === 'entwurf' && hasPermission('buchhaltung:edit') && (
|
{t.status === 'entwurf' && hasPermission('buchhaltung:edit') && (
|
||||||
<Tooltip title="Bearbeiten">
|
<Tooltip title="Bearbeiten">
|
||||||
<IconButton size="small" onClick={() => setEditingTx(t)}>
|
<IconButton size="small" onClick={() => navigate(`/buchhaltung/transaktionen/${t.id}/bearbeiten`)}>
|
||||||
<Edit fontSize="small" />
|
<Edit fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -1435,28 +1293,11 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{hasPermission('buchhaltung:create') && (
|
{hasPermission('buchhaltung:create') && (
|
||||||
<ChatAwareFab color="primary" onClick={() => setCreateOpen(true)}>
|
<ChatAwareFab color="primary" onClick={() => navigate(`/buchhaltung/transaktionen/neu${filters.haushaltsjahr_id ? `?jahr=${filters.haushaltsjahr_id}` : ''}`)}>
|
||||||
<AddIcon />
|
<AddIcon />
|
||||||
</ChatAwareFab>
|
</ChatAwareFab>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<TransaktionDialog
|
|
||||||
open={createOpen}
|
|
||||||
onClose={() => setCreateOpen(false)}
|
|
||||||
haushaltsjahre={haushaltsjahre}
|
|
||||||
selectedJahrId={selectedJahrId}
|
|
||||||
onSave={data => createMut.mutate(data)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TransaktionDialog
|
|
||||||
open={!!editingTx}
|
|
||||||
onClose={() => setEditingTx(null)}
|
|
||||||
haushaltsjahre={haushaltsjahre}
|
|
||||||
selectedJahrId={selectedJahrId}
|
|
||||||
existing={editingTx ?? undefined}
|
|
||||||
onSave={data => updateMut.mutate({ id: editingTx!.id, data })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ErstattungDialog
|
<ErstattungDialog
|
||||||
open={erstattungOpen}
|
open={erstattungOpen}
|
||||||
onClose={() => setErstattungOpen(false)}
|
onClose={() => setErstattungOpen(false)}
|
||||||
|
|||||||
366
frontend/src/pages/BuchhaltungTransaktionForm.tsx
Normal file
366
frontend/src/pages/BuchhaltungTransaktionForm.tsx
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
Alert, Box, Button, Card, CardContent, CardHeader, Divider,
|
||||||
|
FormControl, Grid, InputLabel, MenuItem, Select, Skeleton,
|
||||||
|
Stack, TextField, Typography,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { ArrowBack, Save } from '@mui/icons-material';
|
||||||
|
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||||
|
import { buchhaltungApi } from '../services/buchhaltung';
|
||||||
|
import { bestellungApi } from '../services/bestellung';
|
||||||
|
import { useNotification } from '../contexts/NotificationContext';
|
||||||
|
import type { AusgabenTyp, TransaktionFormData } from '../types/buchhaltung.types';
|
||||||
|
|
||||||
|
export default function BuchhaltungTransaktionForm() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { showSuccess, showError } = useNotification();
|
||||||
|
const isEdit = !!id;
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
const [form, setForm] = useState<TransaktionFormData>({
|
||||||
|
haushaltsjahr_id: 0,
|
||||||
|
typ: 'ausgabe',
|
||||||
|
betrag: 0,
|
||||||
|
datum: today,
|
||||||
|
konto_id: null,
|
||||||
|
bankkonto_id: null,
|
||||||
|
beschreibung: '',
|
||||||
|
empfaenger_auftraggeber: '',
|
||||||
|
verwendungszweck: '',
|
||||||
|
beleg_nr: '',
|
||||||
|
bestellung_id: null,
|
||||||
|
ausgaben_typ: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: haushaltsjahre = [], isLoading: loadingJahre } = useQuery({
|
||||||
|
queryKey: ['haushaltsjahre'],
|
||||||
|
queryFn: buchhaltungApi.getHaushaltsjahre,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: existing, isLoading: loadingExisting } = useQuery({
|
||||||
|
queryKey: ['transaktion', Number(id)],
|
||||||
|
queryFn: () => buchhaltungApi.getTransaktion(Number(id)),
|
||||||
|
enabled: isEdit,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: konten = [] } = useQuery({
|
||||||
|
queryKey: ['buchhaltung-konten', form.haushaltsjahr_id],
|
||||||
|
queryFn: () => buchhaltungApi.getKonten(form.haushaltsjahr_id),
|
||||||
|
enabled: form.haushaltsjahr_id > 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: bankkonten = [] } = useQuery({
|
||||||
|
queryKey: ['bankkonten'],
|
||||||
|
queryFn: buchhaltungApi.getBankkonten,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: bestellungen = [] } = useQuery({
|
||||||
|
queryKey: ['bestellungen-all'],
|
||||||
|
queryFn: () => bestellungApi.getOrders(),
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Default to active fiscal year on create
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isEdit && haushaltsjahre.length > 0 && form.haushaltsjahr_id === 0) {
|
||||||
|
const jahrParam = searchParams.get('jahr');
|
||||||
|
const openYear = haushaltsjahre.find(hj => !hj.abgeschlossen) || haushaltsjahre[0];
|
||||||
|
setForm(f => ({ ...f, haushaltsjahr_id: jahrParam ? Number(jahrParam) : openYear.id }));
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [haushaltsjahre]);
|
||||||
|
|
||||||
|
// Pre-fill from existing transaction when editing
|
||||||
|
useEffect(() => {
|
||||||
|
if (existing) {
|
||||||
|
setForm({
|
||||||
|
haushaltsjahr_id: existing.haushaltsjahr_id,
|
||||||
|
typ: existing.typ as 'einnahme' | 'ausgabe',
|
||||||
|
betrag: Number(existing.betrag),
|
||||||
|
datum: existing.datum.slice(0, 10),
|
||||||
|
konto_id: existing.konto_id,
|
||||||
|
bankkonto_id: existing.bankkonto_id,
|
||||||
|
beschreibung: existing.beschreibung || '',
|
||||||
|
empfaenger_auftraggeber: existing.empfaenger_auftraggeber || '',
|
||||||
|
verwendungszweck: existing.verwendungszweck || '',
|
||||||
|
beleg_nr: existing.beleg_nr || '',
|
||||||
|
bestellung_id: existing.bestellung_id,
|
||||||
|
ausgaben_typ: existing.ausgaben_typ,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [existing]);
|
||||||
|
|
||||||
|
const createMut = useMutation({
|
||||||
|
mutationFn: buchhaltungApi.createTransaktion,
|
||||||
|
onSuccess: () => { showSuccess('Transaktion erstellt'); navigate('/buchhaltung'); },
|
||||||
|
onError: () => showError('Transaktion konnte nicht erstellt werden'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMut = useMutation({
|
||||||
|
mutationFn: (data: Partial<TransaktionFormData>) =>
|
||||||
|
buchhaltungApi.updateTransaktion(Number(id), data),
|
||||||
|
onSuccess: () => { showSuccess('Transaktion aktualisiert'); navigate('/buchhaltung'); },
|
||||||
|
onError: () => showError('Aktualisierung fehlgeschlagen'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedKonto = konten.find(k => k.id === form.konto_id);
|
||||||
|
const isEinfach = selectedKonto && (selectedKonto.budget_typ || 'detailliert') === 'einfach';
|
||||||
|
const isPending = createMut.isPending || updateMut.isPending;
|
||||||
|
|
||||||
|
const canSubmit =
|
||||||
|
!!form.haushaltsjahr_id &&
|
||||||
|
!!form.betrag &&
|
||||||
|
!!form.datum &&
|
||||||
|
!!form.konto_id &&
|
||||||
|
!!form.bankkonto_id &&
|
||||||
|
!!form.beschreibung;
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (isEdit) updateMut.mutate(form);
|
||||||
|
else createMut.mutate(form);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loadingJahre || (isEdit && loadingExisting)) {
|
||||||
|
return (
|
||||||
|
<DashboardLayout>
|
||||||
|
<Box sx={{ p: 3, maxWidth: 800, mx: 'auto' }}>
|
||||||
|
<Skeleton variant="rectangular" height={60} sx={{ mb: 2 }} />
|
||||||
|
<Skeleton variant="rectangular" height={200} sx={{ mb: 2 }} />
|
||||||
|
<Skeleton variant="rectangular" height={160} />
|
||||||
|
</Box>
|
||||||
|
</DashboardLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardLayout>
|
||||||
|
<Box sx={{ p: 3, maxWidth: 800, mx: 'auto' }}>
|
||||||
|
{/* Header */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||||
|
<Button startIcon={<ArrowBack />} onClick={() => navigate('/buchhaltung')}>
|
||||||
|
Zurück
|
||||||
|
</Button>
|
||||||
|
<Typography variant="h5" sx={{ ml: 1 }}>
|
||||||
|
{isEdit ? 'Transaktion bearbeiten' : 'Neue Transaktion'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Stack spacing={3}>
|
||||||
|
{/* Section: General */}
|
||||||
|
<Card variant="outlined">
|
||||||
|
<CardHeader
|
||||||
|
title="Allgemein"
|
||||||
|
titleTypographyProps={{ variant: 'subtitle1', fontWeight: 600 }}
|
||||||
|
/>
|
||||||
|
<Divider />
|
||||||
|
<CardContent>
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid item xs={12} sm={6}>
|
||||||
|
<FormControl fullWidth required>
|
||||||
|
<InputLabel>Haushaltsjahr</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={form.haushaltsjahr_id || ''}
|
||||||
|
label="Haushaltsjahr"
|
||||||
|
onChange={e => setForm(f => ({ ...f, haushaltsjahr_id: Number(e.target.value), konto_id: null }))}
|
||||||
|
>
|
||||||
|
{haushaltsjahre.map(hj => (
|
||||||
|
<MenuItem key={hj.id} value={hj.id}>{hj.bezeichnung}</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} sm={6}>
|
||||||
|
<FormControl fullWidth required>
|
||||||
|
<InputLabel>Typ</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={form.typ}
|
||||||
|
label="Typ"
|
||||||
|
onChange={e => setForm(f => ({ ...f, typ: e.target.value as 'einnahme' | 'ausgabe', ausgaben_typ: null }))}
|
||||||
|
>
|
||||||
|
<MenuItem value="ausgabe">Ausgabe</MenuItem>
|
||||||
|
<MenuItem value="einnahme">Einnahme</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} sm={6}>
|
||||||
|
<TextField
|
||||||
|
fullWidth required
|
||||||
|
label="Betrag (€)"
|
||||||
|
type="number"
|
||||||
|
value={form.betrag || ''}
|
||||||
|
onChange={e => setForm(f => ({ ...f, betrag: parseFloat(e.target.value) || 0 }))}
|
||||||
|
inputProps={{ step: '0.01', min: '0.01' }}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} sm={6}>
|
||||||
|
<TextField
|
||||||
|
fullWidth required
|
||||||
|
label="Datum"
|
||||||
|
type="date"
|
||||||
|
value={form.datum}
|
||||||
|
onChange={e => setForm(f => ({ ...f, datum: e.target.value }))}
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
{form.typ === 'ausgabe' && !isEinfach && (
|
||||||
|
<Grid item xs={12} sm={6}>
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>Ausgaben-Typ</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={form.ausgaben_typ || ''}
|
||||||
|
label="Ausgaben-Typ"
|
||||||
|
onChange={e => setForm(f => ({ ...f, ausgaben_typ: (e.target.value as AusgabenTyp) || null }))}
|
||||||
|
>
|
||||||
|
<MenuItem value=""><em>Kein Typ</em></MenuItem>
|
||||||
|
<MenuItem value="gwg">GWG</MenuItem>
|
||||||
|
<MenuItem value="anlagen">Anlagen</MenuItem>
|
||||||
|
<MenuItem value="instandhaltung">Instandhaltung</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Section: Accounts */}
|
||||||
|
<Card variant="outlined">
|
||||||
|
<CardHeader
|
||||||
|
title="Konten"
|
||||||
|
titleTypographyProps={{ variant: 'subtitle1', fontWeight: 600 }}
|
||||||
|
/>
|
||||||
|
<Divider />
|
||||||
|
<CardContent>
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid item xs={12} sm={6}>
|
||||||
|
<FormControl fullWidth required>
|
||||||
|
<InputLabel>Konto (Topf)</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={form.konto_id ?? ''}
|
||||||
|
label="Konto (Topf)"
|
||||||
|
onChange={e => setForm(f => ({ ...f, konto_id: e.target.value ? Number(e.target.value) : null, ausgaben_typ: null }))}
|
||||||
|
>
|
||||||
|
<MenuItem value=""><em>Kein Konto</em></MenuItem>
|
||||||
|
{konten.map(k => (
|
||||||
|
<MenuItem key={k.id} value={k.id}>{k.kontonummer} – {k.bezeichnung}</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} sm={6}>
|
||||||
|
<FormControl fullWidth required>
|
||||||
|
<InputLabel>Bankkonto</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={form.bankkonto_id ?? ''}
|
||||||
|
label="Bankkonto"
|
||||||
|
onChange={e => setForm(f => ({ ...f, bankkonto_id: e.target.value ? Number(e.target.value) : null }))}
|
||||||
|
>
|
||||||
|
<MenuItem value=""><em>Kein Bankkonto</em></MenuItem>
|
||||||
|
{bankkonten.map(bk => (
|
||||||
|
<MenuItem key={bk.id} value={bk.id}>{bk.bezeichnung}{bk.iban ? ` (${bk.iban})` : ''}</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Section: Details */}
|
||||||
|
<Card variant="outlined">
|
||||||
|
<CardHeader
|
||||||
|
title="Details"
|
||||||
|
titleTypographyProps={{ variant: 'subtitle1', fontWeight: 600 }}
|
||||||
|
/>
|
||||||
|
<Divider />
|
||||||
|
<CardContent>
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid item xs={12} sm={6}>
|
||||||
|
<TextField
|
||||||
|
fullWidth required
|
||||||
|
label="Beschreibung"
|
||||||
|
value={form.beschreibung}
|
||||||
|
onChange={e => setForm(f => ({ ...f, beschreibung: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} sm={6}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Empfänger / Auftraggeber"
|
||||||
|
value={form.empfaenger_auftraggeber}
|
||||||
|
onChange={e => setForm(f => ({ ...f, empfaenger_auftraggeber: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} sm={6}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Verwendungszweck"
|
||||||
|
value={form.verwendungszweck}
|
||||||
|
onChange={e => setForm(f => ({ ...f, verwendungszweck: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} sm={6}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Belegnummer"
|
||||||
|
value={form.beleg_nr}
|
||||||
|
onChange={e => setForm(f => ({ ...f, beleg_nr: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Section: Order link (optional) */}
|
||||||
|
<Card variant="outlined">
|
||||||
|
<CardHeader
|
||||||
|
title="Bestellung verknüpfen"
|
||||||
|
subheader="Optional"
|
||||||
|
titleTypographyProps={{ variant: 'subtitle1', fontWeight: 600 }}
|
||||||
|
/>
|
||||||
|
<Divider />
|
||||||
|
<CardContent>
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>Bestellung</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={form.bestellung_id ?? ''}
|
||||||
|
label="Bestellung"
|
||||||
|
onChange={e => setForm(f => ({ ...f, bestellung_id: e.target.value ? Number(e.target.value) : null }))}
|
||||||
|
>
|
||||||
|
<MenuItem value=""><em>Keine</em></MenuItem>
|
||||||
|
{bestellungen.map(b => (
|
||||||
|
<MenuItem key={b.id} value={b.id}>
|
||||||
|
{b.laufende_nummer ? `#${b.laufende_nummer} – ` : ''}{b.bezeichnung}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{!canSubmit && (
|
||||||
|
<Alert severity="info">
|
||||||
|
Pflichtfelder: Haushaltsjahr, Typ, Betrag, Datum, Konto (Topf), Bankkonto und Beschreibung müssen ausgefüllt sein.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
|
||||||
|
<Button onClick={() => navigate('/buchhaltung')}>Abbrechen</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<Save />}
|
||||||
|
disabled={isPending || !canSubmit}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
>
|
||||||
|
{isEdit ? 'Speichern' : 'Erstellen'}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</DashboardLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user