5b01aa3d-f0b2-4a70-9e57-f4fd90682497

This commit is contained in:
Z User committed 2026-10-26 15:33:24 +00:00
1 parent d4c3b0f5ca
commit 34bd721ab1
23 files changed
+1461 -3

No files matched your search

+4
View File
@@ -10,7 +10,9 @@ import { Sheet, SheetContent, SheetTitle } from '@/components/ui/sheet'
import { DashboardView } from './views/dashboard'
import { CrmView } from './views/crm'
import { LeadsView } from './views/leads'
import { EstimatesView } from './views/estimates'
import { ProjectsView } from './views/projects'
import { SupportView } from './views/support'
import { LicensesView } from './views/licenses'
import { ContractsView } from './views/contracts'
import { ExpensesView } from './views/expenses'
@@ -45,7 +47,9 @@ export function AppShell() {
{view === 'dashboard' && <DashboardView />}
{view === 'crm' && <CrmView />}
{view === 'leads' && <LeadsView />}
{view === 'estimates' && <EstimatesView />}
{view === 'projects' && <ProjectsView />}
{view === 'support' && <SupportView />}
{view === 'licenses' && <LicensesView />}
{view === 'contracts' && <ContractsView />}
{view === 'expenses' && <ExpensesView />}
+4
View File
@@ -16,6 +16,8 @@ import {
ArrowRight,
FileSignature,
Wallet,
FileText,
LifeBuoy,
} from 'lucide-react'
import { useAos, type ViewId } from '@/store/aos'
import {
@@ -33,7 +35,9 @@ const VIEWS: { id: ViewId; label: string; icon: typeof LayoutDashboard; hint: st
{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard, hint: 'Übersicht & KPIs' },
{ id: 'crm', label: 'CRM', icon: Users, hint: 'Firmen & Pipeline' },
{ id: 'leads', label: 'Leads Finder', icon: Radar, hint: 'Review-Queue' },
{ id: 'estimates', label: 'Angebote', icon: FileText, hint: 'Quotes & Conversion' },
{ id: 'projects', label: 'Projekte', icon: FolderKanban, hint: 'Aufgaben & Zeit' },
{ id: 'support', label: 'Support', icon: LifeBuoy, hint: 'Tickets & SLA' },
{ id: 'licenses', label: 'Lizenzen', icon: KeyRound, hint: 'Ausgestellt & Erneuerungen' },
{ id: 'contracts', label: 'Verträge', icon: FileSignature, hint: 'Werkvertrag, AVV, NDA' },
{ id: 'expenses', label: 'Ausgaben', icon: Wallet, hint: 'Belastbar & DATEV' },
+7 -1
View File
@@ -14,6 +14,8 @@ import {
ScrollText,
FileSignature,
Wallet,
FileText,
LifeBuoy,
} from 'lucide-react'
import { useAos, type ViewId } from '@/store/aos'
import { cn } from '@/lib/utils'
@@ -33,11 +35,15 @@ const NAV: {
items: [
{ id: 'crm', label: 'CRM', icon: Users, permission: 'crm.company:read' },
{ id: 'leads', label: 'Leads Finder', icon: Radar, permission: 'leads:read' },
{ id: 'estimates', label: 'Angebote', icon: FileText, permission: 'estimates:read' },
],
},
{
group: 'Lieferung',
items: [{ id: 'projects', label: 'Projekte', icon: FolderKanban, permission: 'projects:read' }],
items: [
{ id: 'projects', label: 'Projekte', icon: FolderKanban, permission: 'projects:read' },
{ id: 'support', label: 'Support', icon: LifeBuoy, permission: 'support:read' },
],
},
{
group: 'Betrieb',
+2
View File
@@ -19,7 +19,9 @@ const TITLES: Record<ViewId, { title: string; crumb: string }> = {
dashboard: { title: 'Dashboard', crumb: 'Übersicht' },
crm: { title: 'CRM', crumb: 'Vertrieb / CRM' },
leads: { title: 'Leads Finder', crumb: 'Vertrieb / Leads' },
estimates: { title: 'Angebote', crumb: 'Vertrieb / Angebote' },
projects: { title: 'Projekte', crumb: 'Lieferung / Projekte' },
support: { title: 'Support', crumb: 'Lieferung / Tickets' },
licenses: { title: 'Lizenzen', crumb: 'Betrieb / Lizenzen' },
contracts: { title: 'Verträge', crumb: 'Betrieb / Verträge' },
expenses: { title: 'Ausgaben', crumb: 'Betrieb / Ausgaben' },
+368
View File
@@ -0,0 +1,368 @@
'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[]
}
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>
)
}
+2 -1
View File
@@ -40,9 +40,10 @@ const MODULE_META: Record<string, { label: string; desc: string; phase: string }
licenses: { label: 'Lizenzverwaltung', desc: 'Ausgestellte Lizenzen + Erneuerungsregister.', phase: 'Phase 4' },
contracts: { label: 'Verträge', desc: 'Werkvertrag, AVV (Art. 28 DSGVO), NDA, SLA mit E-Signatur.', phase: 'Phase 5' },
expenses: { label: 'Ausgaben', desc: 'Belastbare Posten, Wiederkehrendes, DATEV-Export.', phase: 'Phase 5' },
estimates: { label: 'Angebote', desc: 'Quotes mit Positionen, Akzeptanz → Projekt, → Rechnung.', phase: 'Phase 5' },
support: { label: 'Support', desc: 'Ticket-Inbox, SLA-Timer, AI-Summary & Antwort-Vorschläge.', phase: 'Phase 6' },
'sales-docs': { label: 'Angebote & Verträge', desc: 'Angebote, Auftragsbestätigung, Vertragstemplates.', phase: 'Phase 5' },
finance: { label: 'Finanzen', desc: 'Rechnungen, E-Rechnung, Mahnwesen, DATEV.', phase: 'Phase 5' },
support: { label: 'Support', desc: 'Ticket-Inbox, SLA-Timer (geplant).', phase: 'Later' },
reporting: { label: 'Reporting', desc: 'Dashboards: Pipeline, Auslastung, MRR (geplant).', phase: 'Later' },
}
+360
View File
@@ -0,0 +1,360 @@
'use client'
import { useState } from 'react'
import {
LifeBuoy,
Plus,
Sparkles,
AlertTriangle,
Clock,
CheckCircle2,
Loader2,
Send,
MessageSquare,
} 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 { ScrollArea } from '@/components/ui/scroll-area'
import { Textarea } from '@/components/ui/textarea'
import { PageHeader } from '../primitives'
import { formatRelative, formatDateTime } from '@/lib/format'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
interface TicketMsg { id: string; fromType: string; fromName: string; body: string; createdAt: string }
interface Ticket {
id: string
number: string
subject: string
status: string
priority: string
categoryId: string
companyName: string
assignee: string | null
aiSummary: string | null
messages: TicketMsg[]
sla: {
responseHours: number
resolveHours: number
respondedAt: string | null
resolvedAt: string | null
responseBreached: boolean
resolveBreached: boolean
ageHours: number
}
}
interface TicketsData {
tickets: Ticket[]
stats: {
total: number
open: number
pending: number
resolved: number
closed: number
urgent: number
slaBreached: number
unresponded: number
}
}
const STATUS_META: Record<string, { label: string; cls: string }> = {
open: { label: 'Offen', cls: 'bg-foreground text-background' },
pending: { label: 'Wartend', cls: 'bg-foreground/50 text-background' },
resolved: { label: 'Gelöst', cls: 'bg-muted text-foreground' },
closed: { label: 'Geschlossen', cls: 'bg-muted text-muted-foreground' },
}
const PRIORITY_META: Record<string, { label: string; cls: string }> = {
low: { label: 'Niedrig', cls: 'border-border' },
normal: { label: 'Normal', cls: 'border-foreground/30' },
high: { label: 'Hoch', cls: 'border-foreground/50' },
urgent: { label: 'Dringend', cls: 'border-foreground bg-foreground/5' },
}
const CATEGORY_LABELS: Record<string, string> = {
bug: 'Bug',
anfrage: 'Anfrage',
aenderung: 'Änderung',
konto: 'Konto',
rechnung: 'Rechnung',
hosting: 'Hosting',
}
export function SupportView() {
const { data, loading, refetch } = useFetch<TicketsData>('/api/aos/tickets')
const [selectedId, setSelectedId] = useState<string | null>(null)
const [aiBusy, setAiBusy] = useState<'summary' | 'reply' | null>(null)
const [replyDraft, setReplyDraft] = useState('')
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 { tickets, stats } = data
const selected = tickets.find((t) => t.id === selectedId) ?? tickets[0] ?? null
const summarize = async (ticket: Ticket) => {
setAiBusy('summary')
const t = toast.loading('AI fasst das Ticket zusammen…')
try {
const r = await fetch(`/api/aos/tickets/${ticket.id}/summarize`, { method: 'POST' })
if (!r.ok) throw new Error('summarize failed')
toast.success('Zusammenfassung generiert', { id: t })
refetch()
} catch {
toast.error('Zusammenfassung fehlgeschlagen', { id: t })
} finally {
setAiBusy(null)
}
}
const suggestReply = async (ticket: Ticket, tone: 'polite' | 'formal' | 'friendly' = 'polite') => {
setAiBusy('reply')
const t = toast.loading('AI schlägt eine Antwort vor…')
try {
const r = await fetch(`/api/aos/tickets/${ticket.id}/reply`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ tone }),
})
if (!r.ok) throw new Error('reply failed')
const data = await r.json()
setReplyDraft(data.suggestion)
toast.success('Antwort-Entwurf generiert ( bitte vor Versand prüfen)', { id: t })
} catch {
toast.error('Antwort-Vorschlag fehlgeschlagen', { id: t })
} finally {
setAiBusy(null)
}
}
return (
<div className="space-y-6 p-4 md:p-6">
<PageHeader
title="Support"
description="Ticket-Inbox mit SLA-Timern. AI-Schicht: Ticket-Zusammenfassung und Antwort-Vorschläge (optional, Daten bleiben im Server)."
actions={
<Button size="sm">
<Plus className="size-4" />
Ticket
</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 / Wartend</p>
<LifeBuoy className="size-3.5 text-muted-foreground" />
</div>
<p className="mt-1 text-2xl font-semibold tabular-nums">{stats.open + stats.pending}</p>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">Dringend</p>
<AlertTriangle className="size-3.5 text-muted-foreground" />
</div>
<p className="mt-1 text-2xl font-semibold tabular-nums">{stats.urgent}</p>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">SLA-Verletzung</p>
<Clock className="size-3.5 text-muted-foreground" />
</div>
<p className="mt-1 text-2xl font-semibold tabular-nums">{stats.slaBreached}</p>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">Unbeantwortet</p>
<MessageSquare className="size-3.5 text-muted-foreground" />
</div>
<p className="mt-1 text-2xl font-semibold tabular-nums">{stats.unresponded}</p>
</Card>
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-5">
{/* Ticket list */}
<Card className="lg:col-span-2 overflow-hidden">
<CardHeader className="pb-2">
<CardTitle className="text-sm">Inbox ({tickets.length})</CardTitle>
</CardHeader>
<ScrollArea className="h-[72vh]">
<div className="space-y-1.5 p-2">
{tickets.map((t) => {
const st = STATUS_META[t.status] ?? STATUS_META.open
const pr = PRIORITY_META[t.priority] ?? PRIORITY_META.normal
return (
<button
key={t.id}
onClick={() => { setSelectedId(t.id); setReplyDraft('') }}
className={cn(
'w-full rounded-md border p-3 text-left transition hover:border-foreground/40',
pr.cls,
selected?.id === t.id && 'ring-1 ring-foreground/40'
)}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<code className="text-[10px] font-mono text-muted-foreground">{t.number}</code>
{t.priority === 'urgent' && t.status !== 'closed' && t.status !== 'resolved' && (
<Badge variant="outline" className="text-[9px]">DRINGEND</Badge>
)}
</div>
<p className="mt-0.5 truncate text-sm font-medium">{t.subject}</p>
<p className="mt-0.5 truncate text-[11px] text-muted-foreground">{t.companyName}</p>
</div>
<Badge variant="outline" className={cn('shrink-0 text-[10px]', st.cls)}>{st.label}</Badge>
</div>
<div className="mt-2 flex items-center gap-2 text-[10px] text-muted-foreground">
<Badge variant="secondary" className="text-[9px]">{CATEGORY_LABELS[t.categoryId] ?? t.categoryId}</Badge>
<span>{t.messages.length} Msgs</span>
<span>· {formatRelative(t.messages[t.messages.length - 1]?.createdAt ?? t.createdAt)}</span>
{(t.sla.responseBreached || t.sla.resolveBreached) && (
<span className="flex items-center gap-0.5 text-foreground">
<AlertTriangle className="size-2.5" />SLA
</span>
)}
</div>
</button>
)
})}
</div>
</ScrollArea>
</Card>
{/* Conversation detail */}
<div className="lg:col-span-3">
{selected ? (
<Card className="flex h-[72vh] flex-col">
{/* Header */}
<CardHeader className="border-b border-border pb-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
<code className="font-mono text-xs">{selected.number}</code>
<Badge variant="outline" className="text-[10px]">{CATEGORY_LABELS[selected.categoryId] ?? selected.categoryId}</Badge>
</div>
<h2 className="mt-1 truncate text-base font-semibold">{selected.subject}</h2>
<p className="mt-0.5 text-[11px] text-muted-foreground">{selected.companyName}{selected.assignee && ` · ${selected.assignee}`}</p>
</div>
<Badge variant="outline" className={cn('shrink-0 text-[10px]', (STATUS_META[selected.status] ?? STATUS_META.open).cls)}>
{(STATUS_META[selected.status] ?? STATUS_META.open).label}
</Badge>
</div>
{/* SLA bar */}
<div className="mt-3 flex flex-wrap items-center gap-3 rounded-md border border-border bg-muted/30 p-2 text-[11px]">
<span className="flex items-center gap-1">
<Clock className="size-3 text-muted-foreground" />
SLA Antwort: {selected.sla.responseHours} h
{selected.sla.respondedAt ? (
<CheckCircle2 className="size-3 text-foreground" />
) : selected.sla.responseBreached ? (
<span className="font-medium text-foreground">verletzt ({selected.sla.ageHours} h)</span>
) : (
<span className="text-muted-foreground">{selected.sla.ageHours}/{selected.sla.responseHours} h</span>
)}
</span>
<span className="flex items-center gap-1">
SLA Lösung: {selected.sla.resolveHours} h
{selected.sla.resolvedAt ? (
<CheckCircle2 className="size-3 text-foreground" />
) : selected.sla.resolveBreached ? (
<span className="font-medium text-foreground">verletzt</span>
) : (
<span className="text-muted-foreground">{selected.sla.ageHours}/{selected.sla.resolveHours} h</span>
)}
</span>
</div>
</CardHeader>
{/* AI summary */}
{selected.aiSummary && (
<div className="border-b border-border bg-muted/20 px-4 py-2.5">
<div className="flex items-center gap-1.5">
<Sparkles className="size-3 text-muted-foreground" />
<span className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">AI-Zusammenfassung</span>
</div>
<p className="mt-1 text-[11px] leading-relaxed">{selected.aiSummary}</p>
</div>
)}
{/* Conversation thread */}
<ScrollArea className="flex-1">
<CardContent className="space-y-3 p-4">
{selected.messages.map((m) => {
const isStaff = m.fromType === 'staff'
const isSystem = m.fromType === 'system'
if (isSystem) {
return (
<div key={m.id} className="flex justify-center">
<span className="rounded-full bg-muted px-3 py-1 text-[10px] text-muted-foreground">
{m.body}
</span>
</div>
)
}
return (
<div key={m.id} className={cn('flex', isStaff ? 'justify-end' : 'justify-start')}>
<div className={cn('max-w-[80%] rounded-md border p-2.5', isStaff ? 'border-foreground/30 bg-foreground/5' : 'border-border')}>
<div className="flex items-center gap-1.5">
<span className="text-[10px] font-medium">{m.fromName}</span>
<Badge variant="outline" className="text-[9px]">{isStaff ? 'Agentur' : 'Kunde'}</Badge>
</div>
<p className="mt-1 text-xs leading-relaxed">{m.body}</p>
<p className="mt-1 text-[9px] text-muted-foreground">{formatDateTime(m.createdAt)}</p>
</div>
</div>
)
})}
</CardContent>
</ScrollArea>
{/* Reply + AI actions */}
<div className="border-t border-border p-3 space-y-2">
<div className="flex items-center gap-1.5">
<Button size="sm" variant="outline" className="h-7 gap-1 text-xs" onClick={() => summarize(selected)} disabled={aiBusy !== null}>
{aiBusy === 'summary' ? <Loader2 className="size-3 animate-spin" /> : <Sparkles className="size-3" />}
Zusammenfassen
</Button>
<Button size="sm" variant="outline" className="h-7 gap-1 text-xs" onClick={() => suggestReply(selected)} disabled={aiBusy !== null}>
{aiBusy === 'reply' ? <Loader2 className="size-3 animate-spin" /> : <Sparkles className="size-3" />}
Antwort vorschlagen
</Button>
</div>
<Textarea
value={replyDraft}
onChange={(e) => setReplyDraft(e.target.value)}
placeholder="Antwort schreiben oder AI-Vorschlag übernehmen…"
className="min-h-[80px] text-xs"
/>
<div className="flex items-center justify-between">
<p className="text-[10px] text-muted-foreground">
{replyDraft ? `${replyDraft.length} Zeichen` : 'AI-Vorschläge sind Entwürfe — vor Versand prüfen.'}
</p>
<Button size="sm" className="gap-1.5" disabled={!replyDraft.trim()}>
<Send className="size-3.5" />
Senden
</Button>
</div>
</div>
</Card>
) : (
<Card className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground">Wähle ein Ticket.</p>
</Card>
)}
</div>
</div>
</div>
)
}