bbb245ef-c23d-4d22-87ba-d5366d9def16
This commit is contained in:
1 parent
1573931c0f
commit
0d0fa6b5e1
3 files changed
+701
No files matched your search
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
@@ -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.
|
||||||
Reference in new issue
Block a user