'use client' import { useEffect, useState } from 'react' import { db as _db } from '@/lib/db' // not used — placeholder for type alignment import { useAos } from '@/store/aos' 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 { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Skeleton } from '@/components/ui/skeleton' import { ScrollArea } from '@/components/ui/scroll-area' import { Progress } from '@/components/ui/progress' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription, } from '@/components/ui/dialog' import { Textarea } from '@/components/ui/textarea' import { Building2, ArrowLeft, FolderKanban, Receipt, LifeBuoy, CheckCircle2, Clock, ShieldCheck, Check, X, Loader2, KeyRound, AlertTriangle, Bell, Info, } from 'lucide-react' import { formatEuro, formatDate, formatRelative, daysUntil } from '@/lib/format' import { cn } from '@/lib/utils' import { toast } from 'sonner' import { Popover, PopoverContent, PopoverTrigger, } from '@/components/ui/popover' import { Footer } from './footer' void _db interface PortalCompany { id: string name: string legalForm: string | null city: string | null projectCount: number invoiceCount: number licenseCount: number contactName: string | null contactEmail: string | null } interface PortalData { company: { id: string; name: string; legalForm: string | null; city: string | null } kpis: { openProjects: number unpaidCents: number unpaidInvoiceCount: number openTickets: number pendingApprovals: number activeLicenses: number } projects: { id: string; name: string; type: string; status: string; progress: number budgetType: string; spentHours: number; budgetHours: number | null dueDate: string | null; taskCount: number; doneTasks: number }[] invoices: { id: string; number: string; status: string; type: string issueDate: string; dueDate: string; grossCents: number zugferd: boolean; xrechnung: boolean; lineCount: number }[] tickets: { id: string; number: string; subject: string; status: string; priority: string messageCount: number; lastMessageAt: string }[] approvals: { id: string; title: string; description: string | null; status: string decidedAt: string | null; decidedBy: string | null; decidedIp: string | null decision: string | null; createdAt: string }[] licenses: { id: string; key: string; product: string; plan: string; status: string validUntil: string | null; activations: { domain: string; environment: string }[] }[] } export function PortalShell() { const { portalMode, portalCompanyId, setPortalMode, setPortalCompany } = useAos() const [tab, setTab] = useState<'overview' | 'approvals' | 'invoices' | 'tickets' | 'licenses'>('overview') // If portal mode is on but no company selected, show the login/selection screen if (portalMode && !portalCompanyId) { return setPortalMode(false)} onSelect={(id) => setPortalCompany(id)} /> } if (portalMode && portalCompanyId) { return ( { setPortalMode(false); setPortalCompany(null) }} /> ) } return null } function PortalLogin({ onBack, onSelect }: { onBack: () => void; onSelect: (id: string) => void }) { const { data, loading } = useFetch<{ companies: PortalCompany[] }>('/api/aos/portal/companies') const [q, setQ] = useState('') return (
Client-Portal · projektbezogener Zugriff

Client-Portal

Wähle dein Unternehmen. Du siehst ausschließlich deine eigenen Projekte, Rechnungen, Tickets und Freigaben — projektbezogen isoliert (PRD §5.6).

setQ(e.target.value)} placeholder="Firma suchen…" className="max-w-sm" />
{loading || !data ? (
{Array.from({ length: 4 }).map((_, i) => )}
) : (
{data.companies .filter((c) => c.name.toLowerCase().includes(q.toLowerCase())) .map((c) => ( ))}
)}
) } function PortalApp({ companyId, tab, setTab, onExit }: { companyId: string tab: 'overview' | 'approvals' | 'invoices' | 'tickets' | 'licenses' setTab: (t: 'overview' | 'approvals' | 'invoices' | 'tickets' | 'licenses') => void onExit: () => void }) { const { data, loading, refetch } = useFetch(`/api/aos/portal/companies/${companyId}`) if (loading || !data) { return (
) } return (
{/* Portal header */}

{data.company.name}

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

Projekt-isolierter Zugriff
{/* Tab nav */}
{([ { id: 'overview', label: 'Übersicht', icon: Building2 }, { id: 'approvals', label: `Freigaben${data.kpis.pendingApprovals > 0 ? ` (${data.kpis.pendingApprovals})` : ''}`, icon: CheckCircle2 }, { id: 'invoices', label: 'Rechnungen', icon: Receipt }, { id: 'tickets', label: 'Tickets', icon: LifeBuoy }, { id: 'licenses', label: 'Lizenzen', icon: KeyRound }, ] as const).map((t) => { const Icon = t.icon const active = tab === t.id return ( ) })}
{/* Content */}
{tab === 'overview' && } {tab === 'approvals' && } {tab === 'invoices' && } {tab === 'tickets' && } {tab === 'licenses' && }
) } function PortalOverview({ data, setTab }: { data: PortalData; setTab: (t: 'overview' | 'approvals' | 'invoices' | 'tickets' | 'licenses') => void }) { return (
{/* Welcome banner */}

Willkommen, {data.company.name}

Du siehst ausschließlich deine eigenen Daten — projektbezogen isoliert (PRD §5.6). {data.kpis.pendingApprovals > 0 && {data.kpis.pendingApprovals} Freigabe(n) warten auf dich.}

{data.kpis.pendingApprovals > 0 && ( )}

Offene Projekte

{data.kpis.openProjects}

Offene Freigaben

{data.kpis.pendingApprovals}

Offene Rechnungen

{data.kpis.unpaidInvoiceCount}

{formatEuro(data.kpis.unpaidCents)}

Offene Tickets

{data.kpis.openTickets}

Aktive Lizenzen

{data.kpis.activeLicenses}

{/* Pending approvals */} Wartende Freigaben {data.approvals.filter((a) => a.status === 'pending').slice(0, 4).map((a) => (

{a.title}

{a.description &&

{a.description}

}

Eingereicht {formatRelative(a.createdAt)}

))} {data.approvals.filter((a) => a.status === 'pending').length === 0 && (

Keine offenen Freigaben.

)}
{/* Projects */} Meine Projekte ({data.projects.length}) {data.projects.slice(0, 4).map((p) => (

{p.name}

{p.status}
{p.doneTasks}/{p.taskCount} Aufgaben {p.progress}%
))}
{/* Recent invoices */} Letzte Rechnungen
{data.invoices.slice(0, 5).map((i) => ( ))}
Nr.DatumFälligBetragStatus
{i.number} {formatDate(i.issueDate)} {formatDate(i.dueDate)} {formatEuro(i.grossCents)} {i.status}
) } function PortalApprovals({ data, refetch }: { data: PortalData; refetch: () => void }) { const [deciding, setDeciding] = useState(null) const [decision, setDecision] = useState<'approved' | 'rejected' | 'changes-requested'>('approved') const [feedback, setFeedback] = useState('') const [dialogOpen, setDialogOpen] = useState(false) const [busy, setBusy] = useState(false) const openDecision = (approvalId: string, dec: 'approved' | 'rejected' | 'changes-requested') => { setDeciding(approvalId) setDecision(dec) setFeedback('') setDialogOpen(true) } const submit = async () => { if (!deciding) return setBusy(true) const t = toast.loading('Freigabe wird gespeichert (IP + Zeitstempel)…') try { const r = await fetch(`/api/aos/portal/approvals/${deciding}/decide`, { method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ decision, decidedBy: data.company.name, feedback }), }) const res = await r.json() if (!r.ok) throw new Error(res.error || 'decide failed') toast.success(`Freigabe ${decision === 'approved' ? 'akzeptiert' : decision === 'rejected' ? 'abgelehnt' : 'mit Änderungswunsch'} (IP: ${res.evidence.decidedIp})`, { id: t }) setDialogOpen(false) refetch() } catch (err) { toast.error(err instanceof Error ? err.message : 'Speichern fehlgeschlagen', { id: t }) } finally { setBusy(false) } } const statusMeta: Record = { pending: { label: 'Wartend', cls: 'bg-foreground/50 text-background' }, approved: { label: 'Freigegeben', cls: 'bg-foreground text-background' }, rejected: { label: 'Abgelehnt', cls: 'bg-muted text-muted-foreground' }, 'changes-requested': { label: 'Änderung gewünscht', cls: 'bg-muted text-foreground' }, } return (

Freigaben

Meilenstein-Freigaben mit Zeitstempel- und IP-Evidenz (PRD §6.3).

{data.approvals.map((a) => { const meta = statusMeta[a.status] ?? statusMeta.pending return (

{a.title}

{meta.label}
{a.description &&

{a.description}

} {a.status !== 'pending' && (

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

IP-Evidenz: {a.decidedIp}

{a.decision &&

„{a.decision}"

}
)} {a.status === 'pending' && (

Eingereicht {formatRelative(a.createdAt)}

)}
{a.status === 'pending' && (
)}
) })} {data.approvals.length === 0 &&

Keine Freigaben.

}
{decision === 'approved' && 'Freigabe bestätigen'} {decision === 'rejected' && 'Freigabe ablehnen'} {decision === 'changes-requested' && 'Änderungswunsch senden'} Deine Entscheidung wird mit Zeitstempel und IP-Adresse gespeichert (eIDAS Art. 25 AdES).