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