Files
AOS/tool-results/read_1793225210119_51beaa6a86ba.txt
T

683 lines
37 KiB
Plaintext

1→'use client'
2→
3→import { useEffect, useState } from 'react'
4→import { db as _db } from '@/lib/db' // not used — placeholder for type alignment
5→import { useAos } from '@/store/aos'
6→import { useFetch } from '@/hooks/use-fetch'
7→import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
8→import { Badge } from '@/components/ui/badge'
9→import { Button } from '@/components/ui/button'
10→import { Input } from '@/components/ui/input'
11→import { Label } from '@/components/ui/label'
12→import { Skeleton } from '@/components/ui/skeleton'
13→import { ScrollArea } from '@/components/ui/scroll-area'
14→import { Progress } from '@/components/ui/progress'
15→import {
16→ Dialog,
17→ DialogContent,
18→ DialogHeader,
19→ DialogTitle,
20→ DialogFooter,
21→ DialogDescription,
22→} from '@/components/ui/dialog'
23→import { Textarea } from '@/components/ui/textarea'
24→import {
25→ Building2,
26→ ArrowLeft,
27→ FolderKanban,
28→ Receipt,
29→ LifeBuoy,
30→ CheckCircle2,
31→ Clock,
32→ ShieldCheck,
33→ Check,
34→ X,
35→ Loader2,
36→ KeyRound,
37→ AlertTriangle,
38→ Bell,
39→ Info,
40→} from 'lucide-react'
41→import { formatEuro, formatDate, formatRelative, daysUntil } from '@/lib/format'
42→import { cn } from '@/lib/utils'
43→import { toast } from 'sonner'
44→import {
45→ Popover,
46→ PopoverContent,
47→ PopoverTrigger,
48→} from '@/components/ui/popover'
49→import { Footer } from './footer'
50→
51→void _db
52→
53→interface PortalCompany {
54→ id: string
55→ name: string
56→ legalForm: string | null
57→ city: string | null
58→ projectCount: number
59→ invoiceCount: number
60→ licenseCount: number
61→ contactName: string | null
62→ contactEmail: string | null
63→}
64→
65→interface PortalData {
66→ company: { id: string; name: string; legalForm: string | null; city: string | null }
67→ kpis: {
68→ openProjects: number
69→ unpaidCents: number
70→ unpaidInvoiceCount: number
71→ openTickets: number
72→ pendingApprovals: number
73→ activeLicenses: number
74→ }
75→ projects: {
76→ id: string; name: string; type: string; status: string; progress: number
77→ budgetType: string; spentHours: number; budgetHours: number | null
78→ dueDate: string | null; taskCount: number; doneTasks: number
79→ }[]
80→ invoices: {
81→ id: string; number: string; status: string; type: string
82→ issueDate: string; dueDate: string; grossCents: number
83→ zugferd: boolean; xrechnung: boolean; lineCount: number
84→ }[]
85→ tickets: {
86→ id: string; number: string; subject: string; status: string; priority: string
87→ messageCount: number; lastMessageAt: string
88→ }[]
89→ approvals: {
90→ id: string; title: string; description: string | null; status: string
91→ decidedAt: string | null; decidedBy: string | null; decidedIp: string | null
92→ decision: string | null; createdAt: string
93→ }[]
94→ licenses: {
95→ id: string; key: string; product: string; plan: string; status: string
96→ validUntil: string | null; activations: { domain: string; environment: string }[]
97→ }[]
98→}
99→
100→export function PortalShell() {
101→ const { portalMode, portalCompanyId, setPortalMode, setPortalCompany } = useAos()
102→ const [tab, setTab] = useState<'overview' | 'approvals' | 'invoices' | 'tickets' | 'licenses'>('overview')
103→
104→ // If portal mode is on but no company selected, show the login/selection screen
105→ if (portalMode && !portalCompanyId) {
106→ return <PortalLogin onBack={() => setPortalMode(false)} onSelect={(id) => setPortalCompany(id)} />
107→ }
108→
109→ if (portalMode && portalCompanyId) {
110→ return (
111→ <PortalApp
112→ companyId={portalCompanyId}
113→ tab={tab}
114→ setTab={setTab}
115→ onExit={() => { setPortalMode(false); setPortalCompany(null) }}
116→ />
117→ )
118→ }
119→
120→ return null
121→}
122→
123→function PortalLogin({ onBack, onSelect }: { onBack: () => void; onSelect: (id: string) => void }) {
124→ const { data, loading } = useFetch<{ companies: PortalCompany[] }>('/api/aos/portal/companies')
125→ const [q, setQ] = useState('')
126→
127→ return (
128→ <div className="flex min-h-screen flex-col bg-muted/30">
129→ <header className="flex h-16 items-center gap-3 border-b border-border bg-background px-4 md:px-6">
130→ <Button variant="ghost" size="sm" className="gap-1.5" onClick={onBack}>
131→ <ArrowLeft className="size-4" />Zur Agentur-Ansicht
132→ </Button>
133→ <div className="ml-auto flex items-center gap-2 text-xs text-muted-foreground">
134→ <ShieldCheck className="size-3.5" />Sichere Verbindung
135→ </div>
136→ </header>
137→ <main className="mx-auto w-full max-w-3xl flex-1 p-4 md:p-8">
138→ <div className="mb-6 space-y-1.5">
139→ <h1 className="text-2xl font-semibold tracking-tight">Client-Portal</h1>
140→ <p className="text-sm text-muted-foreground">
141→ Wähle dein Unternehmen. Du siehst ausschließlich deine eigenen Projekte, Rechnungen, Tickets und Freigaben.
142→ </p>
143→ </div>
144→ <div className="mb-4">
145→ <Input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Firma suchen…" className="max-w-sm" />
146→ </div>
147→ {loading || !data ? (
148→ <div className="space-y-2">
149→ {Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-20 w-full" />)}
150→ </div>
151→ ) : (
152→ <div className="space-y-2">
153→ {data.companies
154→ .filter((c) => c.name.toLowerCase().includes(q.toLowerCase()))
155→ .map((c) => (
156→ <button
157→ key={c.id}
158→ onClick={() => onSelect(c.id)}
159→ className="flex w-full items-center gap-4 rounded-lg border border-border bg-background p-4 text-left transition hover:border-foreground/40 hover:shadow-sm"
160→ >
161→ <div className="flex size-12 items-center justify-center rounded-md bg-muted">
162→ <Building2 className="size-5 text-muted-foreground" />
163→ </div>
164→ <div className="min-w-0 flex-1">
165→ <div className="flex items-center gap-2">
166→ <span className="truncate text-sm font-semibold">{c.name}</span>
167→ {c.legalForm && <Badge variant="outline" className="text-[10px]">{c.legalForm}</Badge>}
168→ </div>
169→ <p className="mt-0.5 truncate text-xs text-muted-foreground">{c.contactName ?? '—'} · {c.contactEmail ?? '—'}</p>
170→ <div className="mt-1.5 flex items-center gap-3 text-[11px] text-muted-foreground">
171→ <span className="flex items-center gap-1"><FolderKanban className="size-3" />{c.projectCount} Projekte</span>
172→ <span className="flex items-center gap-1"><Receipt className="size-3" />{c.invoiceCount} Rechnungen</span>
173→ <span className="flex items-center gap-1"><KeyRound className="size-3" />{c.licenseCount} Lizenzen</span>
174→ </div>
175→ </div>
176→ <ArrowLeft className="size-4 rotate-180 text-muted-foreground" />
177→ </button>
178→ ))}
179→ </div>
180→ )}
181→ </main>
182→ <Footer />
183→ </div>
184→ )
185→}
186→
187→function PortalApp({ companyId, tab, setTab, onExit }: {
188→ companyId: string
189→ tab: 'overview' | 'approvals' | 'invoices' | 'tickets' | 'licenses'
190→ setTab: (t: 'overview' | 'approvals' | 'invoices' | 'tickets' | 'licenses') => void
191→ onExit: () => void
192→}) {
193→ const { data, loading, refetch } = useFetch<PortalData>(`/api/aos/portal/companies/${companyId}`)
194→
195→ if (loading || !data) {
196→ return (
197→ <div className="flex min-h-screen flex-col bg-muted/30">
198→ <header className="flex h-16 items-center gap-3 border-b border-border bg-background px-4 md:px-6">
199→ <Skeleton className="h-8 w-32" />
200→ </header>
201→ <main className="flex-1 p-6"><Skeleton className="h-96 w-full" /></main>
202→ </div>
203→ )
204→ }
205→
206→ return (
207→ <div className="flex min-h-screen flex-col bg-muted/30">
208→ {/* Portal header */}
209→ <header className="flex h-16 items-center gap-3 border-b border-border bg-background px-4 md:px-6">
210→ <Button variant="ghost" size="sm" className="gap-1.5" onClick={onExit}>
211→ <ArrowLeft className="size-4" />Agentur-Ansicht
212→ </Button>
213→ <div className="ml-2 flex items-center gap-2">
214→ <div className="flex size-9 items-center justify-center rounded-md bg-primary text-primary-foreground">
215→ <Building2 className="size-4" />
216→ </div>
217→ <div className="leading-tight">
218→ <p className="text-sm font-semibold">{data.company.name}</p>
219→ <p className="text-[10px] text-muted-foreground">Client-Portal · {data.company.legalForm ?? ''} {data.company.city ?? ''}</p>
220→ </div>
221→ </div>
222→ <div className="ml-auto flex items-center gap-2">
223→ <PortalNotificationsBell companyId={data.company.id} />
224→ <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
225→ <ShieldCheck className="size-3.5" />Sicher geschützt
226→ </div>
227→ </div>
228→ </header>
229→
230→ {/* Tab nav */}
231→ <div className="border-b border-border bg-background px-4 md:px-6">
232→ <div className="flex gap-1 overflow-x-auto py-2">
233→ {([
234→ { id: 'overview', label: 'Übersicht', icon: Building2 },
235→ { id: 'approvals', label: `Freigaben${data.kpis.pendingApprovals > 0 ? ` (${data.kpis.pendingApprovals})` : ''}`, icon: CheckCircle2 },
236→ { id: 'invoices', label: 'Rechnungen', icon: Receipt },
237→ { id: 'tickets', label: 'Tickets', icon: LifeBuoy },
238→ { id: 'licenses', label: 'Lizenzen', icon: KeyRound },
239→ ] as const).map((t) => {
240→ const Icon = t.icon
241→ const active = tab === t.id
242→ return (
243→ <button
244→ key={t.id}
245→ onClick={() => setTab(t.id)}
246→ className={cn(
247→ 'flex shrink-0 items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition',
248→ active ? 'bg-muted text-foreground' : 'text-muted-foreground hover:bg-muted/50'
249→ )}
250→ >
251→ <Icon className="size-3.5" />
252→ {t.label}
253→ </button>
254→ )
255→ })}
256→ </div>
257→ </div>
258→
259→ {/* Content */}
260→ <main className="flex-1 p-4 md:p-6">
261→ {tab === 'overview' && <PortalOverview data={data} setTab={setTab} />}
262→ {tab === 'approvals' && <PortalApprovals data={data} refetch={refetch} />}
263→ {tab === 'invoices' && <PortalInvoices data={data} />}
264→ {tab === 'tickets' && <PortalTickets data={data} />}
265→ {tab === 'licenses' && <PortalLicenses data={data} />}
266→ </main>
267→ <Footer />
268→ </div>
269→ )
270→}
271→
272→function PortalOverview({ data, setTab }: { data: PortalData; setTab: (t: 'overview' | 'approvals' | 'invoices' | 'tickets' | 'licenses') => void }) {
273→ return (
274→ <div className="space-y-6">
275→ {/* Welcome banner */}
276→ <Card className="border-foreground/20 bg-gradient-to-br from-foreground/5 to-transparent">
277→ <CardContent className="flex items-center gap-4 p-5">
278→ <div className="flex size-12 items-center justify-center rounded-xl bg-foreground text-background">
279→ <Building2 className="size-6" />
280→ </div>
281→ <div className="flex-1">
282→ <h2 className="text-lg font-semibold">Willkommen, {data.company.name}</h2>
283→ <p className="text-xs text-muted-foreground">
284→ Du siehst ausschließlich deine eigenen Daten — sicher und geschützt.
285→ {data.kpis.pendingApprovals > 0 && <span className="font-medium text-foreground"> {data.kpis.pendingApprovals} Freigabe(n) warten auf dich.</span>}
286→ </p>
287→ </div>
288→ {data.kpis.pendingApprovals > 0 && (
289→ <Button size="sm" className="gap-1.5" onClick={() => setTab('approvals')}>
290→ <CheckCircle2 className="size-4" />
291→ Freigaben ({data.kpis.pendingApprovals})
292→ </Button>
293→ )}
294→ </CardContent>
295→ </Card>
296→
297→ <div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6">
298→ <Card className="p-4"><p className="text-xs text-muted-foreground">Offene Projekte</p><p className="mt-1 text-2xl font-semibold tabular-nums">{data.kpis.openProjects}</p></Card>
299→ <Card className="p-4"><p className="text-xs text-muted-foreground">Offene Freigaben</p><p className="mt-1 text-2xl font-semibold tabular-nums">{data.kpis.pendingApprovals}</p></Card>
300→ <Card className="p-4"><p className="text-xs text-muted-foreground">Offene Rechnungen</p><p className="mt-1 text-2xl font-semibold tabular-nums">{data.kpis.unpaidInvoiceCount}</p><p className="text-[10px] text-muted-foreground">{formatEuro(data.kpis.unpaidCents)}</p></Card>
301→ <Card className="p-4"><p className="text-xs text-muted-foreground">Offene Tickets</p><p className="mt-1 text-2xl font-semibold tabular-nums">{data.kpis.openTickets}</p></Card>
302→ <Card className="p-4"><p className="text-xs text-muted-foreground">Aktive Lizenzen</p><p className="mt-1 text-2xl font-semibold tabular-nums">{data.kpis.activeLicenses}</p></Card>
303→ </div>
304→
305→ <div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
306→ {/* Pending approvals */}
307→ <Card>
308→ <CardHeader className="flex flex-row items-center justify-between pb-2">
309→ <CardTitle className="flex items-center gap-2 text-sm"><Clock className="size-3.5 text-muted-foreground" />Wartende Freigaben</CardTitle>
310→ <Button variant="ghost" size="sm" className="h-7 text-xs" onClick={() => setTab('approvals')}>Alle</Button>
311→ </CardHeader>
312→ <CardContent className="space-y-2">
313→ {data.approvals.filter((a) => a.status === 'pending').slice(0, 4).map((a) => (
314→ <div key={a.id} className="rounded-md border border-border p-3">
315→ <p className="text-xs font-medium">{a.title}</p>
316→ {a.description && <p className="mt-0.5 text-[11px] text-muted-foreground">{a.description}</p>}
317→ <p className="mt-1 text-[10px] text-muted-foreground">Eingereicht {formatRelative(a.createdAt)}</p>
318→ </div>
319→ ))}
320→ {data.approvals.filter((a) => a.status === 'pending').length === 0 && (
321→ <p className="py-4 text-center text-xs text-muted-foreground">Keine offenen Freigaben.</p>
322→ )}
323→ </CardContent>
324→ </Card>
325→
326→ {/* Projects */}
327→ <Card>
328→ <CardHeader className="pb-2"><CardTitle className="text-sm">Meine Projekte ({data.projects.length})</CardTitle></CardHeader>
329→ <CardContent className="space-y-2">
330→ {data.projects.slice(0, 4).map((p) => (
331→ <div key={p.id} className="rounded-md border border-border p-3">
332→ <div className="flex items-center justify-between">
333→ <p className="truncate text-xs font-medium">{p.name}</p>
334→ <Badge variant="outline" className="text-[10px] capitalize">{p.status}</Badge>
335→ </div>
336→ <div className="mt-2 flex items-center justify-between text-[10px] text-muted-foreground">
337→ <span>{p.doneTasks}/{p.taskCount} Aufgaben</span>
338→ <span>{p.progress}%</span>
339→ </div>
340→ <Progress value={p.progress} className="mt-1 h-1" />
341→ </div>
342→ ))}
343→ </CardContent>
344→ </Card>
345→ </div>
346→
347→ {/* Recent invoices */}
348→ <Card>
349→ <CardHeader className="pb-2"><CardTitle className="text-sm">Letzte Rechnungen</CardTitle></CardHeader>
350→ <CardContent className="p-0">
351→ <div className="overflow-x-auto">
352→ <table className="w-full text-sm">
353→ <thead className="border-y border-border bg-muted/40 text-left text-[11px] uppercase tracking-wide text-muted-foreground">
354→ <tr><th className="px-4 py-2 font-medium">Nr.</th><th className="px-4 py-2 font-medium">Datum</th><th className="px-4 py-2 font-medium">Fällig</th><th className="px-4 py-2 font-medium text-right">Betrag</th><th className="px-4 py-2 font-medium">Status</th></tr>
355→ </thead>
356→ <tbody className="divide-y divide-border">
357→ {data.invoices.slice(0, 5).map((i) => (
358→ <tr key={i.id}>
359→ <td className="px-4 py-2.5"><code className="font-mono text-[11px]">{i.number}</code></td>
360→ <td className="px-4 py-2.5 text-xs">{formatDate(i.issueDate)}</td>
361→ <td className="px-4 py-2.5 text-xs">{formatDate(i.dueDate)}</td>
362→ <td className="px-4 py-2.5 text-right text-xs font-semibold tabular-nums">{formatEuro(i.grossCents)}</td>
363→ <td className="px-4 py-2.5"><Badge variant="outline" className="text-[10px] capitalize">{i.status}</Badge></td>
364→ </tr>
365→ ))}
366→ </tbody>
367→ </table>
368→ </div>
369→ </CardContent>
370→ </Card>
371→ </div>
372→ )
373→}
374→
375→function PortalApprovals({ data, refetch }: { data: PortalData; refetch: () => void }) {
376→ const [deciding, setDeciding] = useState<string | null>(null)
377→ const [decision, setDecision] = useState<'approved' | 'rejected' | 'changes-requested'>('approved')
378→ const [feedback, setFeedback] = useState('')
379→ const [dialogOpen, setDialogOpen] = useState(false)
380→ const [busy, setBusy] = useState(false)
381→
382→ const openDecision = (approvalId: string, dec: 'approved' | 'rejected' | 'changes-requested') => {
383→ setDeciding(approvalId)
384→ setDecision(dec)
385→ setFeedback('')
386→ setDialogOpen(true)
387→ }
388→
389→ const submit = async () => {
390→ if (!deciding) return
391→ setBusy(true)
392→ const t = toast.loading('Freigabe wird gespeichert (IP + Zeitstempel)…')
393→ try {
394→ const r = await fetch(`/api/aos/portal/approvals/${deciding}/decide`, {
395→ method: 'PATCH',
396→ headers: { 'content-type': 'application/json' },
397→ body: JSON.stringify({ decision, decidedBy: data.company.name, feedback }),
398→ })
399→ const res = await r.json()
400→ if (!r.ok) throw new Error(res.error || 'decide failed')
401→ toast.success(`Freigabe ${decision === 'approved' ? 'akzeptiert' : decision === 'rejected' ? 'abgelehnt' : 'mit Änderungswunsch'} (IP: ${res.evidence.decidedIp})`, { id: t })
402→ setDialogOpen(false)
403→ refetch()
404→ } catch (err) {
405→ toast.error(err instanceof Error ? err.message : 'Speichern fehlgeschlagen', { id: t })
406→ } finally {
407→ setBusy(false)
408→ }
409→ }
410→
411→ const statusMeta: Record<string, { label: string; cls: string }> = {
412→ pending: { label: 'Wartend', cls: 'bg-foreground/50 text-background' },
413→ approved: { label: 'Freigegeben', cls: 'bg-foreground text-background' },
414→ rejected: { label: 'Abgelehnt', cls: 'bg-muted text-muted-foreground' },
415→ 'changes-requested': { label: 'Änderung gewünscht', cls: 'bg-muted text-foreground' },
416→ }
417→
418→ return (
419→ <div className="space-y-4">
420→ <div className="flex items-center gap-2">
421→ <CheckCircle2 className="size-5 text-muted-foreground" />
422→ <div>
423→ <h1 className="text-lg font-semibold">Freigaben</h1>
424→ <p className="text-xs text-muted-foreground">Meilenstein-Freigaben mit Zeitstempel- und IP-Evidenz.</p>
425→ </div>
426→ </div>
427→
428→ <div className="space-y-2">
429→ {data.approvals.map((a) => {
430→ const meta = statusMeta[a.status] ?? statusMeta.pending
431→ return (
432→ <Card key={a.id} className="p-4">
433→ <div className="flex items-start justify-between gap-3">
434→ <div className="min-w-0 flex-1">
435→ <div className="flex items-center gap-2">
436→ <p className="text-sm font-medium">{a.title}</p>
437→ <Badge variant="outline" className={cn('text-[10px]', meta.cls)}>{meta.label}</Badge>
438→ </div>
439→ {a.description && <p className="mt-1 text-xs text-muted-foreground">{a.description}</p>}
440→ {a.status !== 'pending' && (
441→ <div className="mt-2 space-y-0.5 text-[10px] text-muted-foreground">
442→ <p>Entschieden: {formatDate(a.decidedAt!)} von {a.decidedBy}</p>
443→ <p>IP-Evidenz: <code className="font-mono">{a.decidedIp}</code></p>
444→ {a.decision && <p className="italic">„{a.decision}"</p>}
445→ </div>
446→ )}
447→ {a.status === 'pending' && (
448→ <p className="mt-1 text-[10px] text-muted-foreground">Eingereicht {formatRelative(a.createdAt)}</p>
449→ )}
450→ </div>
451→ {a.status === 'pending' && (
452→ <div className="flex shrink-0 gap-1.5">
453→ <Button size="sm" className="h-8 gap-1 text-xs" onClick={() => openDecision(a.id, 'approved')}>
454→ <Check className="size-3.5" />Freigeben
455→ </Button>
456→ <Button size="sm" variant="outline" className="h-8 gap-1 text-xs" onClick={() => openDecision(a.id, 'changes-requested')}>
457→ <AlertTriangle className="size-3.5" />Änderung
458→ </Button>
459→ <Button size="sm" variant="ghost" className="h-8 gap-1 text-xs text-muted-foreground" onClick={() => openDecision(a.id, 'rejected')}>
460→ <X className="size-3.5" />Ablehnen
461→ </Button>
462→ </div>
463→ )}
464→ </div>
465→ </Card>
466→ )
467→ })}
468→ {data.approvals.length === 0 && <p className="py-8 text-center text-xs text-muted-foreground">Keine Freigaben.</p>}
469→ </div>
470→
471→ <Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
472→ <DialogContent className="sm:max-w-md">
473→ <DialogHeader>
474→ <DialogTitle>
475→ {decision === 'approved' && 'Freigabe bestätigen'}
476→ {decision === 'rejected' && 'Freigabe ablehnen'}
477→ {decision === 'changes-requested' && 'Änderungswunsch senden'}
478→ </DialogTitle>
479→ <DialogDescription>
480→ Deine Entscheidung wird mit Zeitstempel und IP-Adresse gespeichert (eIDAS Art. 25 AdES).
481→ </DialogDescription>
482→ </DialogHeader>
483→ <div className="space-y-3">
484→ <div className="space-y-1.5">
485→ <Label className="text-xs">Kommentar / Feedback (optional)</Label>
486→ <Textarea value={feedback} onChange={(e) => setFeedback(e.target.value)} placeholder="z.B. Bitte Kontrast erhöhen…" className="min-h-[80px] text-sm" />
487→ </div>
488→ <div className="rounded-md border border-dashed border-border p-2.5 text-[10px] text-muted-foreground">
489→ <p className="flex items-center gap-1.5"><ShieldCheck className="size-3" />Evidenz: {new Date().toLocaleString('de-DE')} · IP wird erfasst</p>
490→ </div>
491→ </div>
492→ <DialogFooter>
493→ <Button variant="outline" onClick={() => setDialogOpen(false)}>Abbrechen</Button>
494→ <Button onClick={submit} disabled={busy} className="gap-1.5">
495→ {busy && <Loader2 className="size-3.5 animate-spin" />}
496→ {decision === 'approved' ? 'Freigeben' : decision === 'rejected' ? 'Ablehnen' : 'Senden'}
497→ </Button>
498→ </DialogFooter>
499→ </DialogContent>
500→ </Dialog>
501→ </div>
502→ )
503→}
504→
505→function PortalInvoices({ data }: { data: PortalData }) {
506→ return (
507→ <div className="space-y-4">
508→ <div className="flex items-center gap-2">
509→ <Receipt className="size-5 text-muted-foreground" />
510→ <div>
511→ <h1 className="text-lg font-semibold">Rechnungen ({data.invoices.length})</h1>
512→ <p className="text-xs text-muted-foreground">Deine Rechnungen — Ansicht nur für dein Unternehmen.</p>
513→ </div>
514→ </div>
515→ <Card>
516→ <CardContent className="p-0">
517→ <div className="overflow-x-auto">
518→ <table className="w-full text-sm">
519→ <thead className="border-y border-border bg-muted/40 text-left text-[11px] uppercase tracking-wide text-muted-foreground">
520→ <tr>
521→ <th className="px-4 py-2 font-medium">Nr.</th>
522→ <th className="px-4 py-2 font-medium">Typ</th>
523→ <th className="px-4 py-2 font-medium">Datum</th>
524→ <th className="px-4 py-2 font-medium">Fällig</th>
525→ <th className="px-4 py-2 font-medium text-right">Betrag</th>
526→ <th className="px-4 py-2 font-medium">E-Rechnung</th>
527→ <th className="px-4 py-2 font-medium">Status</th>
528→ </tr>
529→ </thead>
530→ <tbody className="divide-y divide-border">
531→ {data.invoices.map((i) => {
532→ const due = daysUntil(i.dueDate)
533→ return (
534→ <tr key={i.id}>
535→ <td className="px-4 py-2.5"><code className="font-mono text-[11px]">{i.number}</code></td>
536→ <td className="px-4 py-2.5 text-xs text-muted-foreground">{i.type}</td>
537→ <td className="px-4 py-2.5 text-xs">{formatDate(i.issueDate)}</td>
538→ <td className="px-4 py-2.5 text-xs">{formatDate(i.dueDate)}{i.status === 'sent' && due < 0 && <span className="ml-1 text-[10px] text-foreground">({Math.abs(due)} d)</span>}</td>
539→ <td className="px-4 py-2.5 text-right text-xs font-semibold tabular-nums">{formatEuro(i.grossCents)}</td>
540→ <td className="px-4 py-2.5">
541→ <div className="flex gap-1">
542→ {i.zugferd && <Badge variant="outline" className="text-[9px]">ZUGFeRD</Badge>}
543→ {i.xrechnung && <Badge variant="outline" className="text-[9px]">XRechnung</Badge>}
544→ </div>
545→ </td>
546→ <td className="px-4 py-2.5"><Badge variant="outline" className="text-[10px] capitalize">{i.status}</Badge></td>
547→ </tr>
548→ )
549→ })}
550→ </tbody>
551→ </table>
552→ </div>
553→ </CardContent>
554→ </Card>
555→ </div>
556→ )
557→}
558→
559→function PortalTickets({ data }: { data: PortalData }) {
560→ return (
561→ <div className="space-y-4">
562→ <div className="flex items-center justify-between">
563→ <div className="flex items-center gap-2">
564→ <LifeBuoy className="size-5 text-muted-foreground" />
565→ <div>
566→ <h1 className="text-lg font-semibold">Tickets ({data.tickets.length})</h1>
567→ <p className="text-xs text-muted-foreground">Support-Anfragen und Konversationen.</p>
568→ </div>
569→ </div>
570→ <Button size="sm"><LifeBuoy className="size-4" />Neues Ticket</Button>
571→ </div>
572→ <div className="space-y-2">
573→ {data.tickets.map((t) => (
574→ <Card key={t.id} className="p-4">
575→ <div className="flex items-start justify-between gap-3">
576→ <div className="min-w-0 flex-1">
577→ <div className="flex items-center gap-2">
578→ <code className="text-[10px] font-mono text-muted-foreground">{t.number}</code>
579→ {t.priority === 'urgent' && <Badge variant="outline" className="text-[9px]">DRINGEND</Badge>}
580→ </div>
581→ <p className="mt-0.5 truncate text-sm font-medium">{t.subject}</p>
582→ <p className="mt-1 text-[10px] text-muted-foreground">{t.messageCount} Nachrichten · letzte {formatRelative(t.lastMessageAt)}</p>
583→ </div>
584→ <Badge variant="outline" className="text-[10px] capitalize">{t.status}</Badge>
585→ </div>
586→ </Card>
587→ ))}
588→ {data.tickets.length === 0 && <p className="py-8 text-center text-xs text-muted-foreground">Keine Tickets.</p>}
589→ </div>
590→ </div>
591→ )
592→}
593→
594→function PortalLicenses({ data }: { data: PortalData }) {
595→ return (
596→ <div className="space-y-4">
597→ <div className="flex items-center gap-2">
598→ <KeyRound className="size-5 text-muted-foreground" />
599→ <div>
600→ <h1 className="text-lg font-semibold">Lizenzen ({data.licenses.length})</h1>
601→ <p className="text-xs text-muted-foreground">Deine Produkt-Lizenzen und Aktivierungen.</p>
602→ </div>
603→ </div>
604→ <div className="space-y-2">
605→ {data.licenses.map((l) => (
606→ <Card key={l.id} className="p-4">
607→ <div className="flex items-start justify-between gap-3">
608→ <div className="min-w-0 flex-1">
609→ <div className="flex items-center gap-2">
610→ <p className="text-sm font-medium">{l.product}</p>
611→ <Badge variant="outline" className="text-[10px] capitalize">{l.plan}</Badge>
612→ </div>
613→ <code className="mt-1 block text-[10px] font-mono text-muted-foreground">{l.key}</code>
614→ <div className="mt-1.5 space-y-0.5 text-[10px] text-muted-foreground">
615→ {l.activations.map((a, i) => <p key={i}>↳ {a.domain} ({a.environment})</p>)}
616→ {l.validUntil && <p>Gültig bis: {formatDate(l.validUntil)}</p>}
617→ </div>
618→ </div>
619→ <Badge variant="outline" className="text-[10px] capitalize">{l.status}</Badge>
620→ </div>
621→ </Card>
622→ ))}
623→ </div>
624→ </div>
625→ )
626→}
627→
628→// Portal notifications bell — client-scoped
629→function PortalNotificationsBell({ companyId }: { companyId: string }) {
630→ const [open, setOpen] = useState(false)
631→ const { data } = useFetch<{ notifications: { id: string; type: string; title: string; detail: string; module: string; daysUntil: number }[]; stats: { total: number; urgent: number; warning: number; info: number } }>(`/api/aos/portal/companies/${companyId}/notifications`)
632→
633→ const notifs = data?.notifications ?? []
634→ const urgent = data?.stats.urgent ?? 0
635→
636→ return (
637→ <Popover open={open} onOpenChange={setOpen}>
638→ <PopoverTrigger asChild>
639→ <Button variant="ghost" size="icon" className="relative" aria-label="Benachrichtigungen">
640→ <Bell className="size-4" />
641→ {urgent > 0 && (
642→ <span className="absolute right-1 top-1 flex size-4 items-center justify-center rounded-full bg-foreground text-[9px] font-bold text-background">
643→ {urgent}
644→ </span>
645→ )}
646→ {urgent === 0 && notifs.length > 0 && (
647→ <span className="absolute right-1.5 top-1.5 size-1.5 rounded-full bg-foreground/50" />
648→ )}
649→ </Button>
650→ </PopoverTrigger>
651→ <PopoverContent align="end" className="w-72 p-0">
652→ <div className="flex items-center justify-between border-b border-border px-3 py-2.5">
653→ <span className="text-xs font-semibold">Deine Benachrichtigungen</span>
654→ {notifs.length > 0 && <Badge variant="outline" className="text-[10px]">{notifs.length}</Badge>}
655→ </div>
656→ <ScrollArea className="h-72">
657→ {notifs.length === 0 ? (
658→ <div className="flex flex-col items-center justify-center gap-2 py-10 text-center">
659→ <CheckCircle2 className="size-7 text-muted-foreground/50" />
660→ <p className="text-xs text-muted-foreground">Keine offenen Benachrichtigungen.</p>
661→ </div>
662→ ) : (
663→ <div className="divide-y divide-border">
664→ {notifs.map((n) => {
665→ const Icon = n.type === 'urgent' ? AlertTriangle : n.type === 'warning' ? Clock : Info
666→ return (
667→ <div key={n.id} className="flex items-start gap-2.5 px-3 py-2.5">
668→ <Icon className={cn('mt-0.5 size-3.5 shrink-0', n.type === 'urgent' ? 'text-foreground' : n.type === 'warning' ? 'text-foreground/70' : 'text-muted-foreground')} />
669→ <div className="min-w-0 flex-1">
670→ <p className="truncate text-xs font-medium">{n.title}</p>
671→ <p className="mt-0.5 text-[10px] text-muted-foreground">{n.detail}</p>
672→ </div>
673→ </div>
674→ )
675→ })}
676→ </div>
677→ )}
678→ </ScrollArea>
679→ </PopoverContent>
680→ </Popover>
681→ )
682→}
683→