- 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
370 lines
16 KiB
TypeScript
Executable File
370 lines
16 KiB
TypeScript
Executable File
'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>
|
||
)
|
||
}
|