cfbb20c1-e953-4788-abed-5c8308f329ee
This commit is contained in:
1 parent
be34f88822
commit
9feb124da6
18 files changed
+998
-4
No files matched your search
Binary file not shown.
|
After Width: | Height: | Size: 162 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 162 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 162 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 162 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 92 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 162 KiB |
+122
@@ -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 ==="
|
||||
Binary file not shown.
@@ -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
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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)',
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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 })),
|
||||
})),
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})),
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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 <PortalShell />
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen w-full bg-background">
|
||||
|
||||
@@ -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 <PortalLogin onBack={() => setPortalMode(false)} onSelect={(id) => setPortalCompany(id)} />
|
||||
}
|
||||
|
||||
if (portalMode && portalCompanyId) {
|
||||
return (
|
||||
<PortalApp
|
||||
companyId={portalCompanyId}
|
||||
tab={tab}
|
||||
setTab={setTab}
|
||||
onExit={() => { 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 (
|
||||
<div className="flex min-h-screen flex-col bg-muted/30">
|
||||
<header className="flex h-16 items-center gap-3 border-b border-border bg-background px-4 md:px-6">
|
||||
<Button variant="ghost" size="sm" className="gap-1.5" onClick={onBack}>
|
||||
<ArrowLeft className="size-4" />Zur Agentur-Ansicht
|
||||
</Button>
|
||||
<div className="ml-auto flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<ShieldCheck className="size-3.5" />Client-Portal · projektbezogener Zugriff
|
||||
</div>
|
||||
</header>
|
||||
<main className="mx-auto w-full max-w-3xl flex-1 p-4 md:p-8">
|
||||
<div className="mb-6 space-y-1.5">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Client-Portal</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Wähle dein Unternehmen. Du siehst ausschließlich deine eigenen Projekte, Rechnungen, Tickets und Freigaben — projektbezogen isoliert (PRD §5.6).
|
||||
</p>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<Input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Firma suchen…" className="max-w-sm" />
|
||||
</div>
|
||||
{loading || !data ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-20 w-full" />)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{data.companies
|
||||
.filter((c) => c.name.toLowerCase().includes(q.toLowerCase()))
|
||||
.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => onSelect(c.id)}
|
||||
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"
|
||||
>
|
||||
<div className="flex size-12 items-center justify-center rounded-md bg-muted">
|
||||
<Building2 className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-semibold">{c.name}</span>
|
||||
{c.legalForm && <Badge variant="outline" className="text-[10px]">{c.legalForm}</Badge>}
|
||||
</div>
|
||||
<p className="mt-0.5 truncate text-xs text-muted-foreground">{c.contactName ?? '—'} · {c.contactEmail ?? '—'}</p>
|
||||
<div className="mt-1.5 flex items-center gap-3 text-[11px] text-muted-foreground">
|
||||
<span className="flex items-center gap-1"><FolderKanban className="size-3" />{c.projectCount} Projekte</span>
|
||||
<span className="flex items-center gap-1"><Receipt className="size-3" />{c.invoiceCount} Rechnungen</span>
|
||||
<span className="flex items-center gap-1"><KeyRound className="size-3" />{c.licenseCount} Lizenzen</span>
|
||||
</div>
|
||||
</div>
|
||||
<ArrowLeft className="size-4 rotate-180 text-muted-foreground" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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<PortalData>(`/api/aos/portal/companies/${companyId}`)
|
||||
|
||||
if (loading || !data) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-muted/30">
|
||||
<header className="flex h-16 items-center gap-3 border-b border-border bg-background px-4 md:px-6">
|
||||
<Skeleton className="h-8 w-32" />
|
||||
</header>
|
||||
<main className="flex-1 p-6"><Skeleton className="h-96 w-full" /></main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-muted/30">
|
||||
{/* Portal header */}
|
||||
<header className="flex h-16 items-center gap-3 border-b border-border bg-background px-4 md:px-6">
|
||||
<Button variant="ghost" size="sm" className="gap-1.5" onClick={onExit}>
|
||||
<ArrowLeft className="size-4" />Agentur-Ansicht
|
||||
</Button>
|
||||
<div className="ml-2 flex items-center gap-2">
|
||||
<div className="flex size-9 items-center justify-center rounded-md bg-primary text-primary-foreground">
|
||||
<Building2 className="size-4" />
|
||||
</div>
|
||||
<div className="leading-tight">
|
||||
<p className="text-sm font-semibold">{data.company.name}</p>
|
||||
<p className="text-[10px] text-muted-foreground">Client-Portal · {data.company.legalForm ?? ''} {data.company.city ?? ''}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<ShieldCheck className="size-3.5" />Projekt-isolierter Zugriff
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Tab nav */}
|
||||
<div className="border-b border-border bg-background px-4 md:px-6">
|
||||
<div className="flex gap-1 overflow-x-auto py-2">
|
||||
{([
|
||||
{ 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 (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => setTab(t.id)}
|
||||
className={cn(
|
||||
'flex shrink-0 items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition',
|
||||
active ? 'bg-muted text-foreground' : 'text-muted-foreground hover:bg-muted/50'
|
||||
)}
|
||||
>
|
||||
<Icon className="size-3.5" />
|
||||
{t.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<main className="flex-1 p-4 md:p-6">
|
||||
{tab === 'overview' && <PortalOverview data={data} setTab={setTab} />}
|
||||
{tab === 'approvals' && <PortalApprovals data={data} refetch={refetch} />}
|
||||
{tab === 'invoices' && <PortalInvoices data={data} />}
|
||||
{tab === 'tickets' && <PortalTickets data={data} />}
|
||||
{tab === 'licenses' && <PortalLicenses data={data} />}
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PortalOverview({ data, setTab }: { data: PortalData; setTab: (t: 'overview' | 'approvals' | 'invoices' | 'tickets' | 'licenses') => void }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
{/* Pending approvals */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-sm"><Clock className="size-3.5 text-muted-foreground" />Wartende Freigaben</CardTitle>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={() => setTab('approvals')}>Alle</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{data.approvals.filter((a) => a.status === 'pending').slice(0, 4).map((a) => (
|
||||
<div key={a.id} className="rounded-md border border-border p-3">
|
||||
<p className="text-xs font-medium">{a.title}</p>
|
||||
{a.description && <p className="mt-0.5 text-[11px] text-muted-foreground">{a.description}</p>}
|
||||
<p className="mt-1 text-[10px] text-muted-foreground">Eingereicht {formatRelative(a.createdAt)}</p>
|
||||
</div>
|
||||
))}
|
||||
{data.approvals.filter((a) => a.status === 'pending').length === 0 && (
|
||||
<p className="py-4 text-center text-xs text-muted-foreground">Keine offenen Freigaben.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Projects */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2"><CardTitle className="text-sm">Meine Projekte ({data.projects.length})</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{data.projects.slice(0, 4).map((p) => (
|
||||
<div key={p.id} className="rounded-md border border-border p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="truncate text-xs font-medium">{p.name}</p>
|
||||
<Badge variant="outline" className="text-[10px] capitalize">{p.status}</Badge>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between text-[10px] text-muted-foreground">
|
||||
<span>{p.doneTasks}/{p.taskCount} Aufgaben</span>
|
||||
<span>{p.progress}%</span>
|
||||
</div>
|
||||
<Progress value={p.progress} className="mt-1 h-1" />
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Recent invoices */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2"><CardTitle className="text-sm">Letzte Rechnungen</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">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>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{data.invoices.slice(0, 5).map((i) => (
|
||||
<tr key={i.id}>
|
||||
<td className="px-4 py-2.5"><code className="font-mono text-[11px]">{i.number}</code></td>
|
||||
<td className="px-4 py-2.5 text-xs">{formatDate(i.issueDate)}</td>
|
||||
<td className="px-4 py-2.5 text-xs">{formatDate(i.dueDate)}</td>
|
||||
<td className="px-4 py-2.5 text-right text-xs font-semibold tabular-nums">{formatEuro(i.grossCents)}</td>
|
||||
<td className="px-4 py-2.5"><Badge variant="outline" className="text-[10px] capitalize">{i.status}</Badge></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PortalApprovals({ data, refetch }: { data: PortalData; refetch: () => void }) {
|
||||
const [deciding, setDeciding] = useState<string | null>(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<string, { label: string; cls: string }> = {
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="size-5 text-muted-foreground" />
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">Freigaben</h1>
|
||||
<p className="text-xs text-muted-foreground">Meilenstein-Freigaben mit Zeitstempel- und IP-Evidenz (PRD §6.3).</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{data.approvals.map((a) => {
|
||||
const meta = statusMeta[a.status] ?? statusMeta.pending
|
||||
return (
|
||||
<Card key={a.id} className="p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium">{a.title}</p>
|
||||
<Badge variant="outline" className={cn('text-[10px]', meta.cls)}>{meta.label}</Badge>
|
||||
</div>
|
||||
{a.description && <p className="mt-1 text-xs text-muted-foreground">{a.description}</p>}
|
||||
{a.status !== 'pending' && (
|
||||
<div className="mt-2 space-y-0.5 text-[10px] text-muted-foreground">
|
||||
<p>Entschieden: {formatDate(a.decidedAt!)} von {a.decidedBy}</p>
|
||||
<p>IP-Evidenz: <code className="font-mono">{a.decidedIp}</code></p>
|
||||
{a.decision && <p className="italic">„{a.decision}"</p>}
|
||||
</div>
|
||||
)}
|
||||
{a.status === 'pending' && (
|
||||
<p className="mt-1 text-[10px] text-muted-foreground">Eingereicht {formatRelative(a.createdAt)}</p>
|
||||
)}
|
||||
</div>
|
||||
{a.status === 'pending' && (
|
||||
<div className="flex shrink-0 gap-1.5">
|
||||
<Button size="sm" className="h-8 gap-1 text-xs" onClick={() => openDecision(a.id, 'approved')}>
|
||||
<Check className="size-3.5" />Freigeben
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="h-8 gap-1 text-xs" onClick={() => openDecision(a.id, 'changes-requested')}>
|
||||
<AlertTriangle className="size-3.5" />Änderung
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="h-8 gap-1 text-xs text-muted-foreground" onClick={() => openDecision(a.id, 'rejected')}>
|
||||
<X className="size-3.5" />Ablehnen
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
{data.approvals.length === 0 && <p className="py-8 text-center text-xs text-muted-foreground">Keine Freigaben.</p>}
|
||||
</div>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{decision === 'approved' && 'Freigabe bestätigen'}
|
||||
{decision === 'rejected' && 'Freigabe ablehnen'}
|
||||
{decision === 'changes-requested' && 'Änderungswunsch senden'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Deine Entscheidung wird mit Zeitstempel und IP-Adresse gespeichert (eIDAS Art. 25 AdES).
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Kommentar / Feedback (optional)</Label>
|
||||
<Textarea value={feedback} onChange={(e) => setFeedback(e.target.value)} placeholder="z.B. Bitte Kontrast erhöhen…" className="min-h-[80px] text-sm" />
|
||||
</div>
|
||||
<div className="rounded-md border border-dashed border-border p-2.5 text-[10px] text-muted-foreground">
|
||||
<p className="flex items-center gap-1.5"><ShieldCheck className="size-3" />Evidenz: {new Date().toLocaleString('de-DE')} · IP wird erfasst</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>Abbrechen</Button>
|
||||
<Button onClick={submit} disabled={busy} className="gap-1.5">
|
||||
{busy && <Loader2 className="size-3.5 animate-spin" />}
|
||||
{decision === 'approved' ? 'Freigeben' : decision === 'rejected' ? 'Ablehnen' : 'Senden'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PortalInvoices({ data }: { data: PortalData }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Receipt className="size-5 text-muted-foreground" />
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">Rechnungen ({data.invoices.length})</h1>
|
||||
<p className="text-xs text-muted-foreground">Deine Rechnungen — Ansicht nur für dein Unternehmen.</p>
|
||||
</div>
|
||||
</div>
|
||||
<Card>
|
||||
<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">Typ</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">E-Rechnung</th>
|
||||
<th className="px-4 py-2 font-medium">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{data.invoices.map((i) => {
|
||||
const due = daysUntil(i.dueDate)
|
||||
return (
|
||||
<tr key={i.id}>
|
||||
<td className="px-4 py-2.5"><code className="font-mono text-[11px]">{i.number}</code></td>
|
||||
<td className="px-4 py-2.5 text-xs text-muted-foreground">{i.type}</td>
|
||||
<td className="px-4 py-2.5 text-xs">{formatDate(i.issueDate)}</td>
|
||||
<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>
|
||||
<td className="px-4 py-2.5 text-right text-xs font-semibold tabular-nums">{formatEuro(i.grossCents)}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex gap-1">
|
||||
{i.zugferd && <Badge variant="outline" className="text-[9px]">ZUGFeRD</Badge>}
|
||||
{i.xrechnung && <Badge variant="outline" className="text-[9px]">XRechnung</Badge>}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5"><Badge variant="outline" className="text-[10px] capitalize">{i.status}</Badge></td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PortalTickets({ data }: { data: PortalData }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<LifeBuoy className="size-5 text-muted-foreground" />
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">Tickets ({data.tickets.length})</h1>
|
||||
<p className="text-xs text-muted-foreground">Support-Anfragen und Konversationen.</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm"><LifeBuoy className="size-4" />Neues Ticket</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{data.tickets.map((t) => (
|
||||
<Card key={t.id} className="p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-[10px] font-mono text-muted-foreground">{t.number}</code>
|
||||
{t.priority === 'urgent' && <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-1 text-[10px] text-muted-foreground">{t.messageCount} Nachrichten · letzte {formatRelative(t.lastMessageAt)}</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-[10px] capitalize">{t.status}</Badge>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
{data.tickets.length === 0 && <p className="py-8 text-center text-xs text-muted-foreground">Keine Tickets.</p>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PortalLicenses({ data }: { data: PortalData }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<KeyRound className="size-5 text-muted-foreground" />
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">Lizenzen ({data.licenses.length})</h1>
|
||||
<p className="text-xs text-muted-foreground">Deine Produkt-Lizenzen und Aktivierungen.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{data.licenses.map((l) => (
|
||||
<Card key={l.id} className="p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium">{l.product}</p>
|
||||
<Badge variant="outline" className="text-[10px] capitalize">{l.plan}</Badge>
|
||||
</div>
|
||||
<code className="mt-1 block text-[10px] font-mono text-muted-foreground">{l.key}</code>
|
||||
<div className="mt-1.5 space-y-0.5 text-[10px] text-muted-foreground">
|
||||
{l.activations.map((a, i) => <p key={i}>↳ {a.domain} ({a.environment})</p>)}
|
||||
{l.validUntil && <p>Gültig bis: {formatDate(l.validUntil)}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-[10px] capitalize">{l.status}</Badge>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
Rocket,
|
||||
BookOpen,
|
||||
Webhook,
|
||||
ExternalLink,
|
||||
} from 'lucide-react'
|
||||
import { useAos, type ViewId } from '@/store/aos'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -74,7 +75,7 @@ const NAV: {
|
||||
]
|
||||
|
||||
export function SidebarNav() {
|
||||
const { view, setView, setSidebarOpen, setCommandOpen } = useAos()
|
||||
const { view, setView, setSidebarOpen, setCommandOpen, setPortalMode } = useAos()
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-sidebar text-sidebar-foreground">
|
||||
@@ -145,7 +146,7 @@ export function SidebarNav() {
|
||||
</nav>
|
||||
|
||||
{/* AI status */}
|
||||
<div className="mx-3 mb-3 rounded-md border border-sidebar-border bg-background/40 p-3">
|
||||
<div className="mx-3 mb-2 rounded-md border border-sidebar-border bg-background/40 p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex size-7 items-center justify-center rounded bg-muted">
|
||||
<Bot className="size-3.5 text-muted-foreground" />
|
||||
@@ -157,6 +158,17 @@ export function SidebarNav() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Client Portal entry */}
|
||||
<div className="mx-3 mb-3">
|
||||
<button
|
||||
onClick={() => setPortalMode(true)}
|
||||
className="flex w-full items-center gap-2.5 rounded-md border border-dashed border-sidebar-border bg-background/60 px-3 py-2.5 text-xs font-medium text-muted-foreground transition hover:border-foreground/40 hover:text-foreground"
|
||||
>
|
||||
<ExternalLink className="size-3.5" />
|
||||
<span>Client-Portal öffnen</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* User */}
|
||||
<div className="border-t border-sidebar-border px-3 py-3">
|
||||
<Button variant="ghost" className="h-auto w-full justify-start gap-3 px-2 py-2">
|
||||
|
||||
@@ -673,6 +673,22 @@ async function main() {
|
||||
await db.auditLog.create({ data: { actor: 'nora@nordlicht.digital', action: 'bank.import:camt053', entity: 'BankTransaction', entityId: 'seed', after: '{"count":10,"batch":"camt-2026-10"}', hash: 'sha256:' + Math.random().toString(36).slice(2) } })
|
||||
await db.auditLog.create({ data: { actor: 'nora@nordlicht.digital', action: 'bank.match:invoice', entity: 'BankTransaction', entityId: 'seed', after: '{"invoiceId":"matched","status":"matched"}', hash: 'sha256:' + Math.random().toString(36).slice(2) } })
|
||||
|
||||
// --- Portal approvals (client milestone approvals with evidence) ---------
|
||||
const portalApprovals = [
|
||||
{ project: 'SEO-Optimierung & BFGG-Check', title: 'SEO-Audit & Keyword-Analyse', description: 'Audit-Bericht mit Keyword-Strategie und Wettbewerbsanalyse.', status: 'approved', decidedAt: daysFromNow(-8), decidedBy: 'Dr. med. Iris Brandt', decidedIp: '91.64.224.12', decision: 'Sehr gute Analyse, bitte umsetzen.' },
|
||||
{ project: 'SEO-Optimierung & BFGG-Check', title: 'Schema.org LocalBusiness Markup', description: 'Strukturierte Daten implementiert, Test via Google Rich Results.', status: 'approved', decidedAt: daysFromNow(-5), decidedBy: 'Dr. med. Iris Brandt', decidedIp: '91.64.224.12', decision: 'Freigegeben.' },
|
||||
{ project: 'SEO-Optimierung & BFGG-Check', title: 'BFSG Accessibility-Audit', description: 'axe-Audit abgeschlossen, 12 findings (3 kritisch).', status: 'changes-requested', decidedAt: daysFromNow(-3), decidedBy: 'Dr. med. Iris Brandt', decidedIp: '91.64.224.18', decision: 'Bitte Kontrast auf Buttons erhöhen und Skip-Link nach oben setzen.' },
|
||||
{ project: 'Corporate Website Relaunch', title: 'Design-Tokens abnehmen', description: 'Farbpalette, Typografie, Spacing-System.', status: 'approved', decidedAt: daysFromNow(-1), decidedBy: 'Klaus Bauer', decidedIp: '85.183.99.44', decision: 'Sieht super aus, weiter so!' },
|
||||
{ project: 'Corporate Website Relaunch', title: 'Wireframes Startseite', description: '3 Varianten: klassisch, hero-fokus, story-telling.', status: 'pending' },
|
||||
{ project: 'Intranet-Portal Q4', title: 'SSO-Integration abnehmen', description: 'Better Auth SSO mit vorhandenen AD-Accounts.', status: 'pending' },
|
||||
{ project: 'Website-Redesign Konzept', title: 'Moodboard freigeben', description: '2 Moodboards: maritim-elegant vs. modern-minimal.', status: 'pending' },
|
||||
{ project: 'SaaS MVP — Auth & Billing', title: 'Onboarding-Flow (3 Schritte)', description: 'Signup → Team erstellen → Plan wählen.', status: 'pending' },
|
||||
]
|
||||
for (const pa of portalApprovals) {
|
||||
const { project, ...rest } = pa
|
||||
await db.portalApproval.create({ data: { ...rest, projectId: projectMap[project], contentHash: 'sha256:' + Math.random().toString(36).slice(2) } })
|
||||
}
|
||||
|
||||
// --- API Tokens + Webhooks (real crypto, hashed at rest) -----------------
|
||||
const crypto = await import('crypto')
|
||||
const apiTokens = [
|
||||
|
||||
+10
-1
@@ -32,6 +32,11 @@ interface AosState {
|
||||
setSidebarOpen: (open: boolean) => void
|
||||
commandOpen: boolean
|
||||
setCommandOpen: (open: boolean) => void
|
||||
// portal mode (PRD §6.3 client portal)
|
||||
portalMode: boolean
|
||||
portalCompanyId: string | null
|
||||
setPortalMode: (mode: boolean) => void
|
||||
setPortalCompany: (id: string | null) => void
|
||||
}
|
||||
|
||||
export const useAos = create<AosState>()(
|
||||
@@ -47,7 +52,11 @@ export const useAos = create<AosState>()(
|
||||
setSidebarOpen: (sidebarOpen) => set({ sidebarOpen }),
|
||||
commandOpen: false,
|
||||
setCommandOpen: (commandOpen) => set({ commandOpen }),
|
||||
portalMode: false,
|
||||
portalCompanyId: null,
|
||||
setPortalMode: (portalMode) => set({ portalMode }),
|
||||
setPortalCompany: (portalCompanyId) => set({ portalCompanyId }),
|
||||
}),
|
||||
{ name: 'aos-ui', partialize: (s) => ({ view: s.view }) }
|
||||
{ name: 'aos-ui', partialize: (s) => ({ view: s.view, portalMode: s.portalMode, portalCompanyId: s.portalCompanyId }) }
|
||||
)
|
||||
)
|
||||
Reference in new issue
Block a user