diff --git a/aos-shots/v21-dark-crm.png b/aos-shots/v21-dark-crm.png new file mode 100644 index 0000000..0144779 Binary files /dev/null and b/aos-shots/v21-dark-crm.png differ diff --git a/aos-shots/v21-dark-dashboard.png b/aos-shots/v21-dark-dashboard.png new file mode 100644 index 0000000..d8d8e89 Binary files /dev/null and b/aos-shots/v21-dark-dashboard.png differ diff --git a/aos-shots/v21-dark-finance.png b/aos-shots/v21-dark-finance.png new file mode 100644 index 0000000..7c5c11d Binary files /dev/null and b/aos-shots/v21-dark-finance.png differ diff --git a/aos-shots/v21-dark-portal.png b/aos-shots/v21-dark-portal.png new file mode 100644 index 0000000..b365ee3 Binary files /dev/null and b/aos-shots/v21-dark-portal.png differ diff --git a/src/app/api/aos/portal/companies/[id]/notifications/route.ts b/src/app/api/aos/portal/companies/[id]/notifications/route.ts new file mode 100644 index 0000000..7edda26 --- /dev/null +++ b/src/app/api/aos/portal/companies/[id]/notifications/route.ts @@ -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, + }, + }) +} diff --git a/src/components/aos/portal-shell.tsx b/src/components/aos/portal-shell.tsx index 58719ac..667420d 100644 --- a/src/components/aos/portal-shell.tsx +++ b/src/components/aos/portal-shell.tsx @@ -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 }: {
Client-Portal · {data.company.legalForm ?? ''} {data.company.city ?? ''}
-+ Du siehst ausschließlich deine eigenen Daten — projektbezogen isoliert (PRD §5.6). + {data.kpis.pendingApprovals > 0 && {data.kpis.pendingApprovals} Freigabe(n) warten auf dich.} +
+Offene Projekte
{data.kpis.openProjects}
Offene Freigaben
{data.kpis.pendingApprovals}
Keine offenen Benachrichtigungen.
+{n.title}
+{n.detail}
+