217 lines
8.9 KiB
TypeScript
217 lines
8.9 KiB
TypeScript
'use client'
|
||
|
||
import { useState } from 'react'
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from '@/components/ui/dialog'
|
||
import { Badge } from '@/components/ui/badge'
|
||
import { Button } from '@/components/ui/button'
|
||
import { Separator } from '@/components/ui/separator'
|
||
import { Loader2, FileCheck2, Lock, ShieldCheck } from 'lucide-react'
|
||
import { formatEuro, formatDate, daysUntil } from '@/lib/format'
|
||
import { toast } from 'sonner'
|
||
import { cn } from '@/lib/utils'
|
||
|
||
export interface Invoice {
|
||
id: string
|
||
number: string
|
||
status: string
|
||
type: string
|
||
issueDate: string
|
||
dueDate: string
|
||
paidDate: string | null
|
||
netCents: number
|
||
taxBp: number
|
||
grossCents: number
|
||
zugferd: boolean
|
||
xrechnung: boolean
|
||
datevExported: boolean
|
||
company: { id: string; name: string }
|
||
lines: { id: string; description: string; quantity: number; unitCents: number; totalCents: 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: 'Abschlagszahlung',
|
||
recurring: 'Wiederkehrend',
|
||
credit: 'Gutschrift',
|
||
}
|
||
|
||
interface Props {
|
||
invoice: Invoice | null
|
||
open: boolean
|
||
onOpenChange: (o: boolean) => void
|
||
onFinalized: () => void
|
||
}
|
||
|
||
export function InvoiceDetailDialog({ invoice, open, onOpenChange, onFinalized }: Props) {
|
||
const [finalizing, setFinalizing] = useState(false)
|
||
const [wantXrechnung, setWantXrechnung] = useState(false)
|
||
|
||
if (!invoice) return null
|
||
const st = STATUS_META[invoice.status] ?? STATUS_META.draft
|
||
const dueDays = daysUntil(invoice.dueDate)
|
||
const isDraft = invoice.status === 'draft'
|
||
|
||
const finalize = async () => {
|
||
setFinalizing(true)
|
||
const t = toast.loading('Rechnung wird finalisiert (E-Rechnung generiert)…')
|
||
try {
|
||
const r = await fetch(`/api/aos/finance/${invoice.id}/finalize`, {
|
||
method: 'PATCH',
|
||
headers: { 'content-type': 'application/json' },
|
||
body: JSON.stringify({ zugferd: true, xrechnung: wantXrechnung }),
|
||
})
|
||
if (!r.ok) throw new Error('finalize failed')
|
||
toast.success('Rechnung finalisiert und versendet', { id: t })
|
||
onOpenChange(false)
|
||
onFinalized()
|
||
} catch {
|
||
toast.error('Finalisierung fehlgeschlagen', { id: t })
|
||
} finally {
|
||
setFinalizing(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||
<DialogContent className="sm:max-w-2xl">
|
||
<DialogHeader>
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div>
|
||
<DialogTitle className="flex items-center gap-2 font-mono">
|
||
{invoice.number}
|
||
</DialogTitle>
|
||
<DialogDescription className="mt-1">
|
||
{invoice.company.name} · {TYPE_LABELS[invoice.type] ?? invoice.type}
|
||
</DialogDescription>
|
||
</div>
|
||
<Badge variant="outline" className={cn('text-[10px]', st.cls)}>{st.label}</Badge>
|
||
</div>
|
||
</DialogHeader>
|
||
|
||
<div className="space-y-4">
|
||
{/* Meta */}
|
||
<div className="grid grid-cols-2 gap-3 text-xs sm:grid-cols-4">
|
||
<Meta label="Rechnungsdatum" value={formatDate(invoice.issueDate)} />
|
||
<Meta label="Fällig" value={formatDate(invoice.dueDate)} sub={dueDays < 0 && invoice.status === 'sent' ? `${Math.abs(dueDays)} d überfällig` : undefined} />
|
||
<Meta label="Bezahlt am" value={invoice.paidDate ? formatDate(invoice.paidDate) : '—'} />
|
||
<Meta label="Steuersatz" value={`${(invoice.taxBp / 100).toFixed(0)} %`} />
|
||
</div>
|
||
|
||
{/* Lines */}
|
||
<div className="rounded-md border border-border">
|
||
<div className="border-b border-border bg-muted/40 px-3 py-2 text-[10px] uppercase tracking-wide text-muted-foreground">
|
||
Rechnungspositionen ({invoice.lines.length})
|
||
</div>
|
||
<div className="divide-y divide-border">
|
||
{invoice.lines.map((l) => (
|
||
<div key={l.id} className="flex items-center justify-between gap-3 px-3 py-2.5">
|
||
<div className="min-w-0">
|
||
<p className="truncate text-xs font-medium">{l.description}</p>
|
||
<p className="text-[10px] text-muted-foreground">{l.quantity} × {formatEuro(l.unitCents)}</p>
|
||
</div>
|
||
<p className="shrink-0 text-xs font-semibold tabular-nums">{formatEuro(l.totalCents)}</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<Separator />
|
||
<div className="space-y-1 px-3 py-2.5 text-xs">
|
||
<Row label="Netto" value={formatEuro(invoice.netCents)} />
|
||
<Row label={`USt (${(invoice.taxBp / 100).toFixed(0)} %)`} value={formatEuro(invoice.grossCents - invoice.netCents)} />
|
||
<Separator className="my-1" />
|
||
<Row label="Brutto" value={formatEuro(invoice.grossCents)} strong />
|
||
</div>
|
||
</div>
|
||
|
||
{/* E-Rechnung status */}
|
||
<div className="rounded-md border border-border bg-muted/20 p-3">
|
||
<div className="flex items-center gap-2">
|
||
<FileCheck2 className="size-4 text-muted-foreground" />
|
||
<p className="text-xs font-semibold">E-Rechnung-Status</p>
|
||
</div>
|
||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||
<Badge variant={invoice.zugferd ? 'default' : 'outline'} className="text-[10px]">ZUGFeRD {invoice.zugferd ? '✓' : '—'}</Badge>
|
||
<Badge variant={invoice.xrechnung ? 'default' : 'outline'} className="text-[10px]">XRechnung {invoice.xrechnung ? '✓' : '—'}</Badge>
|
||
<Badge variant={invoice.datevExported ? 'default' : 'outline'} className="text-[10px]">DATEV {invoice.datevExported ? '✓' : '—'}</Badge>
|
||
</div>
|
||
<p className="mt-2 text-[10px] text-muted-foreground">
|
||
EN 16931 validiert via <code className="rounded bg-muted px-1 py-0.5 font-mono">@e-invoice-eu/core</code>. GoBD: Finalisierung sperrt nachträgliches Editieren (Storno via neue Zeile).
|
||
</p>
|
||
</div>
|
||
|
||
{/* Dunning */}
|
||
{invoice.status === 'overdue' && (
|
||
<div className="rounded-md border border-foreground/30 bg-foreground/5 p-3 text-xs">
|
||
<p className="font-medium">Mahnstufe 1 fällig</p>
|
||
<p className="mt-0.5 text-muted-foreground">Überfällig seit {Math.abs(dueDays)} Tagen. Automatisches Mahnwesen konfigurierbar (3 Stufen → Suspendierung verknüpfter Lizenz).</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<DialogFooter className="gap-2">
|
||
{isDraft ? (
|
||
<>
|
||
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||
<input
|
||
type="checkbox"
|
||
checked={wantXrechnung}
|
||
onChange={(e) => setWantXrechnung(e.target.checked)}
|
||
className="size-3.5 rounded border-border"
|
||
/>
|
||
XRechnung (öffentlicher Kunde)
|
||
</label>
|
||
<Button variant="outline" onClick={() => onOpenChange(false)}>Abbrechen</Button>
|
||
<Button onClick={finalize} disabled={finalizing} className="gap-1.5">
|
||
{finalizing ? <Loader2 className="size-3.5 animate-spin" /> : <Lock className="size-3.5" />}
|
||
Finalisieren & versenden
|
||
</Button>
|
||
</>
|
||
) : (
|
||
<>
|
||
<div className="mr-auto flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
||
<ShieldCheck className="size-3.5" />
|
||
Finalisiert · lückenlos nummeriert
|
||
</div>
|
||
<Button variant="outline" onClick={() => onOpenChange(false)}>Schließen</Button>
|
||
</>
|
||
)}
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
)
|
||
}
|
||
|
||
function Meta({ label, value, sub }: { label: string; value: string; sub?: string }) {
|
||
return (
|
||
<div>
|
||
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">{label}</p>
|
||
<p className="mt-0.5 text-xs font-medium">{value}</p>
|
||
{sub && <p className="text-[10px] text-foreground">{sub}</p>}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function Row({ label, value, strong }: { label: string; value: string; strong?: boolean }) {
|
||
return (
|
||
<div className={cn('flex items-center justify-between', strong && 'font-semibold')}>
|
||
<span className="text-muted-foreground">{label}</span>
|
||
<span className="tabular-nums">{value}</span>
|
||
</div>
|
||
)
|
||
}
|