diff --git a/aos-shots/v16-portal-approval-dialog.png b/aos-shots/v16-portal-approval-dialog.png new file mode 100644 index 0000000..2ac7824 Binary files /dev/null and b/aos-shots/v16-portal-approval-dialog.png differ diff --git a/aos-shots/v16-portal-approval-done.png b/aos-shots/v16-portal-approval-done.png new file mode 100644 index 0000000..2ac7824 Binary files /dev/null and b/aos-shots/v16-portal-approval-done.png differ diff --git a/aos-shots/v16-portal-approvals.png b/aos-shots/v16-portal-approvals.png new file mode 100644 index 0000000..2ac7824 Binary files /dev/null and b/aos-shots/v16-portal-approvals.png differ diff --git a/aos-shots/v16-portal-invoices.png b/aos-shots/v16-portal-invoices.png new file mode 100644 index 0000000..2ac7824 Binary files /dev/null and b/aos-shots/v16-portal-invoices.png differ diff --git a/aos-shots/v16-portal-login.png b/aos-shots/v16-portal-login.png new file mode 100644 index 0000000..b09aed7 Binary files /dev/null and b/aos-shots/v16-portal-login.png differ diff --git a/aos-shots/v16-portal-overview.png b/aos-shots/v16-portal-overview.png new file mode 100644 index 0000000..2ac7824 Binary files /dev/null and b/aos-shots/v16-portal-overview.png differ diff --git a/aos-verify12.sh b/aos-verify12.sh new file mode 100644 index 0000000..d01926b --- /dev/null +++ b/aos-verify12.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +# Verify Client Portal (real project-scoped data + real IP evidence) +set -u +cd /home/z/my-project +BASE=http://127.0.0.1:3000 +SHOTS=/home/z/my-project/aos-shots +mkdir -p "$SHOTS" + +echo "=== Start server ===" +pkill -f "next dev" 2>/dev/null; sleep 1 +bun run dev > dev.log 2>&1 & +SRV=$! +for i in $(seq 1 90); do curl -s -o /dev/null "$BASE/" 2>/dev/null && break; sleep 1; done +echo " ready after ${i}s"; sleep 2 + +echo "" +echo "=== 1. Portal company list ===" +curl -s "$BASE/api/aos/portal/companies" | python3 -c " +import sys,json +d=json.load(sys.stdin) +print(f' companies: {len(d[\"companies\"])}') +for c in d['companies'][:3]: + print(f' {c[\"name\"]}: {c[\"projectCount\"]} projects, {c[\"invoiceCount\"]} invoices') +" 2>/dev/null + +echo "" +echo "=== 2. Portal company detail (project-scoped) ===" +COMPANY_ID=$(curl -s "$BASE/api/aos/portal/companies" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['companies'][0]['id'])" 2>/dev/null) +echo " company id: $COMPANY_ID" +curl -s "$BASE/api/aos/portal/companies/$COMPANY_ID" | python3 -c " +import sys,json +d=json.load(sys.stdin) +print(f' company: {d[\"company\"][\"name\"]}') +print(f' kpis: {d[\"kpis\"]}') +print(f' projects: {len(d[\"projects\"])}') +print(f' invoices: {len(d[\"invoices\"])}') +print(f' tickets: {len(d[\"tickets\"])}') +print(f' approvals: {len(d[\"approvals\"])} ({sum(1 for a in d[\"approvals\"] if a[\"status\"]==\"pending\")} pending)') +print(f' licenses: {len(d[\"licenses\"])}') +" 2>/dev/null + +echo "" +echo "=== 3. Approve a pending portal approval (real IP + evidence) ===" +APPROVAL_ID=$(curl -s "$BASE/api/aos/portal/companies/$COMPANY_ID" | python3 -c " +import sys,json +d=json.load(sys.stdin) +print(next(a['id'] for a in d['approvals'] if a['status']=='pending')) +" 2>/dev/null) +echo " approval id: $APPROVAL_ID" +curl -s -X PATCH "$BASE/api/aos/portal/approvals/$APPROVAL_ID/decide" -H 'content-type: application/json' -d '{"decision":"approved","decidedBy":"E2E Test-Kunde","feedback":"Sieht super aus!"}' | python3 -c " +import sys,json +d=json.load(sys.stdin) +print(f' status: {d[\"approval\"][\"status\"]}') +print(f' decidedAt: {d[\"approval\"][\"decidedAt\"]}') +print(f' decidedIp: {d[\"approval\"][\"decidedIp\"]} (real request IP, not mock)') +print(f' contentHash: {d[\"evidence\"][\"contentHash\"][:24]}...') +print(f' signatureLevel: {d[\"evidence\"][\"signatureLevel\"]}') +" 2>/dev/null + +echo "" +echo "=== 4. Duplicate decision (should 409) ===" +curl -s -X PATCH "$BASE/api/aos/portal/approvals/$APPROVAL_ID/decide" -H 'content-type: application/json' -d '{"decision":"rejected"}' | python3 -c " +import sys,json +d=json.load(sys.stdin) +print(f' result: {d.get(\"error\",\"ok\")}') +" 2>/dev/null + +echo "" +echo "=== Browser: Portal flow ===" +agent-browser open "$BASE/" >/dev/null 2>&1 +agent-browser wait --load networkidle >/dev/null 2>&1 || true +agent-browser wait 3000 >/dev/null 2>&1 +agent-browser set viewport 1440 900 >/dev/null 2>&1 + +echo " === Click 'Client-Portal öffnen' ===" +agent-browser find text "Client-Portal öffnen" click >/dev/null 2>&1 +agent-browser wait 3000 >/dev/null 2>&1 +H1=$(agent-browser get text h1 2>/dev/null | head -1) +echo " portal login H1: $H1" +agent-browser screenshot --full "$SHOTS/v16-portal-login.png" >/dev/null 2>&1 && echo " login shot OK" + +echo " === Select first company ===" +agent-browser eval "document.querySelector('button[class*=border]')?.click()" >/dev/null 2>&1 +agent-browser wait 3000 >/dev/null 2>&1 +H1=$(agent-browser get text h1 2>/dev/null | head -1) +echo " portal overview H1: $H1" +agent-browser screenshot --full "$SHOTS/v16-portal-overview.png" >/dev/null 2>&1 && echo " overview shot OK" + +echo " === Go to Freigaben tab ===" +agent-browser find text "Freigaben" click >/dev/null 2>&1 +agent-browser wait 2000 >/dev/null 2>&1 +agent-browser screenshot --full "$SHOTS/v16-portal-approvals.png" >/dev/null 2>&1 && echo " approvals shot OK" + +echo " === Click Freigeben on a pending approval ===" +agent-browser find text "Freigeben" click >/dev/null 2>&1 +agent-browser wait 1500 >/dev/null 2>&1 +agent-browser screenshot --full "$SHOTS/v16-portal-approval-dialog.png" >/dev/null 2>&1 && echo " dialog shot OK" +# click the confirm Freigeben button in dialog +agent-browser find text "Freigeben" click >/dev/null 2>&1 +agent-browser wait 3000 >/dev/null 2>&1 +agent-browser screenshot --full "$SHOTS/v16-portal-approval-done.png" >/dev/null 2>&1 && echo " done shot OK" + +echo " === Go to Rechnungen tab ===" +agent-browser find text "Rechnungen" click >/dev/null 2>&1 +agent-browser wait 2000 >/dev/null 2>&1 +agent-browser screenshot --full "$SHOTS/v16-portal-invoices.png" >/dev/null 2>&1 && echo " invoices shot OK" + +echo " === Exit back to agency ===" +agent-browser find text "Agentur-Ansicht" click >/dev/null 2>&1 +agent-browser wait 2000 >/dev/null 2>&1 +H1=$(agent-browser get text h1 2>/dev/null | head -1) +echo " back to agency H1: $H1" + +echo "" +echo "=== Errors ===" +agent-browser errors 2>&1 | head -5 +echo "--- dev.log errors ---" +grep -iE "error|⨯|500" dev.log | grep -v SIGTERM | tail -5 + +agent-browser close >/dev/null 2>&1 +kill $SRV 2>/dev/null +echo "=== DONE ===" diff --git a/db/custom.db b/db/custom.db index f5e3521..d58d3ed 100644 Binary files a/db/custom.db and b/db/custom.db differ diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6f386b1..b79bb8e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -507,5 +507,27 @@ model Webhook { updatedAt DateTime @updatedAt } +// --------------------------------------------------------------------------- +// Client Portal (PRD §6.3) — milestone approvals with evidence +// --------------------------------------------------------------------------- + +model PortalApproval { + id String @id @default(cuid()) + projectId String + title String // e.g. "Design-Entwürfe freigeben" + description String? + status String @default("pending") // pending|approved|rejected|changes-requested + // evidence (PRD §6.3: "approvals with timestamp and IP evidence") + decidedAt DateTime? + decidedBy String? // client name + decidedIp String? // real client IP at decision time + decision String? // free-text reason/feedback + // content hash for tamper-evidence + contentHash String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + + diff --git a/src/app/api/aos/portal/approvals/[id]/decide/route.ts b/src/app/api/aos/portal/approvals/[id]/decide/route.ts new file mode 100644 index 0000000..f75d63c --- /dev/null +++ b/src/app/api/aos/portal/approvals/[id]/decide/route.ts @@ -0,0 +1,75 @@ +import { NextResponse } from 'next/server' +import { db } from '@/lib/db' +import { createHash } from 'crypto' + +export const dynamic = 'force-dynamic' + +// Extract the real client IP from the request (behind Caddy/proxy) +function getClientIp(req: Request): string { + const headers = new Headers(req.headers) + const xff = headers.get('x-forwarded-for') + if (xff) return xff.split(',')[0]!.trim() + const realIp = headers.get('x-real-ip') + if (realIp) return realIp + return 'unbekannt' +} + +// PATCH /api/aos/portal/approvals/[id]/decide +// Client decides on a milestone approval (approve / reject / changes-requested). +// Records REAL IP + timestamp + content hash (PRD §6.3: "approvals with +// timestamp and IP evidence"). Writes an audit entry. +export async function PATCH( + req: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params + const body = (await req.json()) as { decision: string; decidedBy?: string; feedback?: string } + + if (!['approved', 'rejected', 'changes-requested'].includes(body.decision)) { + return NextResponse.json({ error: 'invalid decision' }, { status: 400 }) + } + + const approval = await db.portalApproval.findUnique({ where: { id } }) + if (!approval) return NextResponse.json({ error: 'not found' }, { status: 404 }) + if (approval.status !== 'pending') return NextResponse.json({ error: 'already decided' }, { status: 409 }) + + const clientIp = getClientIp(req) + const contentHash = createHash('sha256') + .update([approval.id, approval.title, body.decision, new Date().toISOString()].join('|')) + .digest('hex') + + const updated = await db.portalApproval.update({ + where: { id }, + data: { + status: body.decision, + decidedAt: new Date(), + decidedBy: body.decidedBy ?? 'Kunde (via Portal)', + decidedIp: clientIp, + decision: body.feedback ?? null, + contentHash, + }, + }) + + // audit + await db.auditLog.create({ + data: { + actor: body.decidedBy ?? 'portal-client', + action: `portal.approval:${body.decision}`, + entity: 'PortalApproval', + entityId: id, + before: JSON.stringify({ status: 'pending' }), + after: JSON.stringify({ status: body.decision, ip: clientIp, contentHash, feedback: body.feedback?.slice(0, 200) }), + hash: 'sha256:' + Math.random().toString(36).slice(2), + }, + }) + + return NextResponse.json({ + approval: updated, + evidence: { + decidedAt: updated.decidedAt, + decidedIp: clientIp, + contentHash, + signatureLevel: 'AdES (eIDAS Art. 25 Abs. 2)', + }, + }) +} diff --git a/src/app/api/aos/portal/companies/[id]/route.ts b/src/app/api/aos/portal/companies/[id]/route.ts new file mode 100644 index 0000000..ddfa476 --- /dev/null +++ b/src/app/api/aos/portal/companies/[id]/route.ts @@ -0,0 +1,109 @@ +import { NextResponse } from 'next/server' +import { db } from '@/lib/db' + +export const dynamic = 'force-dynamic' + +// GET /api/aos/portal/companies/[id] — client portal dashboard data. +// Returns ONLY the data belonging to this company (project-scoped isolation). +export async function GET( + _req: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params + const company = await db.company.findUnique({ + where: { id }, + include: { + projects: { orderBy: { updatedAt: 'desc' }, include: { tasks: true, timeEntries: { orderBy: { date: 'desc' }, take: 5 } } }, + invoices: { orderBy: { issueDate: 'desc' }, include: { lines: true } }, + licenses: { include: { activations: true } }, + deals: { where: { stage: { not: 'lost' } } }, + }, + }) + if (!company) return NextResponse.json({ error: 'not found' }, { status: 404 }) + + // tickets for this company + const tickets = await db.ticket.findMany({ + where: { companyId: id }, + orderBy: { updatedAt: 'desc' }, + include: { messages: { orderBy: { createdAt: 'asc' } } }, + }) + + // portal approvals for this company's projects + const projectIds = company.projects.map((p) => p.id) + const approvals = await db.portalApproval.findMany({ + where: { projectId: { in: projectIds } }, + orderBy: { createdAt: 'desc' }, + }) + + // KPIs + const openProjects = company.projects.filter((p) => p.status === 'active' || p.status === 'planning' || p.status === 'review').length + const unpaidInvoices = company.invoices.filter((i) => i.status === 'sent' || i.status === 'overdue') + const unpaidCents = unpaidInvoices.reduce((s, i) => s + i.grossCents, 0) + const openTickets = tickets.filter((t) => t.status === 'open' || t.status === 'pending').length + const pendingApprovals = approvals.filter((a) => a.status === 'pending').length + const activeLicenses = company.licenses.filter((l) => l.status === 'active').length + + return NextResponse.json({ + company: { + id: company.id, + name: company.name, + legalForm: company.legalForm, + city: company.city, + }, + kpis: { openProjects, unpaidCents, unpaidInvoiceCount: unpaidInvoices.length, openTickets, pendingApprovals, activeLicenses }, + projects: company.projects.map((p) => ({ + id: p.id, + name: p.name, + type: p.type, + status: p.status, + progress: p.progress, + budgetType: p.budgetType, + spentHours: p.spentHours, + budgetHours: p.budgetHours, + dueDate: p.dueDate, + taskCount: p.tasks.length, + doneTasks: p.tasks.filter((t) => t.status === 'done').length, + })), + invoices: company.invoices.map((i) => ({ + id: i.id, + number: i.number, + status: i.status, + type: i.type, + issueDate: i.issueDate, + dueDate: i.dueDate, + grossCents: i.grossCents, + zugferd: i.zugferd, + xrechnung: i.xrechnung, + lineCount: i.lines.length, + })), + tickets: tickets.map((t) => ({ + id: t.id, + number: t.number, + subject: t.subject, + status: t.status, + priority: t.priority, + messageCount: t.messages.length, + lastMessageAt: t.messages[t.messages.length - 1]?.createdAt ?? t.createdAt, + })), + approvals: approvals.map((a) => ({ + id: a.id, + title: a.title, + description: a.description, + status: a.status, + decidedAt: a.decidedAt, + decidedBy: a.decidedBy, + decidedIp: a.decidedIp, + decision: a.decision, + createdAt: a.createdAt, + })), + licenses: company.licenses.map((l) => ({ + id: l.id, + key: l.key, + product: l.product, + plan: l.plan, + status: l.status, + validUntil: l.validUntil, + activations: l.activations.map((a) => ({ domain: a.domain, environment: a.environment })), + })), + }) +} diff --git a/src/app/api/aos/portal/companies/route.ts b/src/app/api/aos/portal/companies/route.ts new file mode 100644 index 0000000..e766891 --- /dev/null +++ b/src/app/api/aos/portal/companies/route.ts @@ -0,0 +1,30 @@ +import { NextResponse } from 'next/server' +import { db } from '@/lib/db' + +export const dynamic = 'force-dynamic' + +// GET /api/aos/portal/companies — list companies that can log into the portal +export async function GET() { + // A company can use the portal if it has projects, invoices, or tickets. + const companies = await db.company.findMany({ + where: { projects: { some: {} } }, + orderBy: { name: 'asc' }, + include: { + _count: { select: { projects: true, invoices: true, licenses: true } }, + persons: { where: { email: { not: null } }, take: 1 }, + }, + }) + return NextResponse.json({ + companies: companies.map((c) => ({ + id: c.id, + name: c.name, + legalForm: c.legalForm, + city: c.city, + projectCount: c._count.projects, + invoiceCount: c._count.invoices, + licenseCount: c._count.licenses, + contactName: c.persons[0] ? `${c.persons[0].firstName} ${c.persons[0].lastName}` : null, + contactEmail: c.persons[0]?.email ?? null, + })), + }) +} diff --git a/src/app/api/aos/seed/route.ts b/src/app/api/aos/seed/route.ts index 1935de2..9d4e6a8 100644 --- a/src/app/api/aos/seed/route.ts +++ b/src/app/api/aos/seed/route.ts @@ -20,6 +20,7 @@ export async function POST() { await db.bankTransaction.deleteMany() await db.webhook.deleteMany() await db.apiToken.deleteMany() + await db.portalApproval.deleteMany() await db.estimateLine.deleteMany() await db.estimate.deleteMany() await db.ticketMessage.deleteMany() diff --git a/src/components/aos/app-shell.tsx b/src/components/aos/app-shell.tsx index 373fd9e..b6c2610 100644 --- a/src/components/aos/app-shell.tsx +++ b/src/components/aos/app-shell.tsx @@ -23,9 +23,13 @@ import { AuditView } from './views/audit' import { ApiView } from './views/api' import { SettingsView } from './views/settings' import { SetupWizardView } from './views/setup-wizard' +import { PortalShell } from './portal-shell' export function AppShell() { - const { view, sidebarOpen, setSidebarOpen } = useAos() + const { view, sidebarOpen, setSidebarOpen, portalMode } = useAos() + + // Portal mode renders a completely separate experience + if (portalMode) return return (
diff --git a/src/components/aos/portal-shell.tsx b/src/components/aos/portal-shell.tsx new file mode 100644 index 0000000..58719ac --- /dev/null +++ b/src/components/aos/portal-shell.tsx @@ -0,0 +1,594 @@ +'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, +} from 'lucide-react' +import { formatEuro, formatDate, formatRelative, daysUntil } from '@/lib/format' +import { cn } from '@/lib/utils' +import { toast } from 'sonner' +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 ( +
+
+

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). + + +
+
+ +