Files
AOS/src/components/aos/views/estimates.tsx
T
Z User fa8dd46571 refactor: professional code quality audit
- 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
2026-10-27 12:23:32 +00:00

370 lines
16 KiB
TypeScript
Executable File
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 {
FileText,
Plus,
Check,
X,
ArrowRight,
Loader2,
TrendingUp,
Percent,
} 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 { Separator } from '@/components/ui/separator'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { PageHeader } from '../primitives'
import { formatEuro, formatEuroCompact, formatDate, daysUntil } from '@/lib/format'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
interface EstimateLine { id: string; description: string; quantity: number; unitCents: number; totalCents: number }
interface Estimate {
id: string
number: string
companyId: string
companyName: string
subject: string
status: string
validUntil: string | null
issueDate: string
acceptedAt: string | null
acceptedBy: string | null
notes: string | null
netCents: number
taxBp: number
grossCents: number
lines: EstimateLine[]
projectId?: string | null
}
interface EstimatesData {
estimates: Estimate[]
stats: {
total: number
draft: number
sent: number
accepted: number
rejected: number
acceptedValueCents: number
pendingValueCents: number
winRate: number
}
}
const STATUS_META: Record<string, { label: string; cls: string }> = {
draft: { label: 'Entwurf', cls: 'bg-muted text-foreground' },
sent: { label: 'Versendet', cls: 'bg-foreground/50 text-background' },
accepted: { label: 'Akzeptiert', cls: 'bg-foreground text-background' },
rejected: { label: 'Abgelehnt', cls: 'bg-muted text-muted-foreground' },
expired: { label: 'Abgelaufen', cls: 'bg-muted text-muted-foreground' },
}
export function EstimatesView() {
const { data, loading, refetch } = useFetch<EstimatesData>('/api/aos/estimates')
const [selected, setSelected] = useState<Estimate | null>(null)
const [dialogOpen, setDialogOpen] = useState(false)
const [action, setAction] = useState<'accept' | 'convert' | null>(null)
const [busy, setBusy] = 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-40" />
</div>
)
}
const { estimates, stats } = data
const openDetail = (e: Estimate) => {
setSelected(e)
setDialogOpen(true)
}
const accept = async (estimate: Estimate) => {
setBusy(true)
const t = toast.loading('Angebot wird akzeptiert (Projekt wird angelegt)…')
try {
const r = await fetch(`/api/aos/estimates/${estimate.id}/accept`, {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ acceptedBy: estimate.companyName + ' (Portal)' }),
})
if (!r.ok) {
const err = await r.json().catch(() => ({}))
throw new Error(err.error || 'accept failed')
}
const data = await r.json()
toast.success(`Akzeptiert — Projekt angelegt`, { id: t })
setDialogOpen(false)
refetch()
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Akzeptanz fehlgeschlagen', { id: t })
} finally {
setBusy(false)
}
}
const convert = async (estimate: Estimate) => {
setBusy(true)
const t = toast.loading('Rechnung wird aus Angebot erstellt…')
try {
const r = await fetch(`/api/aos/estimates/${estimate.id}/convert`, { method: 'POST' })
if (!r.ok) {
const err = await r.json().catch(() => ({}))
throw new Error(err.error || 'convert failed')
}
const data = await r.json()
toast.success(`Rechnung ${data.invoice.number} erstellt (${formatEuro(data.invoice.grossCents)})`, { id: t })
setDialogOpen(false)
refetch()
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Umwandlung fehlgeschlagen', { id: t })
} finally {
setBusy(false)
}
}
return (
<div className="space-y-6 p-4 md:p-6">
<PageHeader
title="Angebote"
description="Quotes mit Positionen, Gültigkeit, Kunden-Akzeptanz via Portal. Akzeptiertes Angebot → Projekt + Rechnung (lead → deal → quote → project → invoice)."
actions={
<Button size="sm">
<Plus className="size-4" />
Angebot
</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">Offen (versendet)</p>
<FileText className="size-3.5 text-muted-foreground" />
</div>
<p className="mt-1 text-2xl font-semibold tabular-nums">{stats.sent}</p>
<p className="text-[10px] text-muted-foreground">{formatEuroCompact(stats.pendingValueCents)} Volumen</p>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">Akzeptiert</p>
<Check className="size-3.5 text-muted-foreground" />
</div>
<p className="mt-1 text-2xl font-semibold tabular-nums">{stats.accepted}</p>
<p className="text-[10px] text-muted-foreground">{formatEuroCompact(stats.acceptedValueCents)} Volumen</p>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">Win-Rate</p>
<Percent className="size-3.5 text-muted-foreground" />
</div>
<p className="mt-1 text-2xl font-semibold tabular-nums">{stats.winRate}<span className="text-sm text-muted-foreground">%</span></p>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">Gesamt</p>
<TrendingUp className="size-3.5 text-muted-foreground" />
</div>
<p className="mt-1 text-2xl font-semibold tabular-nums">{stats.total}</p>
</Card>
</div>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Angebote ({estimates.length})</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">Betreff</th>
<th className="px-4 py-2 font-medium">Kunde</th>
<th className="px-4 py-2 font-medium">Datum</th>
<th className="px-4 py-2 font-medium">Gültig bis</th>
<th className="px-4 py-2 font-medium text-right">Brutto</th>
<th className="px-4 py-2 font-medium">Status</th>
<th className="px-4 py-2 font-medium">Aktion</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{estimates.map((e) => {
const st = STATUS_META[e.status] ?? STATUS_META.draft
const valid = e.validUntil ? daysUntil(e.validUntil) : null
const expiring = valid !== null && valid < 0 && e.status === 'sent'
return (
<tr key={e.id} className="cursor-pointer transition hover:bg-muted/40" onClick={() => openDetail(e)}>
<td className="px-4 py-2.5"><code className="font-mono text-[11px]">{e.number}</code></td>
<td className="px-4 py-2.5 text-xs font-medium">{e.subject}</td>
<td className="px-4 py-2.5 text-xs text-muted-foreground">{e.companyName}</td>
<td className="px-4 py-2.5 text-xs">{formatDate(e.issueDate)}</td>
<td className="px-4 py-2.5 text-xs">
{e.validUntil ? (
<span className={cn(expiring && 'text-foreground')}>
{formatDate(e.validUntil)}
{valid !== null && e.status === 'sent' && (
<span className="ml-1 text-[10px] text-muted-foreground">
{valid < 0 ? `(${Math.abs(valid)} d abgelaufen)` : `(${valid} d)`}
</span>
)}
</span>
) : '—'}
</td>
<td className="px-4 py-2.5 text-right text-xs font-semibold tabular-nums">{formatEuro(e.grossCents)}</td>
<td className="px-4 py-2.5">
<Badge variant="outline" className={cn('text-[10px]', st.cls)}>{st.label}</Badge>
</td>
<td className="px-4 py-2.5" onClick={(e2) => e2.stopPropagation()}>
{e.status === 'sent' ? (
<Button size="sm" variant="outline" className="h-7 gap-1 text-[11px]" onClick={() => accept(e)}>
<Check className="size-3" />Akzeptieren
</Button>
) : e.status === 'accepted' ? (
<Button size="sm" variant="outline" className="h-7 gap-1 text-[11px]" onClick={() => convert(e)}>
<ArrowRight className="size-3" />Zur Rechnung
</Button>
) : (
<span className="text-[10px] text-muted-foreground">—</span>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</CardContent>
</Card>
{/* Detail dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-2xl">
{selected && (() => {
const st = STATUS_META[selected.status] ?? STATUS_META.draft
return (
<>
<DialogHeader>
<div className="flex items-start justify-between gap-3">
<div>
<DialogTitle className="font-mono">{selected.number}</DialogTitle>
<DialogDescription className="mt-1">{selected.subject}</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-3">
<Meta label="Kunde" value={selected.companyName} />
<Meta label="Ausgestellt" value={formatDate(selected.issueDate)} />
<Meta label="Gültig bis" value={selected.validUntil ? formatDate(selected.validUntil) : '—'} />
{selected.acceptedAt && <Meta label="Akzeptiert am" value={formatDate(selected.acceptedAt)} />}
{selected.acceptedBy && <Meta label="Akzeptiert von" value={selected.acceptedBy} />}
</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">
Positionen ({selected.lines.length})
</div>
<div className="divide-y divide-border">
{selected.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">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Netto</span>
<span className="tabular-nums">{formatEuro(selected.netCents)}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">USt ({(selected.taxBp / 100).toFixed(0)} %)</span>
<span className="tabular-nums">{formatEuro(selected.grossCents - selected.netCents)}</span>
</div>
<Separator className="my-1" />
<div className="flex items-center justify-between font-semibold">
<span>Brutto</span>
<span className="tabular-nums">{formatEuro(selected.grossCents)}</span>
</div>
</div>
</div>
{selected.notes && (
<div className="rounded-md border border-border p-3">
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">Notizen</p>
<p className="mt-1 text-xs">{selected.notes}</p>
</div>
)}
{/* Chain indicator */}
<div className="rounded-md border border-dashed border-border p-3 text-[11px] text-muted-foreground">
<p className="font-medium text-foreground">Lead → Deal → Angebot → Projekt → Rechnung</p>
<p className="mt-0.5">
{selected.status === 'accepted' && selected.projectId
? '✓ Akzeptiert — Projekt wurde aus diesem Angebot angelegt.'
: selected.status === 'sent'
? 'Wartet auf Kunden-Akzeptanz via Portal.'
: selected.status === 'rejected'
? 'Abgelehnt — ggf. neues, reduziertes Angebot erstellen.'
: 'Entwurf — noch nicht versendet.'}
</p>
</div>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => setDialogOpen(false)}>Schließen</Button>
{selected.status === 'sent' && (
<Button onClick={() => accept(selected)} disabled={busy} className="gap-1.5">
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Check className="size-3.5" />}
Akzeptieren (→ Projekt)
</Button>
)}
{selected.status === 'accepted' && (
<Button onClick={() => convert(selected)} disabled={busy} className="gap-1.5">
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <ArrowRight className="size-3.5" />}
Zur Rechnung
</Button>
)}
</DialogFooter>
</>
)
})()}
</DialogContent>
</Dialog>
</div>
)
}
function Meta({ label, value }: { label: string; value: 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>
</div>
)
}