echos-ocr/src/components/notifications/notification-center.tsx
Randall Stillwell f53b08f99f Add dynamic fields, people directory, analytics, security hardening, and UX polish
Phase 1 - Security & Bug Fixes:
- Add requireApiAuth helper and protect all 25 unprotected API routes
- Add org-tenant scoping to all card, job, stats, and notification queries
- Fix SSRF in ai-test, mask secrets in settings API, fix middleware bypass
- Fix cards pagination routing, stat filter sync, drag-drop file passing
- Add PUT /api/auth/me for profile persistence, stuck job recovery
- Fix email watcher MIME type detection

Phase 2 - Dynamic Fields & Digital Survey:
- Add FormTemplate, FormField, Person, PasswordResetToken models to schema
- Add fieldData, formTemplateId, firstName, lastName, personId to ResponseCard
- Build FormTemplate CRUD API with field management and org scoping
- Build Form Builder UI with field ordering, type config, and section management
- Refactor card detail page to render fields dynamically from templates
- Add dynamic OCR prompt/schema generation from template fields
- Build public survey page at /s/[orgSlug]/[formSlug] with branding
- Add QR code generation API and share section component

Phase 3 - People & Analytics:
- Build People CRUD API with merge and batch auto-link endpoints
- Build People list and detail pages with search, merge dialog
- Add auto-link logic in OCR completion to match/create Person records
- Add /api/stats/trends endpoint with time series and team activity
- Build Reports page with Recharts (area charts, bar charts, pipeline)
- Upgrade dashboard with sparklines and People stat card

Phase 4 - UX Polish:
- Replace silent error handling with toast notifications across all pages
- Add loading skeletons, differentiated empty states
- Add ARIA labels, skip-to-content link, accessible column toggle
- Add forgot password flow, Cmd+K command palette, Collection Days pages
- Unify Echo branding and theme toggle consistency

Made-with: Cursor
2026-04-16 23:29:26 -05:00

236 lines
7.9 KiB
TypeScript

"use client";
import * as React from "react";
import Link from "next/link";
import {
Bell,
CheckCircle2,
AlertCircle,
RefreshCw,
Mail,
Monitor,
LayoutGrid,
Globe,
Clock,
CheckCheck,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { toast } from "sonner";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
type Notification = {
id: string;
createdAt: string;
read: boolean;
type: string;
title: string;
message: string;
cardId: string | null;
actionUrl: string | null;
};
export function NotificationCenter() {
const [open, setOpen] = React.useState(false);
const [notifications, setNotifications] = React.useState<Notification[]>([]);
const [unreadCount, setUnreadCount] = React.useState(0);
const [listLoading, setListLoading] = React.useState(false);
const fetchUnreadCount = React.useCallback(async () => {
try {
const res = await fetch("/api/notifications?unreadOnly=true&limit=1");
if (res.ok) {
const data = await res.json();
setUnreadCount(data.unreadCount ?? 0);
}
} catch {
// Polling failure — don't toast on every interval tick
}
}, []);
React.useEffect(() => {
fetchUnreadCount();
const interval = setInterval(fetchUnreadCount, 30_000);
return () => clearInterval(interval);
}, [fetchUnreadCount]);
const fetchNotifications = React.useCallback(async () => {
setListLoading(true);
try {
const res = await fetch("/api/notifications?limit=30");
if (res.ok) {
const data = await res.json();
setNotifications(data.notifications ?? []);
setUnreadCount(data.unreadCount ?? 0);
}
} catch {
toast.error("Failed to load notifications");
} finally {
setListLoading(false);
}
}, []);
React.useEffect(() => {
if (open) fetchNotifications();
}, [open, fetchNotifications]);
const markAllRead = async () => {
try {
await fetch("/api/notifications", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "mark_all_read" }),
});
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
setUnreadCount(0);
} catch {
toast.error("Failed to mark notifications as read");
}
};
const markRead = async (id: string) => {
try {
await fetch("/api/notifications", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "mark_read", id }),
});
setNotifications((prev) => prev.map((n) => n.id === id ? { ...n, read: true } : n));
setUnreadCount((c) => Math.max(0, c - 1));
} catch {
toast.error("Failed to update notification");
}
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={
<Button variant="ghost" size="icon" className="relative size-9 rounded-xl" />
}
>
<Bell className="size-4" />
{unreadCount > 0 && (
<span className="absolute -right-0.5 -top-0.5 flex size-4.5 items-center justify-center rounded-full bg-red-500 text-[10px] font-bold text-white">
{unreadCount > 9 ? "9+" : unreadCount}
</span>
)}
<span className="sr-only">Notifications</span>
</PopoverTrigger>
<PopoverContent align="end" sideOffset={8} className="w-80 p-0">
<div className="flex items-center justify-between border-b border-border/50 px-3 py-2.5">
<span className="text-sm font-medium">Notifications</span>
{unreadCount > 0 && (
<Button variant="ghost" size="sm" className="h-7 text-xs px-2" onClick={markAllRead}>
<CheckCheck className="mr-1 size-3" /> Mark all read
</Button>
)}
</div>
<div className="max-h-80 overflow-y-auto">
{listLoading ? (
<div className="space-y-1 p-3">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex gap-3 py-2">
<Skeleton className="size-7 shrink-0 rounded-full" />
<div className="flex-1 space-y-1.5">
<Skeleton className="h-3 w-3/4 rounded" />
<Skeleton className="h-3 w-1/2 rounded" />
</div>
</div>
))}
</div>
) : notifications.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 text-muted-foreground">
<Bell className="size-8 mb-2 opacity-20" />
<p className="text-sm">No notifications</p>
</div>
) : (
notifications.map((n) => (
<NotificationItem key={n.id} notification={n} onRead={markRead} onClose={() => setOpen(false)} />
))
)}
</div>
</PopoverContent>
</Popover>
);
}
function NotificationItem({
notification: n,
onRead,
onClose,
}: {
notification: Notification;
onRead: (id: string) => void;
onClose: () => void;
}) {
const iconMap: Record<string, { icon: React.ReactNode; color: string }> = {
ocr_complete: { icon: <CheckCircle2 className="size-3.5" />, color: "text-emerald-600 bg-emerald-500/10" },
ocr_error: { icon: <AlertCircle className="size-3.5" />, color: "text-red-600 bg-red-500/10" },
card_needs_review: { icon: <RefreshCw className="size-3.5" />, color: "text-amber-600 bg-amber-500/10" },
monday_sync: { icon: <LayoutGrid className="size-3.5" />, color: "text-blue-600 bg-blue-500/10" },
monday_error: { icon: <LayoutGrid className="size-3.5" />, color: "text-red-600 bg-red-500/10" },
webhook_error: { icon: <Globe className="size-3.5" />, color: "text-red-600 bg-red-500/10" },
email_watcher: { icon: <Mail className="size-3.5" />, color: "text-purple-600 bg-purple-500/10" },
system: { icon: <Monitor className="size-3.5" />, color: "text-muted-foreground bg-muted" },
};
const { icon, color } = iconMap[n.type] ?? iconMap.system;
const handleClick = () => {
if (!n.read) onRead(n.id);
if (n.actionUrl) onClose();
};
const inner = (
<>
<div className={cn("flex size-7 shrink-0 items-center justify-center rounded-full mt-0.5", color)}>
{icon}
</div>
<div className="min-w-0 flex-1 text-left">
<div className="flex items-center gap-1.5">
<span className={cn("text-xs font-medium truncate", !n.read && "text-foreground")}>{n.title}</span>
{!n.read && <span className="size-1.5 shrink-0 rounded-full bg-primary" />}
</div>
<p className="text-xs text-muted-foreground line-clamp-2 mt-0.5">{n.message}</p>
<span className="text-[10px] text-muted-foreground/70 flex items-center gap-1 mt-1">
<Clock className="size-2.5" />
{formatTimeAgo(n.createdAt)}
</span>
</div>
</>
);
const sharedClasses = cn(
"flex w-full gap-3 px-3 py-2.5 border-b border-border/30 transition-colors hover:bg-muted/50 cursor-pointer",
!n.read && "bg-primary/[0.03]"
);
if (n.actionUrl) {
return (
<Link href={n.actionUrl} className={sharedClasses} onClick={handleClick}>
{inner}
</Link>
);
}
return (
<button type="button" className={sharedClasses} onClick={handleClick}>
{inner}
</button>
);
}
function formatTimeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return "just now";
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}d ago`;
return new Date(dateStr).toLocaleDateString();
}