e70ae79d-5898-4c55-8ceb-d2c701ce08b4
This commit is contained in:
1 parent
d226dabba3
commit
5855f699f3
53 files changed
+1911
-44
No files matched your search
@@ -0,0 +1,216 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user