feat(buchhaltung): replace transaction dialog with dedicated form page, enforce full field validation before booking
This commit is contained in:
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