Client Portal privacy (şirket sırrı): - Removed PRD references from client-facing text - Removed 'Projekt-isolierter Zugriff' technical term - Changed to 'Sichere Verbindung' / 'Sicher geschützt' - Onboarding overlay only shows to agency users (not portal) Haben-cloned features (Almanya/EU muhasebe uyumu): - Belege: content-addressed receipts (SHA-256 hash, GoBD retention 8/6 years) - Pauschalen: Homeoffice (€6.92/day), Kilometer (€0.30/km), Verpflegung (€14/€24/€28) - EStG § 4 Abs. 5, § 9 Abs. 1 compliant rates (2026) - Anlagen & AfA: Anlagenverzeichnis with linear depreciation - GWG (≤€800 Sofortabschreibung, § 6 Abs. 2 EStG) - Sammelposten (€251-€1000, § 6 Abs. 2a EStG, 5 years) - Annual AfA computation + accumulated AfA + book value - Kassenbuch: cash book (§ 146 AO, § 22 UStG) - Immutable (GoBD), running balance, audit-logged - Gapless numbering (KAS-2026-XXXX) Seed data: 5 Belege, 7 Pauschalen, 5 Anlagen, 5 Kassenbuch entries TypeScript: 0 errors | ESLint: clean
683 lines
31 KiB
TypeScript
Executable File
683 lines
31 KiB
TypeScript
Executable File
'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 <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" />Sichere Verbindung
|
|
</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.
|
|
</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-2">
|
|
<PortalNotificationsBell companyId={data.company.id} />
|
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
<ShieldCheck className="size-3.5" />Sicher geschützt
|
|
</div>
|
|
</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">
|
|
{/* Welcome banner */}
|
|
<Card className="border-foreground/20 bg-gradient-to-br from-foreground/5 to-transparent">
|
|
<CardContent className="flex items-center gap-4 p-5">
|
|
<div className="flex size-12 items-center justify-center rounded-xl bg-foreground text-background">
|
|
<Building2 className="size-6" />
|
|
</div>
|
|
<div className="flex-1">
|
|
<h2 className="text-lg font-semibold">Willkommen, {data.company.name}</h2>
|
|
<p className="text-xs text-muted-foreground">
|
|
Du siehst ausschließlich deine eigenen Daten — sicher und geschützt.
|
|
{data.kpis.pendingApprovals > 0 && <span className="font-medium text-foreground"> {data.kpis.pendingApprovals} Freigabe(n) warten auf dich.</span>}
|
|
</p>
|
|
</div>
|
|
{data.kpis.pendingApprovals > 0 && (
|
|
<Button size="sm" className="gap-1.5" onClick={() => setTab('approvals')}>
|
|
<CheckCircle2 className="size-4" />
|
|
Freigaben ({data.kpis.pendingApprovals})
|
|
</Button>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<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.</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>
|
|
)
|
|
}
|
|
|
|
// Portal notifications bell — client-scoped
|
|
function PortalNotificationsBell({ companyId }: { companyId: string }) {
|
|
const [open, setOpen] = useState(false)
|
|
const { data } = useFetch<{ notifications: { id: string; type: string; title: string; detail: string; module: string; daysUntil: number }[]; stats: { total: number; urgent: number; warning: number; info: number } }>(`/api/aos/portal/companies/${companyId}/notifications`)
|
|
|
|
const notifs = data?.notifications ?? []
|
|
const urgent = data?.stats.urgent ?? 0
|
|
|
|
return (
|
|
<Popover open={open} onOpenChange={setOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button variant="ghost" size="icon" className="relative" aria-label="Benachrichtigungen">
|
|
<Bell className="size-4" />
|
|
{urgent > 0 && (
|
|
<span className="absolute right-1 top-1 flex size-4 items-center justify-center rounded-full bg-foreground text-[9px] font-bold text-background">
|
|
{urgent}
|
|
</span>
|
|
)}
|
|
{urgent === 0 && notifs.length > 0 && (
|
|
<span className="absolute right-1.5 top-1.5 size-1.5 rounded-full bg-foreground/50" />
|
|
)}
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent align="end" className="w-72 p-0">
|
|
<div className="flex items-center justify-between border-b border-border px-3 py-2.5">
|
|
<span className="text-xs font-semibold">Deine Benachrichtigungen</span>
|
|
{notifs.length > 0 && <Badge variant="outline" className="text-[10px]">{notifs.length}</Badge>}
|
|
</div>
|
|
<ScrollArea className="h-72">
|
|
{notifs.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center gap-2 py-10 text-center">
|
|
<CheckCircle2 className="size-7 text-muted-foreground/50" />
|
|
<p className="text-xs text-muted-foreground">Keine offenen Benachrichtigungen.</p>
|
|
</div>
|
|
) : (
|
|
<div className="divide-y divide-border">
|
|
{notifs.map((n) => {
|
|
const Icon = n.type === 'urgent' ? AlertTriangle : n.type === 'warning' ? Clock : Info
|
|
return (
|
|
<div key={n.id} className="flex items-start gap-2.5 px-3 py-2.5">
|
|
<Icon className={cn('mt-0.5 size-3.5 shrink-0', n.type === 'urgent' ? 'text-foreground' : n.type === 'warning' ? 'text-foreground/70' : 'text-muted-foreground')} />
|
|
<div className="min-w-0 flex-1">
|
|
<p className="truncate text-xs font-medium">{n.title}</p>
|
|
<p className="mt-0.5 text-[10px] text-muted-foreground">{n.detail}</p>
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</ScrollArea>
|
|
</PopoverContent>
|
|
</Popover>
|
|
)
|
|
}
|