30f7b0f9-9de3-4087-a91e-c351421922db
This commit is contained in:
1 parent
09ca75d36e
commit
9b5477020c
6 files changed
+161
-2
No files matched your search
Binary file not shown.
|
After Width: | Height: | Size: 119 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 177 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 173 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 92 KiB |
@@ -0,0 +1,71 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { db } from '@/lib/db'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
// GET /api/aos/portal/companies/[id]/notifications
|
||||||
|
// Client-scoped notifications: only this company's own alerts.
|
||||||
|
// (PRD §5.6: "Portal users are isolated by project membership")
|
||||||
|
export async function GET(
|
||||||
|
_req: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const { id } = await params
|
||||||
|
const company = await db.company.findUnique({ where: { id } })
|
||||||
|
if (!company) return NextResponse.json({ error: 'not found' }, { status: 404 })
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const in7Days = new Date(now.getTime() + 7 * 86400000)
|
||||||
|
|
||||||
|
const [invoices, tickets, approvals, licenses, projects] = await Promise.all([
|
||||||
|
db.invoice.findMany({ where: { companyId: id, status: { in: ['sent', 'overdue'] } } }),
|
||||||
|
db.ticket.findMany({ where: { companyId: id, status: { in: ['open', 'pending'] } } }),
|
||||||
|
db.portalApproval.findMany({ where: { status: 'pending', project: { companyId: id } }, include: { project: true } }),
|
||||||
|
db.license.findMany({ where: { companyId: id, status: 'active', validUntil: { not: null } } }),
|
||||||
|
db.project.findMany({ where: { companyId: id, status: { in: ['active', 'planning', 'review'] } } }),
|
||||||
|
])
|
||||||
|
|
||||||
|
type Notif = { id: string; type: 'urgent' | 'warning' | 'info'; title: string; detail: string; module: string; daysUntil: number }
|
||||||
|
const notifs: Notif[] = []
|
||||||
|
|
||||||
|
// Overdue invoices
|
||||||
|
for (const inv of invoices.filter((i) => i.status === 'overdue')) {
|
||||||
|
const days = Math.round((now.getTime() - inv.dueDate.getTime()) / 86400000)
|
||||||
|
notifs.push({ id: `inv-overdue-${inv.id}`, type: 'urgent', module: 'invoices', title: `Rechnung ${inv.number} überfällig`, detail: `${(inv.grossCents / 100).toFixed(2)} € · ${days} Tage`, daysUntil: -days })
|
||||||
|
}
|
||||||
|
// Invoices due soon
|
||||||
|
for (const inv of invoices.filter((i) => i.status === 'sent' && i.dueDate < in7Days)) {
|
||||||
|
const days = Math.round((inv.dueDate.getTime() - now.getTime()) / 86400000)
|
||||||
|
notifs.push({ id: `inv-due-${inv.id}`, type: 'warning', module: 'invoices', title: `Rechnung ${inv.number} fällig`, detail: `${(inv.grossCents / 100).toFixed(2)} € · in ${days} Tagen`, daysUntil: days })
|
||||||
|
}
|
||||||
|
// Pending approvals
|
||||||
|
for (const a of approvals) {
|
||||||
|
notifs.push({ id: `appr-${a.id}`, type: 'info', module: 'approvals', title: `Freigabe wartet: ${a.title}`, detail: a.project?.name ?? '—', daysUntil: 0 })
|
||||||
|
}
|
||||||
|
// Open tickets
|
||||||
|
for (const t of tickets) {
|
||||||
|
notifs.push({ id: `tkt-${t.id}`, type: t.priority === 'urgent' ? 'urgent' : 'info', module: 'tickets', title: `Ticket ${t.number}: ${t.subject}`, detail: `Status: ${t.status}`, daysUntil: 0 })
|
||||||
|
}
|
||||||
|
// Expiring licenses
|
||||||
|
for (const l of licenses.filter((l) => l.validUntil! < in7Days)) {
|
||||||
|
const days = Math.round((l.validUntil!.getTime() - now.getTime()) / 86400000)
|
||||||
|
notifs.push({ id: `lic-${l.id}`, type: days < 0 ? 'urgent' : 'warning', module: 'licenses', title: `Lizenz ${l.product} ${days < 0 ? 'abgelaufen' : 'läuft ab'}`, detail: days < 0 ? `${Math.abs(days)} Tage drüber` : `in ${days} Tagen`, daysUntil: days })
|
||||||
|
}
|
||||||
|
// Active projects (info)
|
||||||
|
if (projects.length > 0) {
|
||||||
|
notifs.push({ id: 'proj-active', type: 'info', module: 'projects', title: `${projects.length} aktive Projekte`, detail: projects.map((p) => p.name).join(', ').slice(0, 80), daysUntil: 0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeOrder = { urgent: 0, warning: 1, info: 2 }
|
||||||
|
notifs.sort((a, b) => typeOrder[a.type] - typeOrder[b.type])
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
notifications: notifs,
|
||||||
|
stats: {
|
||||||
|
total: notifs.length,
|
||||||
|
urgent: notifs.filter((n) => n.type === 'urgent').length,
|
||||||
|
warning: notifs.filter((n) => n.type === 'warning').length,
|
||||||
|
info: notifs.filter((n) => n.type === 'info').length,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -35,10 +35,17 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
|
Bell,
|
||||||
|
Info,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { formatEuro, formatDate, formatRelative, daysUntil } from '@/lib/format'
|
import { formatEuro, formatDate, formatRelative, daysUntil } from '@/lib/format'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@/components/ui/popover'
|
||||||
import { Footer } from './footer'
|
import { Footer } from './footer'
|
||||||
|
|
||||||
void _db
|
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>
|
<p className="text-[10px] text-muted-foreground">Client-Portal · {data.company.legalForm ?? ''} {data.company.city ?? ''}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-auto flex items-center gap-1.5 text-xs text-muted-foreground">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
<ShieldCheck className="size-3.5" />Projekt-isolierter Zugriff
|
<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>
|
</div>
|
||||||
</header>
|
</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 }) {
|
function PortalOverview({ data, setTab }: { data: PortalData; setTab: (t: 'overview' | 'approvals' | 'invoices' | 'tickets' | 'licenses') => void }) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<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">
|
<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 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 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>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in new issue
Block a user