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 setPortalMode(false)} onSelect={(id) => setPortalCompany(id)} /> 107→ } 108→ 109→ if (portalMode && portalCompanyId) { 110→ return ( 111→ { 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→
129→
130→ 133→
134→ Sichere Verbindung 135→
136→
137→
138→
139→

Client-Portal

140→

141→ Wähle dein Unternehmen. Du siehst ausschließlich deine eigenen Projekte, Rechnungen, Tickets und Freigaben. 142→

143→
144→
145→ setQ(e.target.value)} placeholder="Firma suchen…" className="max-w-sm" /> 146→
147→ {loading || !data ? ( 148→
149→ {Array.from({ length: 4 }).map((_, i) => )} 150→
151→ ) : ( 152→
153→ {data.companies 154→ .filter((c) => c.name.toLowerCase().includes(q.toLowerCase())) 155→ .map((c) => ( 156→ 178→ ))} 179→
180→ )} 181→
182→
183→
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(`/api/aos/portal/companies/${companyId}`) 194→ 195→ if (loading || !data) { 196→ return ( 197→
198→
199→ 200→
201→
202→
203→ ) 204→ } 205→ 206→ return ( 207→
208→ {/* Portal header */} 209→
210→ 213→
214→
215→ 216→
217→
218→

{data.company.name}

219→

Client-Portal · {data.company.legalForm ?? ''} {data.company.city ?? ''}

220→
221→
222→
223→ 224→
225→ Sicher geschützt 226→
227→
228→
229→ 230→ {/* Tab nav */} 231→
232→
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→ 254→ ) 255→ })} 256→
257→
258→ 259→ {/* Content */} 260→
261→ {tab === 'overview' && } 262→ {tab === 'approvals' && } 263→ {tab === 'invoices' && } 264→ {tab === 'tickets' && } 265→ {tab === 'licenses' && } 266→
267→
268→
269→ ) 270→} 271→ 272→function PortalOverview({ data, setTab }: { data: PortalData; setTab: (t: 'overview' | 'approvals' | 'invoices' | 'tickets' | 'licenses') => void }) { 273→ return ( 274→
275→ {/* Welcome banner */} 276→ 277→ 278→
279→ 280→
281→
282→

Willkommen, {data.company.name}

283→

284→ Du siehst ausschließlich deine eigenen Daten — sicher und geschützt. 285→ {data.kpis.pendingApprovals > 0 && {data.kpis.pendingApprovals} Freigabe(n) warten auf dich.} 286→

287→
288→ {data.kpis.pendingApprovals > 0 && ( 289→ 293→ )} 294→
295→
296→ 297→
298→

Offene Projekte

{data.kpis.openProjects}

299→

Offene Freigaben

{data.kpis.pendingApprovals}

300→

Offene Rechnungen

{data.kpis.unpaidInvoiceCount}

{formatEuro(data.kpis.unpaidCents)}

301→

Offene Tickets

{data.kpis.openTickets}

302→

Aktive Lizenzen

{data.kpis.activeLicenses}

303→
304→ 305→
306→ {/* Pending approvals */} 307→ 308→ 309→ Wartende Freigaben 310→ 311→ 312→ 313→ {data.approvals.filter((a) => a.status === 'pending').slice(0, 4).map((a) => ( 314→
315→

{a.title}

316→ {a.description &&

{a.description}

} 317→

Eingereicht {formatRelative(a.createdAt)}

318→
319→ ))} 320→ {data.approvals.filter((a) => a.status === 'pending').length === 0 && ( 321→

Keine offenen Freigaben.

322→ )} 323→
324→
325→ 326→ {/* Projects */} 327→ 328→ Meine Projekte ({data.projects.length}) 329→ 330→ {data.projects.slice(0, 4).map((p) => ( 331→
332→
333→

{p.name}

334→ {p.status} 335→
336→
337→ {p.doneTasks}/{p.taskCount} Aufgaben 338→ {p.progress}% 339→
340→ 341→
342→ ))} 343→
344→
345→
346→ 347→ {/* Recent invoices */} 348→ 349→ Letzte Rechnungen 350→ 351→
352→ 353→ 354→ 355→ 356→ 357→ {data.invoices.slice(0, 5).map((i) => ( 358→ 359→ 360→ 361→ 362→ 363→ 364→ 365→ ))} 366→ 367→
Nr.DatumFälligBetragStatus
{i.number}{formatDate(i.issueDate)}{formatDate(i.dueDate)}{formatEuro(i.grossCents)}{i.status}
368→
369→
370→
371→
372→ ) 373→} 374→ 375→function PortalApprovals({ data, refetch }: { data: PortalData; refetch: () => void }) { 376→ const [deciding, setDeciding] = useState(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 = { 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→
420→
421→ 422→
423→

Freigaben

424→

Meilenstein-Freigaben mit Zeitstempel- und IP-Evidenz.

425→
426→
427→ 428→
429→ {data.approvals.map((a) => { 430→ const meta = statusMeta[a.status] ?? statusMeta.pending 431→ return ( 432→ 433→
434→
435→
436→

{a.title}

437→ {meta.label} 438→
439→ {a.description &&

{a.description}

} 440→ {a.status !== 'pending' && ( 441→
442→

Entschieden: {formatDate(a.decidedAt!)} von {a.decidedBy}

443→

IP-Evidenz: {a.decidedIp}

444→ {a.decision &&

„{a.decision}"

} 445→
446→ )} 447→ {a.status === 'pending' && ( 448→

Eingereicht {formatRelative(a.createdAt)}

449→ )} 450→
451→ {a.status === 'pending' && ( 452→
453→ 456→ 459→ 462→
463→ )} 464→
465→
466→ ) 467→ })} 468→ {data.approvals.length === 0 &&

Keine Freigaben.

} 469→
470→ 471→ 472→ 473→ 474→ 475→ {decision === 'approved' && 'Freigabe bestätigen'} 476→ {decision === 'rejected' && 'Freigabe ablehnen'} 477→ {decision === 'changes-requested' && 'Änderungswunsch senden'} 478→ 479→ 480→ Deine Entscheidung wird mit Zeitstempel und IP-Adresse gespeichert (eIDAS Art. 25 AdES). 481→ 482→ 483→
484→
485→ 486→