271 lines
12 KiB
TypeScript
271 lines
12 KiB
TypeScript
import { useParams, useNavigate } from 'react-router-dom';
|
|
import { useState } from 'react';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import {
|
|
Box, Typography, Button, Grid, Card, CardContent,
|
|
Table, TableHead, TableBody, TableRow, TableCell,
|
|
Chip, Alert, Skeleton, TableContainer, Paper,
|
|
IconButton, CircularProgress,
|
|
} from '@mui/material';
|
|
import { ArrowBack, KeyboardArrowDown, KeyboardArrowUp } from '@mui/icons-material';
|
|
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
|
import { buchhaltungApi } from '../services/buchhaltung';
|
|
import { AUSGABEN_TYP_LABELS } from '../types/buchhaltung.types';
|
|
import type { AusgabenTyp, BuchhaltungAudit } from '../types/buchhaltung.types';
|
|
|
|
const fmtEur = (n: number) => new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(n);
|
|
|
|
function BudgetCard({ label, budget, spent }: { label: string; budget: number; spent: number }) {
|
|
return (
|
|
<Card>
|
|
<CardContent>
|
|
<Typography variant="subtitle2" color="text.secondary">{label}</Typography>
|
|
<Typography variant="h6">{fmtEur(Number(spent))}</Typography>
|
|
{budget > 0 && (
|
|
<Typography variant="body2" color="text.secondary">Budget: {fmtEur(Number(budget))}</Typography>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
const AKTION_LABELS: Record<string, string> = {
|
|
erstellt: 'Erstellt',
|
|
aktualisiert: 'Aktualisiert',
|
|
gebucht: 'Gebucht',
|
|
storniert: 'Storniert',
|
|
erstellt_wiederkehrend: 'Erstellt (wiederkehrend)',
|
|
freigabe_beantragt: 'Freigabe beantragt',
|
|
freigabe_genehmigt: 'Freigabe genehmigt',
|
|
freigabe_abgelehnt: 'Freigabe abgelehnt',
|
|
};
|
|
|
|
function AuditRows({ transaktionId }: { transaktionId: number }) {
|
|
const { data, isLoading } = useQuery<BuchhaltungAudit[]>({
|
|
queryKey: ['buchhaltung-audit', transaktionId],
|
|
queryFn: () => buchhaltungApi.getAudit(transaktionId),
|
|
});
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<TableRow>
|
|
<TableCell colSpan={7} sx={{ py: 1, bgcolor: 'action.hover' }}>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 2 }}>
|
|
<CircularProgress size={14} />
|
|
<Typography variant="caption" color="text.secondary">Lade Verlauf…</Typography>
|
|
</Box>
|
|
</TableCell>
|
|
</TableRow>
|
|
);
|
|
}
|
|
|
|
if (!data || data.length === 0) {
|
|
return (
|
|
<TableRow>
|
|
<TableCell colSpan={7} sx={{ py: 1, bgcolor: 'action.hover' }}>
|
|
<Typography variant="caption" color="text.secondary" sx={{ px: 2 }}>Kein Verlauf vorhanden</Typography>
|
|
</TableCell>
|
|
</TableRow>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{data.map(entry => (
|
|
<TableRow key={entry.id} sx={{ bgcolor: 'action.hover' }}>
|
|
<TableCell />
|
|
<TableCell>
|
|
<Typography variant="caption" color="text.secondary">
|
|
{new Date(entry.erstellt_am).toLocaleString('de-DE')}
|
|
</Typography>
|
|
</TableCell>
|
|
<TableCell colSpan={3}>
|
|
<Typography variant="caption">
|
|
{AKTION_LABELS[entry.aktion] ?? entry.aktion}
|
|
</Typography>
|
|
</TableCell>
|
|
<TableCell colSpan={2}>
|
|
<Typography variant="caption" color="text.secondary">
|
|
{entry.erstellt_von ?? '—'}
|
|
</Typography>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</>
|
|
);
|
|
}
|
|
|
|
export default function BuchhaltungKontoDetail() {
|
|
const { id } = useParams<{ id: string }>();
|
|
const navigate = useNavigate();
|
|
const kontoId = Number(id);
|
|
const [expandedId, setExpandedId] = useState<number | null>(null);
|
|
|
|
const { data, isLoading, isError } = useQuery({
|
|
queryKey: ['kontoDetail', kontoId],
|
|
queryFn: () => buchhaltungApi.getKontoDetail(kontoId),
|
|
enabled: !!kontoId,
|
|
});
|
|
|
|
if (isLoading) return <DashboardLayout><Skeleton variant="rectangular" height={400} /></DashboardLayout>;
|
|
if (isError || !data) return <DashboardLayout><Alert severity="error">Konto nicht gefunden.</Alert></DashboardLayout>;
|
|
|
|
const { konto, children, transaktionen } = data;
|
|
const isEinfach = (konto.budget_typ || 'detailliert') === 'einfach';
|
|
const totalEinnahmen = transaktionen
|
|
.filter(t => t.typ === 'einnahme' && t.status === 'gebucht')
|
|
.reduce((sum, t) => sum + Number(t.betrag), 0);
|
|
const totalAusgaben = transaktionen
|
|
.filter(t => t.typ === 'ausgabe' && t.status === 'gebucht')
|
|
.reduce((sum, t) => sum + Number(t.betrag), 0);
|
|
|
|
return (
|
|
<DashboardLayout>
|
|
<Box sx={{ p: 3 }}>
|
|
<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 }}>
|
|
{konto.kontonummer} — {konto.bezeichnung}
|
|
</Typography>
|
|
</Box>
|
|
|
|
<Grid container spacing={2} sx={{ mb: 3 }}>
|
|
{isEinfach ? (
|
|
<>
|
|
<Grid item xs={12} sm={6} md={4}>
|
|
<BudgetCard label="Budget Gesamt" budget={konto.budget_gesamt || 0} spent={totalAusgaben} />
|
|
</Grid>
|
|
<Grid item xs={12} sm={6} md={4}>
|
|
<Card>
|
|
<CardContent>
|
|
<Typography variant="subtitle2" color="text.secondary">Einnahmen</Typography>
|
|
<Typography variant="h6" color="success.main">{fmtEur(totalEinnahmen)}</Typography>
|
|
</CardContent>
|
|
</Card>
|
|
</Grid>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Grid item xs={12} sm={6} md={3}>
|
|
<BudgetCard label="GWG" budget={konto.budget_gwg} spent={
|
|
transaktionen.filter(t => t.typ === 'ausgabe' && t.status === 'gebucht' && t.ausgaben_typ === 'gwg').reduce((s, t) => s + Number(t.betrag), 0)
|
|
} />
|
|
</Grid>
|
|
<Grid item xs={12} sm={6} md={3}>
|
|
<BudgetCard label="Anlagen" budget={konto.budget_anlagen} spent={
|
|
transaktionen.filter(t => t.typ === 'ausgabe' && t.status === 'gebucht' && t.ausgaben_typ === 'anlagen').reduce((s, t) => s + Number(t.betrag), 0)
|
|
} />
|
|
</Grid>
|
|
<Grid item xs={12} sm={6} md={3}>
|
|
<BudgetCard label="Instandhaltung" budget={konto.budget_instandhaltung} spent={
|
|
transaktionen.filter(t => t.typ === 'ausgabe' && t.status === 'gebucht' && t.ausgaben_typ === 'instandhaltung').reduce((s, t) => s + Number(t.betrag), 0)
|
|
} />
|
|
</Grid>
|
|
<Grid item xs={12} sm={6} md={3}>
|
|
<Card>
|
|
<CardContent>
|
|
<Typography variant="subtitle2" color="text.secondary">Einnahmen</Typography>
|
|
<Typography variant="h6" color="success.main">{fmtEur(totalEinnahmen)}</Typography>
|
|
</CardContent>
|
|
</Card>
|
|
</Grid>
|
|
</>
|
|
)}
|
|
</Grid>
|
|
|
|
{children.length > 0 && (
|
|
<Box sx={{ mb: 3 }}>
|
|
<Typography variant="h6" sx={{ mb: 1 }}>Unterkonten</Typography>
|
|
<TableContainer component={Paper}>
|
|
<Table size="small">
|
|
<TableHead>
|
|
<TableRow>
|
|
<TableCell>Konto</TableCell>
|
|
{!isEinfach && <TableCell align="right">Budget GWG</TableCell>}
|
|
{!isEinfach && <TableCell align="right">Budget Anlagen</TableCell>}
|
|
{!isEinfach && <TableCell align="right">Budget Instandh.</TableCell>}
|
|
<TableCell align="right">Budget Gesamt</TableCell>
|
|
</TableRow>
|
|
</TableHead>
|
|
<TableBody>
|
|
{children.map(child => {
|
|
const childIsEinfach = (child.budget_typ || 'detailliert') === 'einfach';
|
|
const childTotal = childIsEinfach
|
|
? Number(child.budget_gesamt || 0)
|
|
: Number(child.budget_gwg || 0) + Number(child.budget_anlagen || 0) + Number(child.budget_instandhaltung || 0);
|
|
return (
|
|
<TableRow
|
|
key={child.id}
|
|
hover
|
|
sx={{ cursor: 'pointer' }}
|
|
onClick={() => navigate(`/buchhaltung/konto/${child.id}`)}
|
|
>
|
|
<TableCell>{child.kontonummer} — {child.bezeichnung}</TableCell>
|
|
{!isEinfach && <TableCell align="right">{childIsEinfach ? '—' : fmtEur(Number(child.budget_gwg))}</TableCell>}
|
|
{!isEinfach && <TableCell align="right">{childIsEinfach ? '—' : fmtEur(Number(child.budget_anlagen))}</TableCell>}
|
|
{!isEinfach && <TableCell align="right">{childIsEinfach ? '—' : fmtEur(Number(child.budget_instandhaltung))}</TableCell>}
|
|
<TableCell align="right">{fmtEur(childTotal)}</TableCell>
|
|
</TableRow>
|
|
);
|
|
})}
|
|
</TableBody>
|
|
</Table>
|
|
</TableContainer>
|
|
</Box>
|
|
)}
|
|
|
|
<Box>
|
|
<Typography variant="h6" sx={{ mb: 1 }}>Transaktionen</Typography>
|
|
<TableContainer component={Paper}>
|
|
<Table size="small">
|
|
<TableHead>
|
|
<TableRow>
|
|
<TableCell padding="checkbox" />
|
|
<TableCell>Datum</TableCell>
|
|
<TableCell>Beschreibung</TableCell>
|
|
<TableCell>Typ</TableCell>
|
|
<TableCell>Ausgaben-Typ</TableCell>
|
|
<TableCell align="right">Betrag</TableCell>
|
|
<TableCell>Status</TableCell>
|
|
</TableRow>
|
|
</TableHead>
|
|
<TableBody>
|
|
{transaktionen.length === 0 && (
|
|
<TableRow><TableCell colSpan={7} align="center">Keine Transaktionen</TableCell></TableRow>
|
|
)}
|
|
{transaktionen.map(t => (
|
|
<>
|
|
<TableRow key={t.id} hover sx={{ cursor: 'pointer' }}
|
|
onClick={() => setExpandedId(expandedId === t.id ? null : t.id)}>
|
|
<TableCell padding="checkbox">
|
|
<IconButton size="small">
|
|
{expandedId === t.id ? <KeyboardArrowUp fontSize="small" /> : <KeyboardArrowDown fontSize="small" />}
|
|
</IconButton>
|
|
</TableCell>
|
|
<TableCell>{new Date(t.datum).toLocaleDateString('de-DE')}</TableCell>
|
|
<TableCell>{t.beschreibung}</TableCell>
|
|
<TableCell>
|
|
<Chip size="small" label={t.typ === 'einnahme' ? 'Einnahme' : 'Ausgabe'}
|
|
color={t.typ === 'einnahme' ? 'success' : 'error'} />
|
|
</TableCell>
|
|
<TableCell>{t.ausgaben_typ ? AUSGABEN_TYP_LABELS[t.ausgaben_typ as AusgabenTyp] : '—'}</TableCell>
|
|
<TableCell align="right"
|
|
sx={{ color: t.typ === 'einnahme' ? 'success.main' : 'error.main' }}>
|
|
{t.typ === 'einnahme' ? '+' : '-'}{fmtEur(Number(t.betrag))}
|
|
</TableCell>
|
|
<TableCell>{t.status}</TableCell>
|
|
</TableRow>
|
|
{expandedId === t.id && <AuditRows transaktionId={t.id} />}
|
|
</>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</TableContainer>
|
|
</Box>
|
|
</Box>
|
|
</DashboardLayout>
|
|
);
|
|
}
|