Files
AOS/src/components/aos/invoice-dialog.tsx
T

217 lines
8.9 KiB
TypeScript
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.
'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>
)
}