Files
AOS/tool-results/read_1793121909886_c9de18dc46a4.txt
T
Z User e45b68bb8b feat: IMAP mail sync with thread-to-contact matching (PRD §6.1)
Real IMAP connection via imapflow library:
- Connects to IMAP server (IMAPFlow, TLS on port 993)
- Fetches recent messages (configurable limit, default 20)
- Extracts: from, fromEmail, subject, date, snippet (200 chars)
- Thread-to-contact matching (PRD §6.1):
  1. Match sender email against persons table
  2. If no person match, extract domain and match against company.website
  3. Returns matchedCompany + matchedPerson for each email
- Audit-logged (action: crm.mailsync:imap)
- Zod validated (host, port, user, password, folder, limit)
- 502 on IMAP connection failure with detail
- 400 on validation failure

Tested: invalid creds → 502 'IMAP connection failed', missing host → 400
2026-10-27 22:50:50 +00:00

662 lines
39 KiB
Plaintext
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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→