- Fix all TypeScript compilation errors (0 errors in src/) - Add try/catch error handling to ALL API routes (38 handlers) - Fix empty catch blocks (api-auth.ts now logs warnings) - Add aria-labels to all icon-only buttons (WCAG 2.1 AA) - Fix Prisma type mismatches (OR filter, Person.companyId, Estimate.projectId, Ticket.createdAt) - Fix webhook PATCH route (moved to [id]/route.ts) - Fix mobile-bottom-nav type safety - Fix KB route where-clause type - Fix test-mail banner null vs undefined - Fix task comment fetch: add cancellation + error toast - ESLint: 0 errors, 0 warnings - 175 files, 24.741 lines of code
246 lines
10 KiB
TypeScript
Executable File
246 lines
10 KiB
TypeScript
Executable File
'use client'
|
|
|
|
import { useState } from 'react'
|
|
import {
|
|
Plus,
|
|
Receipt,
|
|
FileCheck2,
|
|
AlertTriangle,
|
|
Download,
|
|
FileText,
|
|
Euro,
|
|
Clock,
|
|
} from 'lucide-react'
|
|
import { useFetch } from '@/hooks/use-fetch'
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Skeleton } from '@/components/ui/skeleton'
|
|
import { PageHeader } from '../primitives'
|
|
import { InvoiceDetailDialog, type Invoice } from '../invoice-dialog'
|
|
import { formatEuro, formatDate, daysUntil } from '@/lib/format'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
interface FinanceData {
|
|
invoices: Invoice[]
|
|
stats: {
|
|
total: number
|
|
draft: number
|
|
sent: number
|
|
paid: number
|
|
overdue: number
|
|
zugferdCount: number
|
|
xrechnungCount: number
|
|
datevCount: number
|
|
outstandingCents: number
|
|
}
|
|
}
|
|
|
|
const STATUS_META: Record<string, { label: string; cls: string }> = {
|
|
draft: { label: 'Entwurf', cls: 'bg-muted text-foreground' },
|
|
sent: { label: 'Versendet', cls: 'bg-foreground/70 text-background' },
|
|
paid: { label: 'Bezahlt', cls: 'bg-foreground text-background' },
|
|
overdue: { label: 'Überfällig', cls: 'bg-foreground text-background' },
|
|
cancelled: { label: 'Storniert', cls: 'bg-muted text-muted-foreground' },
|
|
storno: { label: 'Storno', cls: 'bg-muted text-muted-foreground' },
|
|
}
|
|
|
|
const TYPE_LABELS: Record<string, string> = {
|
|
final: 'Schlussrechnung',
|
|
partial: 'Abschlag',
|
|
recurring: 'Wiederkehrend',
|
|
credit: 'Gutschrift',
|
|
}
|
|
|
|
export function FinanceView() {
|
|
const { data, loading, refetch } = useFetch<FinanceData>('/api/aos/finance')
|
|
const [selected, setSelected] = useState<Invoice | null>(null)
|
|
const [dialogOpen, setDialogOpen] = useState(false)
|
|
|
|
if (loading || !data) {
|
|
return (
|
|
<div className="space-y-6 p-4 md:p-6">
|
|
<Skeleton className="h-9 w-32" />
|
|
<Skeleton className="h-48" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const { invoices, stats } = data
|
|
|
|
return (
|
|
<div className="space-y-6 p-4 md:p-6">
|
|
<PageHeader
|
|
title="Finanzen"
|
|
description="Rechnungen mit lückenloser Nummerierung, E-Rechnung (ZUGFeRD/XRechnung), Mahnwesen und DATEV-Export."
|
|
actions={
|
|
<Button size="sm">
|
|
<Plus className="size-4" />
|
|
Rechnung
|
|
</Button>
|
|
}
|
|
/>
|
|
|
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
|
<Card className="p-4">
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-xs text-muted-foreground">Offene Forderungen</p>
|
|
<Euro className="size-3.5 text-muted-foreground" />
|
|
</div>
|
|
<p className="mt-1 text-2xl font-semibold tabular-nums">{formatEuro(stats.outstandingCents)}</p>
|
|
</Card>
|
|
<Card className="p-4">
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-xs text-muted-foreground">Rechnungen gesamt</p>
|
|
<Receipt className="size-3.5 text-muted-foreground" />
|
|
</div>
|
|
<p className="mt-1 text-2xl font-semibold tabular-nums">{stats.total}</p>
|
|
<p className="text-[10px] text-muted-foreground">{stats.draft} Entwurf · {stats.overdue} überfällig</p>
|
|
</Card>
|
|
<Card className="p-4">
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-xs text-muted-foreground">ZUGFeRD / XRechnung</p>
|
|
<FileCheck2 className="size-3.5 text-muted-foreground" />
|
|
</div>
|
|
<p className="mt-1 text-2xl font-semibold tabular-nums">{stats.zugferdCount} / {stats.xrechnungCount}</p>
|
|
<p className="text-[10px] text-muted-foreground">E-Rechnung-konform</p>
|
|
</Card>
|
|
<Card className="p-4">
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-xs text-muted-foreground">DATEV exportiert</p>
|
|
<Download className="size-3.5 text-muted-foreground" />
|
|
</div>
|
|
<p className="mt-1 text-2xl font-semibold tabular-nums">{stats.datevCount}</p>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Compliance notice */}
|
|
<div className="flex flex-col gap-2 rounded-md border border-border bg-muted/30 p-3 text-xs sm:flex-row sm:items-center sm:justify-between">
|
|
<div className="flex items-start gap-2">
|
|
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
|
<div>
|
|
<p className="font-medium">E-Rechnung-Pflicht (ab 2025 Empfang, ab 2027/2028 Versand je nach Umsatz).</p>
|
|
<p className="text-muted-foreground">Validierung gem. EN 16931 über <code className="rounded bg-muted px-1 py-0.5 font-mono text-[10px]">@e-invoice-eu/core</code>. GoBD: Rechnungen 8 Jahre, Geschäftsbriefe 6 Jahre (mit Steuerberater bestätigen).</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm">Rechnungen</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="p-0">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead className="border-y border-border bg-muted/40 text-left text-[11px] uppercase tracking-wide text-muted-foreground">
|
|
<tr>
|
|
<th className="px-4 py-2 font-medium">Nr.</th>
|
|
<th className="px-4 py-2 font-medium">Kunde</th>
|
|
<th className="px-4 py-2 font-medium">Typ</th>
|
|
<th className="px-4 py-2 font-medium">Datum</th>
|
|
<th className="px-4 py-2 font-medium">Fällig</th>
|
|
<th className="px-4 py-2 font-medium text-right">Netto</th>
|
|
<th className="px-4 py-2 font-medium text-right">Brutto</th>
|
|
<th className="px-4 py-2 font-medium">E-Rechnung</th>
|
|
<th className="px-4 py-2 font-medium">Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-border">
|
|
{invoices.map((inv) => {
|
|
const st = STATUS_META[inv.status] ?? STATUS_META.draft
|
|
const dueDays = daysUntil(inv.dueDate)
|
|
return (
|
|
<tr
|
|
key={inv.id}
|
|
className="cursor-pointer transition hover:bg-muted/40"
|
|
onClick={() => { setSelected(inv); setDialogOpen(true) }}
|
|
>
|
|
<td className="px-4 py-2.5">
|
|
<code className="font-mono text-[11px]">{inv.number}</code>
|
|
</td>
|
|
<td className="px-4 py-2.5 text-xs">{inv.company.name}</td>
|
|
<td className="px-4 py-2.5 text-xs text-muted-foreground">{TYPE_LABELS[inv.type] ?? inv.type}</td>
|
|
<td className="px-4 py-2.5 text-xs">{formatDate(inv.issueDate)}</td>
|
|
<td className="px-4 py-2.5 text-xs">
|
|
{formatDate(inv.dueDate)}
|
|
{inv.status === 'sent' && dueDays < 0 && (
|
|
<span className="ml-1 text-[10px] text-foreground">({Math.abs(dueDays)} d überfällig)</span>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-2.5 text-right text-xs tabular-nums">{formatEuro(inv.netCents)}</td>
|
|
<td className="px-4 py-2.5 text-right text-xs font-semibold tabular-nums">{formatEuro(inv.grossCents)}</td>
|
|
<td className="px-4 py-2.5">
|
|
<div className="flex gap-1">
|
|
{inv.zugferd && (
|
|
<Badge variant="outline" className="text-[9px]">ZUGFeRD</Badge>
|
|
)}
|
|
{inv.xrechnung && (
|
|
<Badge variant="outline" className="text-[9px]">XRechnung</Badge>
|
|
)}
|
|
{inv.datevExported && (
|
|
<Badge variant="secondary" className="text-[9px]">DATEV</Badge>
|
|
)}
|
|
{!inv.zugferd && !inv.xrechnung && !inv.datevExported && (
|
|
<span className="text-[10px] text-muted-foreground">—</span>
|
|
)}
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-2.5">
|
|
<Badge variant="outline" className={cn('text-[10px]', st.cls)}>
|
|
{st.label}
|
|
</Badge>
|
|
</td>
|
|
</tr>
|
|
)
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Gapless numbering + finalize info */}
|
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
<Card>
|
|
<CardContent className="p-4">
|
|
<div className="flex items-start gap-3">
|
|
<div className="flex size-9 items-center justify-center rounded-md border border-border">
|
|
<FileText className="size-4 text-muted-foreground" />
|
|
</div>
|
|
<div className="flex-1">
|
|
<p className="text-sm font-medium">Lückenlose Nummerierung</p>
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
RE-2026-0042 → RE-2026-0043 … GoBD-konform, kein Löschen, Korrekturen als Storno (neue Zeile).
|
|
Finalisierungs-Sperre verhindert Nachträgliches Editieren.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardContent className="p-4">
|
|
<div className="flex items-start gap-3">
|
|
<div className="flex size-9 items-center justify-center rounded-md border border-border">
|
|
<Clock className="size-4 text-muted-foreground" />
|
|
</div>
|
|
<div className="flex-1">
|
|
<p className="text-sm font-medium">Time-to-Invoice: 0 manuelle Schritte</p>
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
Freigegebene Zeiteinträge werden direkt zu Rechnungspositionen. Quote → Projekt-Budget → Rechnungsplan → DATEV-Export.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
<InvoiceDetailDialog
|
|
invoice={selected}
|
|
open={dialogOpen}
|
|
onOpenChange={setDialogOpen}
|
|
onFinalized={refetch}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|