30f7b0f9-9de3-4087-a91e-c351421922db

This commit is contained in:
Z User committed 2026-10-27 00:35:50 +00:00
1 parent 09ca75d36e
commit 9b5477020c
6 files changed
+161 -2

No files matched your search

+90 -2
View File
@@ -35,10 +35,17 @@ import {
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
@@ -212,8 +219,11 @@ function PortalApp({ companyId, tab, setTab, onExit }: {
<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 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" />Projekt-isolierter Zugriff
</div>
</div>
</header>
@@ -262,6 +272,28 @@ function PortalApp({ companyId, tab, setTab, onExit }: {
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 — projektbezogen isoliert (PRD §5.6).
{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>
@@ -592,3 +624,59 @@ function PortalLicenses({ data }: { data: PortalData }) {
</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>
)
}