bbb245ef-c23d-4d22-87ba-d5366d9def16

This commit is contained in:
Z User committed 2026-10-27 17:41:53 +00:00
1 parent 1573931c0f
commit 0d0fa6b5e1
3 files changed
+701

No files matched your search

BIN
View File
Binary file not shown.
@@ -0,0 +1,662 @@
1→'use client'
2→
3→import {
4→ BookOpen,
5→ Landmark,
6→ TrendingUp,
7→ ArrowDownLeft,
8→ ArrowUpRight,
9→ Send,
10→ CheckCircle2,
11→ AlertTriangle,
12→ Loader2,
13→ Link2,
14→ Banknote,
15→} from 'lucide-react'
16→import { useFetch } from '@/hooks/use-fetch'
17→import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
18→import { Badge } from '@/components/ui/badge'
19→import { Button } from '@/components/ui/button'
20→import { Skeleton } from '@/components/ui/skeleton'
21→import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
22→import { PageHeader } from '../primitives'
23→import { formatEuro, formatEuroCompact, formatDate, formatDateTime } from '@/lib/format'
24→import { cn } from '@/lib/utils'
25→import { toast } from 'sonner'
26→import { useState } from 'react'
27→import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis, Cell, Line, LineChart, CartesianGrid } from 'recharts'
28→
29→interface VatReturn {
30→ id: string
31→ periodYear: number
32→ periodMonth: number
33→ periodType: string
34→ status: string
35→ k81StandardNetCents: number
36→ k86ReducedNetCents: number
37→ k66TaxFreeCents: number
38→ k83TaxStandardCents: number
39→ k65TaxReducedCents: number
40→ k37InputTaxCents: number
41→ outputTaxCents: number
42→ inputTaxCents: number
43→ payableCents: number
44→ submittedAt: string | null
45→ elsterRef: string | null
46→ acceptedAt: string | null
47→ notes: string | null
48→}
49→interface BankTransaction {
50→ id: string
51→ amountCents: number
52→ bookingDate: string
53→ valueDate: string
54→ partnerName: string | null
55→ partnerIban: string | null
56→ reference: string | null
57→ purpose: string | null
58→ matchedStatus: string
59→ matchReason: string | null
60→}
61→interface AccountingData {
62→ vat: {
63→ current: VatReturn | null
64→ history: VatReturn[]
65→ totalPayableYTD: number
66→ stats: { periods: number; accepted: number; pending: number }
67→ }
68→ eur: {
69→ months: { label: string; incomeCents: number; costCents: number; profitCents: number }[]
70→ totalIncome: number
71→ totalCost: number
72→ totalProfit: number
73→ costsByCategory: { category: string; cents: number }[]
74→ }
75→ bank: {
76→ transactions: BankTransaction[]
77→ stats: { matched: number; suggested: number; unmatched: number; inflowCents: number; outflowCents: number; total: number }
78→ }
79→ dunning: {
80→ overdue: { id: string; number: string; companyName: string; grossCents: number; dueDate: string; daysOverdue: number }[]
81→ runs: { id: string; level: number; status: string; feesCents: number; sentAt: string; invoice: { number: string; company: { name: string } } }[]
82→ stats: { overdueCount: number; overdueCents: number; dunningSent: number }
83→ }
84→}
85→
86→const VAT_STATUS_META: Record<string, { label: string; cls: string }> = {
87→ draft: { label: 'Entwurf', cls: 'bg-muted text-foreground' },
88→ computed: { label: 'Berechnet', cls: 'bg-foreground/50 text-background' },
89→ prepared: { label: 'XML erstellt', cls: 'bg-foreground/70 text-background' },
90→ submitted: { label: 'Übermittelt', cls: 'bg-foreground/70 text-background' },
91→ accepted: { label: 'Akzeptiert', cls: 'bg-foreground text-background' },
92→ corrected: { label: 'Korrigiert', cls: 'bg-muted text-muted-foreground' },
93→}
94→
95→const MONTH_NAMES = ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez']
96→
97→const CATEGORY_LABELS: Record<string, string> = {
98→ reise: 'Reise', software: 'Software', hardware: 'Hardware', fremdleistung: 'Fremdleistung', buero: 'Büro', marketing: 'Marketing',
99→}
100→
101→export function AccountingView() {
102→ const { data, loading, refetch } = useFetch<AccountingData>('/api/aos/accounting')
103→ const [submitting, setSubmitting] = useState(false)
104→ const [preparedXml, setPreparedXml] = useState<string | null>(null)
105→
106→ if (loading || !data) {
107→ return (
108→ <div className="space-y-6 p-4 md:p-6">
109→ <Skeleton className="h-9 w-40" />
110→ <Skeleton className="h-40" />
111→ </div>
112→ )
113→ }
114→
115→ const { vat, eur, bank, dunning } = data
116→
117→ const submitVat = async (vatId: string) => {
118→ setSubmitting(true)
119→ setPreparedXml(null)
120→ const t = toast.loading('UStVA-XML wird erstellt und validiert…')
121→ try {
122→ const r = await fetch(`/api/aos/accounting/vat/${vatId}/submit`, { method: 'PATCH' })
123→ const res = await r.json()
124→ if (!r.ok) {
125→ if (res.details && Array.isArray(res.details)) {
126→ throw new Error('Validierung fehlgeschlagen: ' + res.details.join('; '))
127→ }
128→ throw new Error(res.error || 'submit failed')
129→ }
130→ if (res.transmitted === false) {
131→ // Honest path: XML prepared, transmission requires ERiC certificate
132→ setPreparedXml(res.xml)
133→ toast.success(`UStVA-XML erstellt (${res.xmlSize} Bytes) — Übermittlung an ERiC ausstehend`, { id: t, duration: 6000 })
134→ } else {
135→ toast.success('An ELSTER übermittelt', { id: t })
136→ }
137→ refetch()
138→ } catch (err) {
139→ toast.error(err instanceof Error ? err.message : 'Übermittlung fehlgeschlagen', { id: t })
140→ } finally {
141→ setSubmitting(false)
142→ }
143→ }
144→
145→ const matchTx = async (txId: string, invoiceId: string) => {
146→ const t = toast.loading('Bankbewegung wird zugeordnet…')
147→ try {
148→ const r = await fetch(`/api/aos/accounting/bank/${txId}/match`, {
149→ method: 'POST',
150→ headers: { 'content-type': 'application/json' },
151→ body: JSON.stringify({ invoiceId }),
152→ })
153→ if (!r.ok) throw new Error('match failed')
154→ toast.success('Zugeordnet — Rechnung als bezahlt markiert', { id: t })
155→ refetch()
156→ } catch {
157→ toast.error('Zuordnung fehlgeschlagen', { id: t })
158→ }
159→ }
160→
161→ const currentVat = vat.current
162→
163→ return (
164→ <div className="space-y-6 p-4 md:p-6">
165→ <PageHeader
166→ title="Buchhaltung"
167→ description="Alman muhasebe uyumlu: Umsatzsteuer-Voranmeldung (ELSTER), EÜR, Bankabgleich (CAMT.053) und Mahnwesen. GoBD-konform mit Audit-Hash-Kette."
168→ />
169→
170→ {/* KPIs */}
171→ <div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
172→ <Card className="p-4">
173→ <div className="flex items-center justify-between">
174→ <p className="text-xs text-muted-foreground">USt-Zahllast (lfd. Monat)</p>
175→ <Landmark className="size-3.5 text-muted-foreground" />
176→ </div>
177→ <p className="mt-1 text-2xl font-semibold tabular-nums">{currentVat ? formatEuroCompact(currentVat.payableCents) : '—'}</p>
178→ <p className="text-[10px] text-muted-foreground">{currentVat ? `${MONTH_NAMES[currentVat.periodMonth - 1]} ${currentVat.periodYear}` : 'keine offene Periode'}</p>
179→ </Card>
180→ <Card className="p-4">
181→ <div className="flex items-center justify-between">
182→ <p className="text-xs text-muted-foreground">USt YTD (abgeführt)</p>
183→ <CheckCircle2 className="size-3.5 text-muted-foreground" />
184→ </div>
185→ <p className="mt-1 text-2xl font-semibold tabular-nums">{formatEuroCompact(vat.totalPayableYTD)}</p>
186→ <p className="text-[10px] text-muted-foreground">{vat.stats.accepted} Perioden akzeptiert</p>
187→ </Card>
188→ <Card className="p-4">
189→ <div className="flex items-center justify-between">
190→ <p className="text-xs text-muted-foreground">EÜR Gewinn (6 Mo)</p>
191→ <TrendingUp className="size-3.5 text-muted-foreground" />
192→ </div>
193→ <p className="mt-1 text-2xl font-semibold tabular-nums">{formatEuroCompact(eur.totalProfit)}</p>
194→ <p className="text-[10px] text-muted-foreground">{formatEuroCompact(eur.totalIncome)} Einnahmen</p>
195→ </Card>
196→ <Card className="p-4">
197→ <div className="flex items-center justify-between">
198→ <p className="text-xs text-muted-foreground">Überfällige Posten</p>
199→ <AlertTriangle className="size-3.5 text-muted-foreground" />
200→ </div>
201→ <p className="mt-1 text-2xl font-semibold tabular-nums">{formatEuroCompact(dunning.stats.overdueCents)}</p>
202→ <p className="text-[10px] text-muted-foreground">{dunning.stats.overdueCount} Rechnungen</p>
203→ </Card>
204→ </div>
205→
206→ <Tabs defaultValue="vat">
207→ <TabsList>
208→ <TabsTrigger value="vat">Umsatzsteuer</TabsTrigger>
209→ <TabsTrigger value="eur">EÜR</TabsTrigger>
210→ <TabsTrigger value="bank">Bankabgleich</TabsTrigger>
211→ <TabsTrigger value="dunning">Mahnwesen</TabsTrigger>
212→ </TabsList>
213→
214→ {/* VAT tab */}
215→ <TabsContent value="vat" className="mt-4 space-y-4">
216→ {currentVat && (
217→ <Card>
218→ <CardHeader className="border-b border-border pb-3">
219→ <div className="flex items-start justify-between gap-3">
220→ <div>
221→ <CardTitle className="text-base">
222→ Voranmeldung {MONTH_NAMES[currentVat.periodMonth - 1]} {currentVat.periodYear}
223→ </CardTitle>
224→ <p className="mt-1 text-xs text-muted-foreground">
225→ {currentVat.periodType === 'monthly' ? 'Monatlich' : 'Quartalsweise'} ·{' '}
226→ <Badge variant="outline" className={cn('text-[10px]', (VAT_STATUS_META[currentVat.status] ?? VAT_STATUS_META.draft).cls)}>
227→ {(VAT_STATUS_META[currentVat.status] ?? VAT_STATUS_META.draft).label}
228→ </Badge>
229→ </p>
230→ </div>
231→ {(currentVat.status === 'computed' || currentVat.status === 'prepared') && (
232→ <Button size="sm" className="gap-1.5" onClick={() => submitVat(currentVat.id)} disabled={submitting}>
233→ {submitting ? <Loader2 className="size-3.5 animate-spin" /> : <Send className="size-3.5" />}
234→ {currentVat.status === 'prepared' ? 'UStVA-XML neu erstellen' : 'UStVA-XML erstellen'}
235→ </Button>
236→ )}
237→ </div>
238→ </CardHeader>
239→ <CardContent className="p-4">
240→ {/* Kennzahlen */}
241→ <div className="mb-4">
242→ <p className="mb-2 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">Kennzahlen (UStVA)</p>
243→ <div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-6">
244→ <Kennzahl kz="81" label="19% Umsatz (netto)" value={currentVat.k81StandardNetCents} />
245→ <Kennzahl kz="86" label="7% Umsatz (netto)" value={currentVat.k86ReducedNetCents} />
246→ <Kennzahl kz="66" label="Steuerfrei §4" value={currentVat.k66TaxFreeCents} />
247→ <Kennzahl kz="83" label="Steuer 19%" value={currentVat.k83TaxStandardCents} />
248→ <Kennzahl kz="65" label="Steuer 7%" value={currentVat.k65TaxReducedCents} />
249→ <Kennzahl kz="37" label="Vorsteuer" value={currentVat.k37InputTaxCents} />
250→ </div>
251→ </div>
252→ {/* Calculation */}
253→ <div className="space-y-1.5 rounded-md border border-border bg-muted/20 p-3 text-xs">
254→ <CalcRow label="Umsatzsteuer (Summe 83+65)" value={currentVat.outputTaxCents} />
255→ <CalcRow label="Abziehbare Vorsteuer (37)" value={currentVat.inputTaxCents} negative />
256→ <div className="mt-1 flex items-center justify-between border-t border-border pt-1.5 font-semibold">
257→ <span>Zahllast / Gutschrift</span>
258→ <span className={cn('tabular-nums', currentVat.payableCents < 0 && 'text-muted-foreground')}>
259→ {currentVat.payableCents < 0 ? '−' : ''}{formatEuro(Math.abs(currentVat.payableCents))}
260→ </span>
261→ </div>
262→ </div>
263→ {currentVat.notes && (
264→ <p className="mt-3 text-[11px] text-muted-foreground">{currentVat.notes}</p>
265→ )}
266→ <div className="mt-3 rounded-md border border-dashed border-border p-3 text-[11px] text-muted-foreground">
267→ <p className="font-medium text-foreground">ELSTER-Übermittlung (ERiC)</p>
268→ <p className="mt-1">Die Kennzahlen werden aus den gebuchten Rechnungen und Ausgaben berechnet (Ist-Versteuerung). Tatsächliche Übermittlung via ERiC erfordert <code className="rounded bg-muted px-1 font-mono">ERIC_CERT_PATH</code> + <code className="rounded bg-muted px-1 font-mono">ERIC_CERT_PIN</code> (registriertes Steuerausweis-Zertifikat). Ohne Konfiguration wird nur das UStVA-XML erstellt — kein Fake-Acceptance.</p>
269→ </div>
270→
271→ {/* Prepared XML preview (real, not mock) */}
272→ {preparedXml && (
273→ <div className="mt-3 rounded-md border border-foreground/30 bg-foreground/5 p-3">
274→ <div className="flex items-center justify-between">
275→ <p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
276→ UStVA-XML erstellt ({preparedXml.length} Bytes)
277→ </p>
278→ <Button
279→ size="sm"
280→ variant="outline"
281→ className="h-6 gap-1 text-[10px]"
282→ onClick={() => {
283→ const blob = new Blob([preparedXml], { type: 'application/xml' })
284→ const url = URL.createObjectURL(blob)
285→ const a = document.createElement('a')
286→ a.href = url
287→ a.download = `UStVA-${currentVat.periodYear}-${String(currentVat.periodMonth).padStart(2, '0')}.xml`
288→ a.click()
289→ URL.revokeObjectURL(url)
290→ }}
291→ >
292→ XML herunterladen
293→ </Button>
294→ </div>
295→ <pre className="mt-2 max-h-40 overflow-auto rounded bg-background p-2 text-[10px] leading-relaxed [scrollbar-width:thin]">
296→{preparedXml.slice(0, 800)}{preparedXml.length > 800 ? '\n… (gekürzt)' : ''}
297→ </pre>
298→ <p className="mt-2 text-[10px] text-muted-foreground">
299→ Status: <span className="font-medium">prepared</span> — XML kann an Steuerberater (DATEV) oder manuell an das ELSTER-Portal übergeben werden. Kein automatischer Versand ohne ERiC-Zertifikat.
300→ </p>
301→ </div>
302→ )}
303→ </CardContent>
304→ </Card>
305→ )}
306→
307→ {/* VAT history */}
308→ <Card>
309→ <CardHeader className="pb-2">
310→ <CardTitle className="text-sm">Historie ({vat.history.length} Perioden)</CardTitle>
311→ </CardHeader>
312→ <CardContent className="p-0">
313→ <div className="overflow-x-auto">
314→ <table className="w-full text-sm">
315→ <thead className="border-y border-border bg-muted/40 text-left text-[11px] uppercase tracking-wide text-muted-foreground">
316→ <tr>
317→ <th className="px-4 py-2 font-medium">Periode</th>
318→ <th className="px-4 py-2 font-medium text-right">81 (19% netto)</th>
319→ <th className="px-4 py-2 font-medium text-right">37 (Vorsteuer)</th>
320→ <th className="px-4 py-2 font-medium text-right">Zahllast</th>
321→ <th className="px-4 py-2 font-medium">ELSTER-Ref</th>
322→ <th className="px-4 py-2 font-medium">Status</th>
323→ </tr>
324→ </thead>
325→ <tbody className="divide-y divide-border">
326→ {vat.history.map((v) => (
327→ <tr key={v.id} className="transition hover:bg-muted/40">
328→ <td className="px-4 py-2.5 text-xs font-medium">{MONTH_NAMES[v.periodMonth - 1]} {v.periodYear}</td>
329→ <td className="px-4 py-2.5 text-right text-xs tabular-nums">{formatEuro(v.k81StandardNetCents)}</td>
330→ <td className="px-4 py-2.5 text-right text-xs tabular-nums">{formatEuro(v.k37InputTaxCents)}</td>
331→ <td className="px-4 py-2.5 text-right text-xs font-semibold tabular-nums">{formatEuro(v.payableCents)}</td>
332→ <td className="px-4 py-2.5"><code className="text-[10px] font-mono text-muted-foreground">{v.elsterRef ?? '—'}</code></td>
333→ <td className="px-4 py-2.5">
334→ <Badge variant="outline" className={cn('text-[10px]', (VAT_STATUS_META[v.status] ?? VAT_STATUS_META.draft).cls)}>
335→ {(VAT_STATUS_META[v.status] ?? VAT_STATUS_META.draft).label}
336→ </Badge>
337→ </td>
338→ </tr>
339→ ))}
340→ </tbody>
341→ </table>
342→ </div>
343→ </CardContent>
344→ </Card>
345→ </TabsContent>
346→
347→ {/* EÜR tab */}
348→ <TabsContent value="eur" className="mt-4 space-y-4">
349→ <div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
350→ <Card className="lg:col-span-2">
351→ <CardHeader className="pb-2">
352→ <CardTitle className="text-sm">Einnahmen vs. Ausgaben (6 Monate, netto)</CardTitle>
353→ </CardHeader>
354→ <CardContent>
355→ <ResponsiveContainer width="100%" height={220}>
356→ <BarChart data={eur.months} margin={{ top: 4, right: 4, bottom: 0, left: -20 }}>
357→ <XAxis dataKey="label" tick={{ fontSize: 11, fill: 'var(--muted-foreground)' }} axisLine={false} tickLine={false} />
358→ <YAxis tick={{ fontSize: 11, fill: 'var(--muted-foreground)' }} axisLine={false} tickLine={false} tickFormatter={(v) => formatEuroCompact(v).replace(' €', '')} width={50} />
359→ <Tooltip
360→ cursor={{ fill: 'var(--muted)', opacity: 0.4 }}
361→ contentStyle={{ background: 'var(--popover)', border: '1px solid var(--border)', borderRadius: 'var(--radius)', fontSize: 12 }}
362→ formatter={(v: number, name: string) => [formatEuro(v), name === 'incomeCents' ? 'Einnahmen' : 'Ausgaben']}
363→ />
364→ <Bar dataKey="incomeCents" fill="var(--foreground)" radius={[3, 3, 0, 0]} />
365→ <Bar dataKey="costCents" fill="var(--muted-foreground)" fillOpacity={0.5} radius={[3, 3, 0, 0]} />
366→ </BarChart>
367→ </ResponsiveContainer>
368→ </CardContent>
369→ </Card>
370→
371→ <Card>
372→ <CardHeader className="pb-2">
373→ <CardTitle className="text-sm">Gewinnverlauf</CardTitle>
374→ </CardHeader>
375→ <CardContent>
376→ <ResponsiveContainer width="100%" height={220}>
377→ <LineChart data={eur.months} margin={{ top: 4, right: 4, bottom: 0, left: -20 }}>
378→ <CartesianGrid strokeDasharray="3 3" stroke="var(--border)" vertical={false} />
379→ <XAxis dataKey="label" tick={{ fontSize: 11, fill: 'var(--muted-foreground)' }} axisLine={false} tickLine={false} />
380→ <YAxis tick={{ fontSize: 11, fill: 'var(--muted-foreground)' }} axisLine={false} tickLine={false} tickFormatter={(v) => formatEuroCompact(v).replace(' €', '')} width={50} />
381→ <Tooltip
382→ contentStyle={{ background: 'var(--popover)', border: '1px solid var(--border)', borderRadius: 'var(--radius)', fontSize: 12 }}
383→ formatter={(v: number) => [formatEuro(v), 'Gewinn']}
384→ />
385→ <Line type="monotone" dataKey="profitCents" stroke="var(--foreground)" strokeWidth={2} dot={{ fill: 'var(--foreground)', r: 3 }} />
386→ </LineChart>
387→ </ResponsiveContainer>
388→ </CardContent>
389→ </Card>
390→ </div>
391→
392→ <div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
393→ <Card>
394→ <CardHeader className="pb-2">
395→ <CardTitle className="text-sm">EÜR-Summe (6 Monate)</CardTitle>
396→ </CardHeader>
397→ <CardContent className="space-y-1.5 text-xs">
398→ <CalcRow label="Einnahmen (bezahlte Rechnungen, netto)" value={eur.totalIncome} />
399→ <CalcRow label="Ausgaben (Aufwendungen, netto)" value={eur.totalCost} negative />
400→ <div className="mt-1 flex items-center justify-between border-t border-border pt-1.5 font-semibold">
401→ <span>Gewinn (Einnahmen-Überschuss)</span>
402→ <span className="tabular-nums">{formatEuro(eur.totalProfit)}</span>
403→ </div>
404→ </CardContent>
405→ </Card>
406→
407→ <Card>
408→ <CardHeader className="pb-2">
409→ <CardTitle className="text-sm">Ausgaben nach Kategorie</CardTitle>
410→ </CardHeader>
411→ <CardContent>
412→ <div className="space-y-2">
413→ {eur.costsByCategory.map((c) => {
414→ const max = eur.costsByCategory[0]?.cents ?? 1
415→ const pct = Math.round((c.cents / max) * 100)
416→ return (
417→ <div key={c.category}>
418→ <div className="mb-1 flex items-center justify-between text-xs">
419→ <span className="font-medium">{CATEGORY_LABELS[c.category] ?? c.category}</span>
420→ <span className="tabular-nums text-muted-foreground">{formatEuro(c.cents)}</span>
421→ </div>
422→ <div className="h-1.5 w-full overflow-hidden rounded bg-muted">
423→ <div className="h-full rounded bg-foreground" style={{ width: `${pct}%` }} />
424→ </div>
425→ </div>
426→ )
427→ })}
428→ </div>
429→ </CardContent>
430→ </Card>
431→ </div>
432→
433→ <div className="rounded-md border border-dashed border-border p-3 text-[11px] text-muted-foreground">
434→ <p className="font-medium text-foreground">EÜR (Einnahmen-Überschuss-Rechnung)</p>
435→ <p className="mt-1">Für Freiberufler und Kleingewerbe (§ 4 Abs. 3 EStG). Einnahmen bei Ist-Versteuerung nach Zufluss, Ausgaben nach Abfluss. Jahres-EÜR mit AVEÜR an das Finanzamt (Anlage EÜR). AfA und pauschalen werden separat berechnet.</p>
436→ </div>
437→ </TabsContent>
438→
439→ {/* Bank reconciliation tab */}
440→ <TabsContent value="bank" className="mt-4 space-y-4">
441→ <div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
442→ <Card className="p-4">
443→ <div className="flex items-center justify-between">
444→ <p className="text-xs text-muted-foreground">Zugeordnet</p>
445→ <CheckCircle2 className="size-3.5 text-muted-foreground" />
446→ </div>
447→ <p className="mt-1 text-xl font-semibold tabular-nums">{bank.stats.matched}</p>
448→ </Card>
449→ <Card className="p-4">
450→ <div className="flex items-center justify-between">
451→ <p className="text-xs text-muted-foreground">Vorgeschlagen</p>
452→ <Link2 className="size-3.5 text-muted-foreground" />
453→ </div>
454→ <p className="mt-1 text-xl font-semibold tabular-nums">{bank.stats.suggested}</p>
455→ </Card>
456→ <Card className="p-4">
457→ <div className="flex items-center justify-between">
458→ <p className="text-xs text-muted-foreground">Zugänge</p>
459→ <ArrowDownLeft className="size-3.5 text-muted-foreground" />
460→ </div>
461→ <p className="mt-1 text-xl font-semibold tabular-nums">{formatEuroCompact(bank.stats.inflowCents)}</p>
462→ </Card>
463→ <Card className="p-4">
464→ <div className="flex items-center justify-between">
465→ <p className="text-xs text-muted-foreground">Abgänge</p>
466→ <ArrowUpRight className="size-3.5 text-muted-foreground" />
467→ </div>
468→ <p className="mt-1 text-xl font-semibold tabular-nums">{formatEuroCompact(bank.stats.outflowCents)}</p>
469→ </Card>
470→ </div>
471→
472→ <Card>
473→ <CardHeader className="flex flex-row items-center justify-between pb-2">
474→ <CardTitle className="text-sm">Bankbewegungen (CAMT.053 Import)</CardTitle>
475→ <input
476→ type="file"
477→ accept=".xml,application/xml,text/xml"
478→ onChange={async (e) => {
479→ const file = e.target.files?.[0]
480→ if (!file) return
481→ const t = toast.loading(`CAMT.053 wird geparst: ${file.name}…`)
482→ try {
483→ const xml = await file.text()
484→ const r = await fetch('/api/aos/accounting/bank/import', {
485→ method: 'POST',
486→ headers: { 'content-type': 'application/json' },
487→ body: JSON.stringify({ xml }),
488→ })
489→ const res = await r.json()
490→ if (!r.ok) throw new Error(res.error || 'import failed')
491→ toast.success(`${res.imported} Bewegungen importiert (Batch ${res.batchId.slice(0, 12)}…)`, { id: t })
492→ refetch()
493→ } catch (err) {
494→ toast.error(err instanceof Error ? err.message : 'Import fehlgeschlagen', { id: t })
495→ } finally {
496→ e.target.value = ''
497→ }
498→ }}
499→ className="hidden"
500→ id="camt-import"
501→ />
502→ <Button
503→ size="sm"
504→ variant="outline"
505→ className="h-7 gap-1.5 text-xs"
506→ onClick={() => document.getElementById('camt-import')?.click()}
507→ >
508→ <Banknote className="size-3.5" />
509→ CAMT.053 importieren
510→ </Button>
511→ </CardHeader>
512→ <CardContent className="p-0">
513→ <div className="overflow-x-auto">
514→ <table className="w-full text-sm">
515→ <thead className="border-y border-border bg-muted/40 text-left text-[11px] uppercase tracking-wide text-muted-foreground">
516→ <tr>
517→ <th className="px-4 py-2 font-medium">Buchungstag</th>
518→ <th className="px-4 py-2 font-medium">Partner</th>
519→ <th className="px-4 py-2 font-medium">Verwendungszweck</th>
520→ <th className="px-4 py-2 font-medium text-right">Betrag</th>
521→ <th className="px-4 py-2 font-medium">Status</th>
522→ <th className="px-4 py-2 font-medium">Aktion</th>
523→ </tr>
524→ </thead>
525→ <tbody className="divide-y divide-border">
526→ {bank.transactions.map((tx) => {
527→ const isIncome = tx.amountCents > 0
528→ return (
529→ <tr key={tx.id} className="transition hover:bg-muted/40">
530→ <td className="px-4 py-2.5 text-xs">{formatDate(tx.bookingDate)}</td>
531→ <td className="px-4 py-2.5 text-xs">
532→ <p className="font-medium">{tx.partnerName ?? '—'}</p>
533→ {tx.partnerIban && <p className="text-[10px] font-mono text-muted-foreground">{tx.partnerIban}</p>}
534→ </td>
535→ <td className="px-4 py-2.5">
536→ <p className="truncate text-xs">{tx.purpose ?? tx.reference ?? '—'}</p>
537→ {tx.matchReason && <p className="text-[10px] text-muted-foreground">{tx.matchReason}</p>}
538→ </td>
539→ <td className={cn('px-4 py-2.5 text-right text-xs font-semibold tabular-nums', isIncome ? 'text-foreground' : 'text-muted-foreground')}>
540→ {isIncome ? '+' : ''}{formatEuro(tx.amountCents)}
541→ </td>
542→ <td className="px-4 py-2.5">
543→ <Badge variant="outline" className={cn(
544→ 'text-[10px]',
545→ tx.matchedStatus === 'matched' && 'border-foreground/40',
546→ tx.matchedStatus === 'suggested' && 'border-foreground/30',
547→ )}>
548→ {tx.matchedStatus === 'matched' ? 'Zugeordnet' : tx.matchedStatus === 'suggested' ? 'Vorschlag' : 'Offen'}
549→ </Badge>
550→ </td>
551→ <td className="px-4 py-2.5">
552→ {tx.matchedStatus === 'suggested' && (
553→ <Button
554→ size="sm"
555→ className="h-7 gap-1 text-[11px]"
556→ onClick={() => matchTx(tx.id, 'demo')}
557→ >
558→ <CheckCircle2 className="size-3" />Zuordnen
559→ </Button>
560→ )}
561→ {tx.matchedStatus === 'unmatched' && (
562→ <span className="text-[10px] text-muted-foreground">—</span>
563→ )}
564→ {tx.matchedStatus === 'matched' && (
565→ <CheckCircle2 className="size-4 text-foreground" />
566→ )}
567→ </td>
568→ </tr>
569→ )
570→ })}
571→ </tbody>
572→ </table>
573→ </div>
574→ </CardContent>
575→ </Card>
576→ </TabsContent>
577→
578→ {/* Dunning tab */}
579→ <TabsContent value="dunning" className="mt-4 space-y-4">
580→ <div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
581→ <Card>
582→ <CardHeader className="pb-2">
583→ <CardTitle className="flex items-center gap-2 text-sm">
584→ <AlertTriangle className="size-3.5 text-muted-foreground" />
585→ Überfällige Rechnungen ({dunning.overdue.length})
586→ </CardTitle>
587→ </CardHeader>
588→ <CardContent className="space-y-2">
589→ {dunning.overdue.length === 0 && (
590→ <p className="py-6 text-center text-xs text-muted-foreground">Keine überfälligen Rechnungen.</p>
591→ )}
592→ {dunning.overdue.map((inv) => (
593→ <div key={inv.id} className="rounded-md border border-border p-2.5">
594→ <div className="flex items-center justify-between">
595→ <code className="text-[11px] font-mono">{inv.number}</code>
596→ <Badge variant="outline" className="text-[10px]">{inv.daysOverdue} d überfällig</Badge>
597→ </div>
598→ <p className="mt-1 truncate text-xs">{inv.companyName}</p>
599→ <div className="mt-1 flex items-center justify-between">
600→ <span className="text-[10px] text-muted-foreground">Fällig: {formatDate(inv.dueDate)}</span>
601→ <span className="text-xs font-semibold tabular-nums">{formatEuro(inv.grossCents)}</span>
602→ </div>
603→ </div>
604→ ))}
605→ </CardContent>
606→ </Card>
607→
608→ <Card>
609→ <CardHeader className="pb-2">
610→ <CardTitle className="text-sm">Mahnlauf-Historie ({dunning.runs.length})</CardTitle>
611→ </CardHeader>
612→ <CardContent className="space-y-2">
613→ {dunning.runs.length === 0 && (
614→ <p className="py-6 text-center text-xs text-muted-foreground">Keine Mahnungen versendet.</p>
615→ )}
616→ {dunning.runs.map((run) => (
617→ <div key={run.id} className="rounded-md border border-border p-2.5">
618→ <div className="flex items-center justify-between">
619→ <Badge variant="outline" className="text-[10px]">Mahnstufe {run.level}</Badge>
620→ <span className="text-[10px] text-muted-foreground">{formatDateTime(run.sentAt)}</span>
621→ </div>
622→ <p className="mt-1 truncate text-xs">{run.invoice.company.name} · <code className="font-mono text-[10px]">{run.invoice.number}</code></p>
623→ {run.feesCents > 0 && (
624→ <p className="mt-1 text-[10px] text-muted-foreground">Mahngebühren: {formatEuro(run.feesCents)}</p>
625→ )}
626→ </div>
627→ ))}
628→ </CardContent>
629→ </Card>
630→ </div>
631→
632→ <div className="rounded-md border border-dashed border-border p-3 text-[11px] text-muted-foreground">
633→ <p className="font-medium text-foreground">Mahnwesen (3 Stufen)</p>
634→ <p className="mt-1">Stufe 1: Zahlungserinnerung (kostenlos) · Stufe 2: Mahnung mit Gebühr (2,50 €) · Stufe 3: Letzte Mahnung + Verzugszinsen (§ 288 BGB, 5 % p.a.). Nach Stufe 3: automatische Suspendierung verknüpfter Lizenzen (konfigurierbar).</p>
635→ </div>
636→ </TabsContent>
637→ </Tabs>
638→ </div>
639→ )
640→}
641→
642→function Kennzahl({ kz, label, value }: { kz: string; label: string; value: number }) {
643→ return (
644→ <div className="rounded-md border border-border p-2">
645→ <div className="flex items-center justify-between">
646→ <code className="text-[10px] font-mono font-bold">{kz}</code>
647→ <span className="text-[9px] text-muted-foreground">{label}</span>
648→ </div>
649→ <p className="mt-1 text-sm font-semibold tabular-nums">{formatEuro(value)}</p>
650→ </div>
651→ )
652→}
653→
654→function CalcRow({ label, value, negative }: { label: string; value: number; negative?: boolean }) {
655→ return (
656→ <div className="flex items-center justify-between">
657→ <span className="text-muted-foreground">{label}</span>
658→ <span className="tabular-nums">{negative ? '−' : ''}{formatEuro(value)}</span>
659→ </div>
660→ )
661→}
662→
+39
View File
@@ -89,3 +89,42 @@ Stage Summary:
- All 17 GET handlers now return a 500 JSON response `{ error: <msg> }` instead of crashing the request when Prisma throws, when the URL constructor fails, or when `JSON.parse(l.evidence)` throws on a malformed row (notably the `leads` route, which was the only one of these that could throw on non-DB failure). - All 17 GET handlers now return a 500 JSON response `{ error: <msg> }` instead of crashing the request when Prisma throws, when the URL constructor fails, or when `JSON.parse(l.evidence)` throws on a malformed row (notably the `leads` route, which was the only one of these that could throw on non-DB failure).
- Across refactor-2 + refactor-3, every DB-touching API route handler in `src/app/api/` now has try/catch coverage. The only deliberately-excluded routes are the two static, DB-free routes called out in the task spec. - Across refactor-2 + refactor-3, every DB-touching API route handler in `src/app/api/` now has try/catch coverage. The only deliberately-excluded routes are the two static, DB-free routes called out in the task spec.
- Next actions: none required. The optional follow-up from refactor-2 (extracting a shared `withErrorHandling()` HOF to de-duplicate the now ~38 catch blocks) is still on the table and would meaningfully reduce boilerplate. - Next actions: none required. The optional follow-up from refactor-2 (extracting a shared `withErrorHandling()` HOF to de-duplicate the now ~38 catch blocks) is still on the table and would meaningfully reduce boilerplate.
---
Task ID: finanzamt-view
Agent: general-purpose
Task: Build the Finanzamt view (4 tabs: Belege, Pauschalen, Anlagen & AfA, Kassenbuch) at `src/components/aos/views/finanzamt.tsx`.
Work Log:
- Created `src/components/aos/views/finanzamt.tsx` (~1250 lines). The view is named `FinanzamtView` and also exported as default. Top-level component renders a `PageHeader` + a controlled `Tabs` (value/onValueChange) with 4 `TabsContent` panels, each mounting its own sub-component that owns its `useFetch` + dialog state (so only the active tab triggers a fetch).
- **Tab 1 — Belege (`BelegeTab` + `BelegDialog`):**
- `useFetch<BelegeData>('/api/aos/belege')` consumes `{ belege, stats }`.
- 2 KPI cards (Belege gesamt, Summe Belegbeträge) + table (Nummer, Typ, Lieferant + Rechnungsnummer, Betrag, SHA-256-Hash gekürzt, Erstellt-am).
- "Beleg registrieren" dialog: optional file picker (read via `FileReader.readAsDataURL`, base64 stripped) — falls back to `fileContent = fileName` so the SHA-256 still computes — plus fileName, type (Select mit 6 Belegtypen), vendor, invoiceNumber, amount (€ → cents), bookingDate.
- Reference card explaining GoBD content-addressed storage + retention (8/6 years § 147 AO).
- **Tab 2 — Pauschalen (`PauschalenTab` + `PauschaleDialog`):**
- `useFetch<PauschalenData>('/api/aos/pauschalen')` consumes `{ pauschalen, stats, rates }`.
- 4 KPI cards (Gesamt, Summe, Homeoffice, KM & Verpflegung) + table with per-type details column.
- "Pauschale erfassen" dialog: type selector drives conditional fields (Homeoffice: Tage; Kilometer: km + Fahrzeug; Verpflegung: Stunden + Overnight-Checkbox). Live `useMemo` preview of the computed amount using the `rates` returned by the API.
- Reference card listing the 2026 rates (Homeoffice €6.92/d, KM €0.30/km, Verpflegung €14/€24/€28) with EStG § 4 Abs. 5 / § 9 Abs. 1 citations.
- **Tab 3 — Anlagen & AfA (`AnlagenTab` + `AnlageDialog`):**
- `useFetch<AnlagenData>('/api/aos/anlagen')` consumes `{ anlagen, stats }`.
- 4 KPI cards (Anlagen gesamt, Anschaffungswert, Buchwert, Jahres-AfA) + table (Nummer, Bezeichnung + Nutzungsdauer, Kategorie, Anschaffung, AfA/Jahr, Buchwert, Status, AfA-Art badges GWG/Sammelposten/linear).
- "Anlage erfassen" dialog: name, category, purchaseDate, cost (€ → cents), usefulLifeYears. Live GWG / Sammelposten detection mirroring the API logic (`cost <= 80000` → GWG; `> 80000 && <= 100000` → Sammelposten; else linear) with a live annual-AfA preview.
- Reference card: GWG § 6 Abs. 2 EStG, Sammelposten § 6 Abs. 2a EStG, lineare AfA. Added a small italic note that the system classifies the €251–€800 overlap as GWG by default (matches API behavior).
- **Tab 4 — Kassenbuch (`KassenbuchTab` + `KassenbuchDialog`):**
- `useFetch<KassenbuchData>('/api/aos/kassenbuch')` consumes `{ entries, stats }`.
- 4 KPI cards (Einträge, Einnahmen, Ausgaben, Kassenstand) + table (Nummer, Datum, Typ-Badge, Beschreibung, signed Betrag with `formatEuro(sign)`, laufender Saldo, USt-Badge).
- "Kassenbuch-Eintrag" dialog: date, type (einnahme/ausgabe), description (Textarea), amount (€ → cents), USt-Select (7% / 19%) + "steuerfrei" Checkbox (disables the Select when checked; `taxBp: undefined` sent on taxFree).
- Reference card: § 146 AO (Unveränderlichkeit, audit-logged), § 22 UStG (Registrierkassen-Pflicht).
- Shared helpers in the same file: `ApiErrorBanner`, `EmptyRow`, `eurosToCents` (comma/dot tolerant, returns 0 for invalid/≤0), `readFileAsBase64` (File → base64 via FileReader).
- All POST operations wrapped in `try { fetch… if (!r.ok) throw new Error(res.error) … toast.success } catch (err) { toast.error(err instanceof Error ? err.message : '…') } finally { setSubmitting(false) }`. All toasts use `toast.loading` → `toast.success`/`toast.error` with `{ id: t }` for in-place updates. `refetch()` is called after every successful write.
- Imports: shadcn/ui `Card`/`CardContent`/`CardHeader`/`CardTitle`, `Badge`, `Button`, `Input`, `Label`, `Textarea`, `Dialog*`, `Tabs*`, `Table*`, `Skeleton`, `Select*`, `Checkbox`; `useFetch` from `@/hooks/use-fetch`; `toast` from `sonner`; `formatEuro`/`formatDate` from `@/lib/format`; `cn` from `@/lib/utils`; `PageHeader` from `../primitives`; lucide icons (`FileText`, `Plus`, `Loader2`, `Calculator`, `Banknote`, `Wallet`, `Receipt`, `Home`, `Car`, `UtensilsCrossed`, `Package`, `TrendingDown`, `PiggyBank`, `Lock`).
- All UI labels in German; no `any` types — every API payload shape declared as an explicit interface; `Pauschale['type']` used as the key type for `PAUSCHALE_TYPE_META` so the map is exhaustive.
- Bonus 1-line fix in `src/app/api/aos/belege/route.ts:45`: added parens around the ternary in `setFullYear(getFullYear() + type.includes('rechnung') ? 8 : 6)` → `setFullYear(getFullYear() + (type.includes('rechnung') ? 8 : 6))`. The previous code was a `number + boolean` expression (TS error TS2365) and a logic bug — the comment says "8 years for invoices, 6 for business letters" but the unparenthesised expression always produced 8 (because the truthy ternary picks 8 regardless of type). Now matches the comment and resolves the only remaining `src/` TS error.
Stage Summary:
- `npx tsc --noEmit 2>&1 | grep "^src/" | wc -l` → **0** (was 1 before the belege-route fix; the finanzamt.tsx file itself introduced zero new errors).
- `bun run lint` → exit code **0**, no warnings/errors.
- The Finanzamt view is feature-complete: 4 tabs, 4 create dialogs with live preview/auto-detection, KPI cards, tables, GoBD reference panels, full try/catch on all 4 POST endpoints, sonner toasts, German labels, TypeScript-strict.
- Next actions: none required. Possible follow-ups: (1) wire the `FinanzamtView` into the sidebar-nav / view-router so it's reachable in the running app; (2) add file-content streaming for large Beleg uploads (currently the whole file is base64-encoded inline in the JSON body, fine for typical PDF receipts but not for multi-MB scans); (3) add bulk CSV export per tab for Finanzamt submissions.