PreRelease v1
This commit is contained in:
295
components/views/dashboard/charts.tsx
Normal file
295
components/views/dashboard/charts.tsx
Normal file
@@ -0,0 +1,295 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
BarChart,
|
||||
Bar,
|
||||
Line,
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
|
||||
type TrendData = Array<{ date: string; count: number }>;
|
||||
type TypeData = Array<{ type: string; count: number }>;
|
||||
type StatusData = Array<{ name: string; count: number }>;
|
||||
|
||||
const PIE_COLORS: Record<string, string> = {
|
||||
Uploaded: "hsl(38 92% 50%)",
|
||||
Processing: "hsl(var(--primary))",
|
||||
Analyzed: "hsl(160 84% 39%)",
|
||||
Failed: "hsl(var(--destructive))",
|
||||
};
|
||||
|
||||
const FALLBACK_COLORS = [
|
||||
"hsl(var(--primary))",
|
||||
"hsl(var(--secondary))",
|
||||
"hsl(var(--accent))",
|
||||
"hsl(var(--destructive))",
|
||||
];
|
||||
|
||||
const tooltipStyle = {
|
||||
backgroundColor: "hsl(var(--background))",
|
||||
border: "1px solid hsl(var(--border))",
|
||||
borderRadius: "12px",
|
||||
color: "hsl(var(--foreground))",
|
||||
};
|
||||
|
||||
export function TrendChart({ data }: { data: TrendData }) {
|
||||
const trendData = useMemo(
|
||||
() =>
|
||||
data.map((point, index) => {
|
||||
const start = Math.max(0, index - 6);
|
||||
const window = data.slice(start, index + 1);
|
||||
const average =
|
||||
window.reduce((sum, item) => sum + item.count, 0) / window.length;
|
||||
|
||||
return {
|
||||
...point,
|
||||
movingAverage: Number(average.toFixed(2)),
|
||||
};
|
||||
}),
|
||||
[data],
|
||||
);
|
||||
|
||||
const xAxisInterval =
|
||||
trendData.length > 12 ? Math.floor(trendData.length / 8) : 0;
|
||||
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart
|
||||
data={trendData}
|
||||
margin={{ top: 10, right: 10, left: -24, bottom: 0 }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="trendFill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor="hsl(var(--primary))"
|
||||
stopOpacity={0.65}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor="hsl(var(--primary))"
|
||||
stopOpacity={0.05}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="hsl(var(--border))"
|
||||
vertical={false}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
interval={xAxisInterval}
|
||||
tick={{ fontSize: 12 }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
allowDecimals={false}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={tooltipStyle}
|
||||
formatter={(
|
||||
value: number | string | undefined,
|
||||
name: string | number | undefined,
|
||||
) => {
|
||||
const numericValue = Number(value ?? 0);
|
||||
if (name === "movingAverage") {
|
||||
return [numericValue.toFixed(1), "7-day avg"];
|
||||
}
|
||||
|
||||
return [numericValue, "Uploads"];
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="count"
|
||||
stroke="hsl(var(--primary))"
|
||||
strokeWidth={2.25}
|
||||
fillOpacity={1}
|
||||
fill="url(#trendFill)"
|
||||
activeDot={{ r: 5 }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="movingAverage"
|
||||
stroke="hsl(var(--secondary))"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ContractTypeChart({ data }: { data: TypeData }) {
|
||||
const sortedData = useMemo(
|
||||
() => [...data].sort((a, b) => b.count - a.count),
|
||||
[data],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={sortedData}
|
||||
layout="vertical"
|
||||
margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="hsl(var(--border))"
|
||||
horizontal={false}
|
||||
/>
|
||||
<XAxis
|
||||
type="number"
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
allowDecimals={false}
|
||||
tick={{ fontSize: 12 }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="type"
|
||||
width={128}
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
tick={{ fontSize: 12 }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={tooltipStyle}
|
||||
cursor={false}
|
||||
formatter={(value: number | string | undefined) => [
|
||||
Number(value ?? 0),
|
||||
"Files",
|
||||
]}
|
||||
/>
|
||||
<Bar dataKey="count" radius={[0, 8, 8, 0]}>
|
||||
{sortedData.map((item, index) => {
|
||||
const opacity = Math.max(0.35, 0.95 - index * 0.12);
|
||||
return (
|
||||
<Cell
|
||||
key={`${item.type}-${index}`}
|
||||
fill={`hsl(var(--primary) / ${opacity})`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ContractStatusChart({ data }: { data: StatusData }) {
|
||||
const total = useMemo(
|
||||
() => data.reduce((sum, item) => sum + item.count, 0),
|
||||
[data],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col">
|
||||
<div className="h-[76%] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={62}
|
||||
outerRadius={94}
|
||||
paddingAngle={3}
|
||||
dataKey="count"
|
||||
stroke="hsl(var(--background))"
|
||||
strokeWidth={2}
|
||||
>
|
||||
{data.map((entry, index) => (
|
||||
<Cell
|
||||
key={`${entry.name}-${index}`}
|
||||
fill={
|
||||
PIE_COLORS[entry.name] ??
|
||||
FALLBACK_COLORS[index % FALLBACK_COLORS.length]
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
{total > 0 && (
|
||||
<text
|
||||
x="50%"
|
||||
y="50%"
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
>
|
||||
<tspan
|
||||
x="50%"
|
||||
y="50%"
|
||||
className="fill-foreground text-base font-semibold"
|
||||
>
|
||||
{total}
|
||||
</tspan>
|
||||
<tspan
|
||||
x="50%"
|
||||
dy="16"
|
||||
className="fill-muted-foreground text-[11px]"
|
||||
>
|
||||
Files
|
||||
</tspan>
|
||||
</text>
|
||||
)}
|
||||
<Tooltip
|
||||
contentStyle={tooltipStyle}
|
||||
formatter={(value: number | string | undefined) => [
|
||||
Number(value ?? 0),
|
||||
"Files",
|
||||
]}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 pt-2">
|
||||
{data.map((item, index) => {
|
||||
const color =
|
||||
PIE_COLORS[item.name] ??
|
||||
FALLBACK_COLORS[index % FALLBACK_COLORS.length];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${item.name}-legend`}
|
||||
className="flex items-center gap-2 rounded-lg border border-border/50 bg-muted/25 px-2.5 py-1.5"
|
||||
>
|
||||
<span
|
||||
className="h-2.5 w-2.5 rounded-full"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
<span className="text-[11px] text-muted-foreground truncate">
|
||||
{item.name}
|
||||
</span>
|
||||
<span className="ml-auto text-[11px] font-medium text-foreground">
|
||||
{item.count}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
44
components/views/dashboard/contacts-header.tsx
Normal file
44
components/views/dashboard/contacts-header.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft, ShieldCheck, Sparkles } from "lucide-react";
|
||||
import { BackgroundBeams } from "@/components/ui/background-beams";
|
||||
|
||||
export function ContactsHeader() {
|
||||
return (
|
||||
<div className="border-b border-border/50 bg-background/80 backdrop-blur-sm">
|
||||
<BackgroundBeams className="opacity-70" />
|
||||
<div className="max-w-7xl mx-auto px-6 py-8 space-y-6">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Back to Dashboard
|
||||
</Link>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h1 className="text-4xl md:text-5xl font-semibold tracking-tight bg-gradient-to-r from-primary via-accent to-secondary bg-clip-text text-transparent">
|
||||
Contracts Manager
|
||||
</h1>
|
||||
<p className="max-w-3xl text-lg text-muted-foreground">
|
||||
Upload, review, and analyze your financial contracts with a focused
|
||||
workspace built for speed and clarity.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3 pt-1">
|
||||
<div className="flex items-center gap-2 rounded-full border border-primary/20 bg-primary/10 px-4 py-2 text-sm font-medium text-primary">
|
||||
<Sparkles className="w-4 h-4" />
|
||||
AI-powered review
|
||||
</div>
|
||||
<div className="flex items-center gap-2 rounded-full border border-emerald-400/20 bg-emerald-400/10 px-4 py-2 text-sm font-medium text-emerald-500">
|
||||
<ShieldCheck className="w-4 h-4" />
|
||||
Compliance-focused workflow
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
141
components/views/dashboard/contract-upload-form.tsx
Normal file
141
components/views/dashboard/contract-upload-form.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import { UploadDropzone } from "@uploadthing/react";
|
||||
import { AlertCircle, Sparkles, Wand2, ShieldCheck } from "lucide-react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { saveContract } from "@/lib/actions/contract.action";
|
||||
import { toast } from "sonner";
|
||||
import type { OurFileRouter } from "@/lib/upload";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export function ContractUploadForm({
|
||||
onUploadSuccess,
|
||||
}: {
|
||||
onUploadSuccess: () => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
|
||||
const emitNotificationRefresh = () => {
|
||||
window.dispatchEvent(new Event("notifications:refresh"));
|
||||
const channel = new BroadcastChannel("notifications-channel");
|
||||
channel.postMessage({ type: "notifications:refresh" });
|
||||
channel.close();
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="relative overflow-hidden border border-border/60 bg-[radial-gradient(circle_at_top_right,hsl(var(--primary)/0.14),transparent_45%),radial-gradient(circle_at_bottom_left,hsl(var(--secondary)/0.1),transparent_42%)] p-0">
|
||||
<div className="pointer-events-none absolute inset-0">
|
||||
<svg
|
||||
viewBox="0 0 480 220"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
className="absolute -right-8 top-0 h-40 w-80 opacity-45"
|
||||
>
|
||||
<path
|
||||
d="M8 176C76 140 114 68 198 68C260 68 286 116 346 116C394 116 430 88 474 74"
|
||||
stroke="hsl(var(--primary))"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M10 204C72 174 122 146 186 146C250 146 294 178 350 178C400 178 434 162 474 146"
|
||||
stroke="hsl(var(--secondary))"
|
||||
strokeWidth="2"
|
||||
strokeDasharray="5 7"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="relative p-6 md:p-8">
|
||||
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="inline-flex items-center gap-1.5 rounded-full border border-primary/25 bg-primary/10 px-3 py-1 text-[11px] font-medium uppercase tracking-[0.14em] text-primary">
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
AI-Ready Intake
|
||||
</p>
|
||||
<h3 className="mt-3 text-xl font-semibold tracking-tight text-foreground">
|
||||
Upload contracts for structured extraction
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Clean intake pipeline for contract parsing, validation, and
|
||||
analysis.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 rounded-xl border border-border/60 bg-background/70 px-3 py-2 text-xs text-muted-foreground">
|
||||
<ShieldCheck className="h-4 w-4 text-emerald-500" />
|
||||
Theme-aware secure upload
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UploadDropzone<OurFileRouter, "contractUploader">
|
||||
endpoint="contractUploader"
|
||||
onClientUploadComplete={async (res) => {
|
||||
if (!res || res.length === 0) {
|
||||
toast.error("Upload failed");
|
||||
return;
|
||||
}
|
||||
|
||||
const file = res[0];
|
||||
|
||||
// Save to database
|
||||
const result = await saveContract({
|
||||
fileName: file.name,
|
||||
fileUrl: file.url,
|
||||
fileSize: file.size,
|
||||
mimeType: file.type,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success("Contract uploaded successfully!");
|
||||
emitNotificationRefresh();
|
||||
onUploadSuccess();
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(result.error || "Failed to save contract");
|
||||
}
|
||||
}}
|
||||
onUploadError={(error: Error) => {
|
||||
toast.error(`Upload failed: ${error.message}`);
|
||||
}}
|
||||
appearance={{
|
||||
container:
|
||||
"w-full cursor-pointer rounded-2xl border border-dashed border-primary/35 bg-background/85 px-4 py-8 backdrop-blur-sm transition-all duration-300 hover:border-primary/55 hover:bg-background ut-uploading:cursor-not-allowed",
|
||||
button:
|
||||
"bg-gradient-to-r from-primary to-accent text-white font-semibold px-6 py-3 rounded-xl transition-all duration-300 hover:from-primary/90 hover:to-accent/90 ut-uploading:cursor-not-allowed",
|
||||
label: "text-base md:text-lg text-foreground font-semibold",
|
||||
uploadIcon: "w-11 h-11 text-primary",
|
||||
allowedContent: "mt-2 text-sm text-muted-foreground",
|
||||
}}
|
||||
content={{
|
||||
label: "Upload Your Contract",
|
||||
allowedContent: "PDF, JPG, PNG, WEBP up to 8MB",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="mt-6 grid gap-3 border-t border-border/50 pt-5 sm:grid-cols-3">
|
||||
<div className="rounded-xl border border-border/50 bg-muted/25 px-3 py-2 text-xs text-muted-foreground">
|
||||
<div className="mb-1 font-semibold text-foreground">Formats</div>
|
||||
<div>PDF, JPG, PNG, WEBP</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border/50 bg-muted/25 px-3 py-2 text-xs text-muted-foreground">
|
||||
<div className="mb-1 font-semibold text-foreground">Max Size</div>
|
||||
<div>8 MB</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border/50 bg-muted/25 px-3 py-2 text-xs text-muted-foreground flex items-start gap-2">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 flex-shrink-0 text-accent" />
|
||||
<div>
|
||||
<div className="mb-1 font-semibold text-foreground">AI Flow</div>
|
||||
<div>Upload first, then click Analyze when ready</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 inline-flex items-center gap-2 rounded-lg border border-border/50 bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
|
||||
<Wand2 className="h-3.5 w-3.5 text-secondary" />
|
||||
Extraction quality improves as more contracts are analyzed.
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
1078
components/views/dashboard/contracts-list.tsx
Normal file
1078
components/views/dashboard/contracts-list.tsx
Normal file
File diff suppressed because it is too large
Load Diff
47
components/views/dashboard/empty-contracts-state.tsx
Normal file
47
components/views/dashboard/empty-contracts-state.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { FileText, Inbox } from "lucide-react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
||||
export function EmptyContractsState() {
|
||||
return (
|
||||
<Card className="border-dashed border-border hover:border-primary/50 transition-colors duration-300">
|
||||
<div className="p-12 md:p-16 flex flex-col items-center justify-center min-h-[300px]">
|
||||
<div className="mb-6 relative">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-primary/20 to-accent/20 rounded-full blur-3xl"></div>
|
||||
<div className="relative p-4 bg-background dark:bg-card rounded-full border border-border/50">
|
||||
<Inbox className="w-8 h-8 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center max-w-md">
|
||||
<h3 className="text-xl font-semibold text-foreground mb-2">
|
||||
No contracts yet
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-1">
|
||||
Upload your first contract to get started.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Our AI will automatically analyze and extract key information from
|
||||
your documents.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid grid-cols-3 gap-4 w-full text-center text-xs">
|
||||
<div className="p-3 rounded-lg bg-primary/5 dark:bg-primary/10 border border-primary/20">
|
||||
<FileText className="w-5 h-5 mx-auto mb-2 text-primary" />
|
||||
<span className="text-muted-foreground">Fast Upload</span>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-accent/5 dark:bg-accent/10 border border-accent/20">
|
||||
<FileText className="w-5 h-5 mx-auto mb-2 text-accent" />
|
||||
<span className="text-muted-foreground">AI Analysis</span>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-secondary/5 dark:bg-secondary/10 border border-secondary/20">
|
||||
<FileText className="w-5 h-5 mx-auto mb-2 text-secondary" />
|
||||
<span className="text-muted-foreground">Blockchain</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
162
components/views/dashboard/navigation.tsx
Normal file
162
components/views/dashboard/navigation.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { BarChart3, FileText, LogOut } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SignOutButton, UserButton } from "@clerk/nextjs";
|
||||
import { motion } from "motion/react";
|
||||
import Image from "next/image";
|
||||
import { ModeToggle } from "@/components/ui/mode-toggle";
|
||||
import NotificationBar from "./notification-bar";
|
||||
|
||||
interface NavItem {
|
||||
href: string;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{
|
||||
href: "/dashboard",
|
||||
label: "Analytics",
|
||||
icon: <BarChart3 className="w-5 h-5" />,
|
||||
description: "View your statistics",
|
||||
},
|
||||
{
|
||||
href: "/contacts",
|
||||
label: "Contracts",
|
||||
icon: <FileText className="w-5 h-5" />,
|
||||
description: "Manage contracts",
|
||||
},
|
||||
];
|
||||
|
||||
export function DashboardNavigation() {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<div className="fixed left-0 top-0 h-screen w-72 border-r border-border/60 bg-background/95 backdrop-blur-xl flex flex-col overflow-hidden">
|
||||
<div className="pointer-events-none absolute inset-0">
|
||||
<div className="absolute -top-16 left-1/2 h-44 w-44 -translate-x-1/2 rounded-full bg-primary/12 blur-3xl" />
|
||||
<svg
|
||||
viewBox="0 0 320 400"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
className="absolute bottom-0 left-0 w-full opacity-50"
|
||||
>
|
||||
<path
|
||||
d="M0 338C56 312 82 252 142 252C194 252 214 302 266 302C294 302 306 290 320 276"
|
||||
stroke="hsl(var(--primary))"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M0 372C52 342 88 322 132 322C176 322 212 346 252 346C282 346 304 338 320 326"
|
||||
stroke="hsl(var(--secondary))"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray="4 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Logo section */}
|
||||
<div className="relative z-10 p-6 border-b border-border/40">
|
||||
<Link href="/dashboard" className="flex items-center gap-3 group">
|
||||
<Image
|
||||
src="/LexiChain.png"
|
||||
alt="LexiCHAIN Logo"
|
||||
width={32}
|
||||
height={32}
|
||||
className="w-8 h-8"
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-semibold text-foreground">LexiChain</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Operations Console
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Navigation items */}
|
||||
<nav className="relative z-10 flex-1 p-4 space-y-2 overflow-y-auto">
|
||||
{navItems.map((item) => {
|
||||
const isActive = pathname === item.href;
|
||||
return (
|
||||
<motion.div
|
||||
key={item.href}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<Link href={item.href}>
|
||||
<div
|
||||
className={`relative p-3 rounded-lg transition-all duration-200 cursor-pointer group ${
|
||||
isActive
|
||||
? "bg-primary/10 border border-primary/20"
|
||||
: "hover:bg-muted/60 border border-transparent"
|
||||
}`}
|
||||
>
|
||||
{isActive && (
|
||||
<motion.div
|
||||
layoutId="active-nav"
|
||||
className="absolute inset-0 rounded-lg bg-primary/5"
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 30,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="relative z-10 flex items-center gap-3">
|
||||
<span
|
||||
className={`transition-colors ${
|
||||
isActive
|
||||
? "text-primary"
|
||||
: "text-muted-foreground group-hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{item.icon}
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<div
|
||||
className={`font-medium text-sm ${isActive ? "text-foreground" : "text-muted-foreground group-hover:text-foreground"}`}
|
||||
>
|
||||
{item.label}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{item.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Bottom section */}
|
||||
<div className="relative z-10 p-4 border-t border-border/40 space-y-3">
|
||||
<div className="rounded-xl border border-border/60 bg-gradient-to-r from-muted/35 via-background to-muted/25 px-3 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<UserButton afterSignOutUrl="/" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<NotificationBar />
|
||||
<ModeToggle />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2 text-[11px] text-muted-foreground">
|
||||
<span className="h-2 w-2 rounded-full bg-emerald-500" />
|
||||
Trusted workspace
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
617
components/views/dashboard/notification-bar.tsx
Normal file
617
components/views/dashboard/notification-bar.tsx
Normal file
@@ -0,0 +1,617 @@
|
||||
/**
|
||||
* Notification Bar Component
|
||||
*
|
||||
* Displays a beautiful notification bar/dropdown for showing:
|
||||
* - Unread notifications count
|
||||
* - Recent notifications from users' actions
|
||||
* - Deadline/renewal alerts
|
||||
* - Notification management (mark as read, delete)
|
||||
*
|
||||
* Features:
|
||||
* - Real-time badge count
|
||||
* - Smooth animations
|
||||
* - Theme support (dark/light mode)
|
||||
* - Responsive design
|
||||
* - Auto-refresh on intervals
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
Bell,
|
||||
X,
|
||||
Check,
|
||||
CheckCheck,
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Info,
|
||||
Clock,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import {
|
||||
getNotifications,
|
||||
getUnreadNotificationCount,
|
||||
markNotificationAsRead,
|
||||
markAllNotificationsAsRead,
|
||||
deleteNotification,
|
||||
checkDeadlineNotifications,
|
||||
} from "@/lib/actions/notification.action";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Notification type interface matching the database structure
|
||||
*/
|
||||
interface Notification {
|
||||
id: string;
|
||||
type: "SUCCESS" | "WARNING" | "ERROR" | "INFO" | "DEADLINE";
|
||||
title: string;
|
||||
message: string;
|
||||
read: boolean;
|
||||
createdAt: string;
|
||||
icon?: string;
|
||||
actionType?: string;
|
||||
contractId?: string;
|
||||
contract?: {
|
||||
id: string;
|
||||
title: string;
|
||||
fileName: string;
|
||||
endDate?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the appropriate icon component based on notification type
|
||||
*
|
||||
* Mapping:
|
||||
* - SUCCESS: CheckCircle2 (green)
|
||||
* - WARNING: AlertTriangle (yellow/orange)
|
||||
* - ERROR: AlertCircle (red)
|
||||
* - INFO: Info (blue)
|
||||
* - DEADLINE: Clock (critical/urgent)
|
||||
*/
|
||||
const getNotificationIcon = (type: Notification["type"], icon?: string) => {
|
||||
switch (type) {
|
||||
case "SUCCESS":
|
||||
return <CheckCircle2 className="h-5 w-5 text-green-500" />;
|
||||
case "WARNING":
|
||||
return <AlertTriangle className="h-5 w-5 text-yellow-500" />;
|
||||
case "ERROR":
|
||||
return <AlertCircle className="h-5 w-5 text-red-500" />;
|
||||
case "DEADLINE":
|
||||
return <Clock className="h-5 w-5 text-red-500" />;
|
||||
case "INFO":
|
||||
default:
|
||||
return <Info className="h-5 w-5 text-blue-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the color styling based on notification type
|
||||
* Used for background and border colors in the notification card
|
||||
*/
|
||||
const getNotificationColor = (type: Notification["type"]) => {
|
||||
switch (type) {
|
||||
case "SUCCESS":
|
||||
return "bg-green-50 border-green-200 dark:bg-green-950/30 dark:border-green-800";
|
||||
case "WARNING":
|
||||
return "bg-yellow-50 border-yellow-200 dark:bg-yellow-950/30 dark:border-yellow-800";
|
||||
case "ERROR":
|
||||
return "bg-red-50 border-red-200 dark:bg-red-950/30 dark:border-red-800";
|
||||
case "DEADLINE":
|
||||
return "bg-red-50 border-red-200 dark:bg-red-950/30 dark:border-red-800";
|
||||
case "INFO":
|
||||
default:
|
||||
return "bg-blue-50 border-blue-200 dark:bg-blue-950/30 dark:border-blue-800";
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Single Notification Item Component
|
||||
*
|
||||
* Displays an individual notification with:
|
||||
* - Type-specific icon and coloring
|
||||
* - Title and message
|
||||
* - Timestamp
|
||||
* - Action buttons (mark as read, delete)
|
||||
* - Visual indicator for unread status
|
||||
*/
|
||||
const NotificationItem: React.FC<{
|
||||
notification: Notification;
|
||||
onRead: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}> = ({ notification, onRead, onDelete }) => {
|
||||
const formatTime = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return "just now";
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
|
||||
return date.toLocaleDateString();
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
"flex items-start gap-3 p-3 border transition-all hover:shadow-sm",
|
||||
getNotificationColor(notification.type),
|
||||
!notification.read && "ring-1 ring-primary/20",
|
||||
)}
|
||||
>
|
||||
{/* Notification Icon */}
|
||||
<div className="mt-1 flex-shrink-0">
|
||||
{getNotificationIcon(notification.type, notification.icon)}
|
||||
</div>
|
||||
|
||||
{/* Notification Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<p className="font-semibold text-sm text-foreground line-clamp-1">
|
||||
{notification.title}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">
|
||||
{notification.message}
|
||||
</p>
|
||||
|
||||
{/* Contract link if available */}
|
||||
{notification.contract && (
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
📄 {notification.contract.title}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Unread dot indicator */}
|
||||
{!notification.read && (
|
||||
<div className="h-2 w-2 rounded-full bg-primary flex-shrink-0 mt-1" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Timestamp */}
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
{formatTime(notification.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{!notification.read && (
|
||||
<button
|
||||
onClick={() => onRead(notification.id)}
|
||||
className="p-1.5 hover:bg-white/20 dark:hover:bg-white/10 rounded-md transition-colors"
|
||||
title="Mark as read"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => onDelete(notification.id)}
|
||||
className="p-1.5 hover:bg-white/20 dark:hover:bg-white/10 rounded-md transition-colors"
|
||||
title="Delete notification"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Main Notification Bar Component
|
||||
*
|
||||
* Displays:
|
||||
* 1. Bell icon with unread count badge
|
||||
* 2. Dropdown showing recent notifications
|
||||
* 3. Ability to mark as read individually or all at once
|
||||
* 4. Action buttons and time formatting
|
||||
*/
|
||||
export default function NotificationBar() {
|
||||
// State management
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const [panelPosition, setPanelPosition] = useState({ top: 70, left: 250 });
|
||||
const channelRef = useRef<BroadcastChannel | null>(null);
|
||||
|
||||
const updatePanelPosition = useCallback(() => {
|
||||
if (!triggerRef.current) return;
|
||||
|
||||
const rect = triggerRef.current.getBoundingClientRect();
|
||||
const panelWidth = 420;
|
||||
const panelHeightEstimate = panelRef.current?.offsetHeight ?? 240;
|
||||
const viewportPadding = 12;
|
||||
|
||||
const preferredLeft = rect.right + 14;
|
||||
const maxLeft = window.innerWidth - panelWidth - viewportPadding;
|
||||
const left = Math.max(viewportPadding, Math.min(preferredLeft, maxLeft));
|
||||
|
||||
// Keep the panel visually balanced around the bell row,
|
||||
// with a slightly higher anchor so it does not feel too low.
|
||||
const triggerCenterY = (rect.top + rect.bottom) / 2;
|
||||
const preferredTop = triggerCenterY - panelHeightEstimate * 0.42;
|
||||
const maxTop = window.innerHeight - viewportPadding - panelHeightEstimate;
|
||||
const top = Math.max(viewportPadding, Math.min(preferredTop, maxTop));
|
||||
|
||||
setPanelPosition({ top, left });
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Fetches unread notifications and deadline checks
|
||||
*
|
||||
* Steps:
|
||||
* 1. Check for upcoming deadlines and create notifications
|
||||
* 2. Fetch unread notifications from database
|
||||
* 3. Update state with fresh notification data
|
||||
* 4. Get unread count for badge display
|
||||
*/
|
||||
const fetchNotifications = useCallback(
|
||||
async (options?: { showLoader?: boolean }) => {
|
||||
const showLoader = options?.showLoader ?? false;
|
||||
|
||||
try {
|
||||
if (showLoader) {
|
||||
setIsLoading(true);
|
||||
}
|
||||
|
||||
// Fetch unread notifications
|
||||
const response = await getNotifications(15);
|
||||
if (response.success) {
|
||||
setNotifications(response.data || []);
|
||||
}
|
||||
|
||||
// Update unread count
|
||||
const countResponse = await getUnreadNotificationCount();
|
||||
if (countResponse.success) {
|
||||
setUnreadCount(countResponse.data?.count || 0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching notifications:", error);
|
||||
} finally {
|
||||
if (showLoader) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const notifyRealtimeRefresh = useCallback(() => {
|
||||
void fetchNotifications({ showLoader: false });
|
||||
}, [fetchNotifications]);
|
||||
|
||||
const broadcastRealtimeRefresh = useCallback(() => {
|
||||
const event = new Event("notifications:refresh");
|
||||
window.dispatchEvent(event);
|
||||
channelRef.current?.postMessage({ type: "notifications:refresh" });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMounted) return;
|
||||
|
||||
// Cross-tab realtime notifications without polling loops.
|
||||
const channel = new BroadcastChannel("notifications-channel");
|
||||
channelRef.current = channel;
|
||||
|
||||
const onMessage = (message: MessageEvent<{ type?: string }>) => {
|
||||
if (message.data?.type === "notifications:refresh") {
|
||||
void fetchNotifications({ showLoader: false });
|
||||
}
|
||||
};
|
||||
|
||||
channel.addEventListener("message", onMessage);
|
||||
|
||||
return () => {
|
||||
channel.removeEventListener("message", onMessage);
|
||||
channel.close();
|
||||
channelRef.current = null;
|
||||
};
|
||||
}, [isMounted, fetchNotifications]);
|
||||
|
||||
/**
|
||||
* Handles marking a notification as read
|
||||
*
|
||||
* For non-deadline notifications: Automatically deletes after marking as read to save storage
|
||||
* For deadline notifications: Keeps them to show ongoing contract expiry info
|
||||
*
|
||||
* Updates local state immediately for optimistic UI
|
||||
* Then calls server action to persist to database
|
||||
*/
|
||||
const handleMarkAsRead = useCallback(
|
||||
async (notificationId: string) => {
|
||||
// Find the notification to check its type
|
||||
const notification = notifications.find((n) => n.id === notificationId);
|
||||
const isDeadlineNotification = notification?.type === "DEADLINE";
|
||||
|
||||
if (isDeadlineNotification) {
|
||||
// For DEADLINE notifications: just mark as read
|
||||
setNotifications((prev) =>
|
||||
prev.map((notif) =>
|
||||
notif.id === notificationId ? { ...notif, read: true } : notif,
|
||||
),
|
||||
);
|
||||
setUnreadCount((prev) => Math.max(0, prev - 1));
|
||||
|
||||
// Persist to database
|
||||
await markNotificationAsRead(notificationId);
|
||||
} else {
|
||||
// For non-deadline notifications: delete after marking read (auto-cleanup)
|
||||
// Optimistic update: remove from UI immediately
|
||||
setNotifications((prev) =>
|
||||
prev.filter((notif) => notif.id !== notificationId),
|
||||
);
|
||||
setUnreadCount((prev) => Math.max(0, prev - 1));
|
||||
|
||||
// Persist to database: delete the notification
|
||||
await deleteNotification(notificationId);
|
||||
}
|
||||
|
||||
broadcastRealtimeRefresh();
|
||||
},
|
||||
[notifications, broadcastRealtimeRefresh],
|
||||
);
|
||||
|
||||
/**
|
||||
* Handles marking all notifications as read
|
||||
*/
|
||||
const handleMarkAllAsRead = useCallback(async () => {
|
||||
setNotifications((prev) => prev.map((notif) => ({ ...notif, read: true })));
|
||||
setUnreadCount(0);
|
||||
|
||||
await markAllNotificationsAsRead();
|
||||
broadcastRealtimeRefresh();
|
||||
}, [broadcastRealtimeRefresh]);
|
||||
|
||||
/**
|
||||
* Handles deleting a notification
|
||||
*
|
||||
* Removes from UI immediately
|
||||
* Then calls server action to delete from database
|
||||
*/
|
||||
const handleDelete = useCallback(
|
||||
async (notificationId: string) => {
|
||||
// Optimistic update
|
||||
setNotifications((prev) =>
|
||||
prev.filter((notif) => notif.id !== notificationId),
|
||||
);
|
||||
|
||||
// Persist to database
|
||||
await deleteNotification(notificationId);
|
||||
|
||||
// Refresh unread count
|
||||
const countResponse = await getUnreadNotificationCount();
|
||||
if (countResponse.success) {
|
||||
setUnreadCount(countResponse.data?.count || 0);
|
||||
}
|
||||
broadcastRealtimeRefresh();
|
||||
},
|
||||
[broadcastRealtimeRefresh],
|
||||
);
|
||||
|
||||
/**
|
||||
* Effect: Close dropdown when clicking outside
|
||||
*/
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as Node;
|
||||
const clickedTrigger = dropdownRef.current?.contains(target);
|
||||
const clickedPanel = panelRef.current?.contains(target);
|
||||
|
||||
if (!clickedTrigger && !clickedPanel) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
/**
|
||||
* Effect: Event-driven realtime updates (no intervals)
|
||||
*
|
||||
* Initial load: one fetch with loader
|
||||
* Refresh triggers:
|
||||
* - custom notifications:refresh event (same tab)
|
||||
* - BroadcastChannel message (cross-tab)
|
||||
* - focus/visibility return
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!isMounted) return;
|
||||
|
||||
// Initial load
|
||||
void fetchNotifications({ showLoader: true });
|
||||
|
||||
// Deadline check runs once per mount (no loop)
|
||||
void checkDeadlineNotifications();
|
||||
|
||||
const focusRefresh = () => void fetchNotifications({ showLoader: false });
|
||||
const visibilityRefresh = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
void fetchNotifications({ showLoader: false });
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("focus", focusRefresh);
|
||||
document.addEventListener("visibilitychange", visibilityRefresh);
|
||||
window.addEventListener("notifications:refresh", notifyRealtimeRefresh);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("focus", focusRefresh);
|
||||
document.removeEventListener("visibilitychange", visibilityRefresh);
|
||||
window.removeEventListener(
|
||||
"notifications:refresh",
|
||||
notifyRealtimeRefresh,
|
||||
);
|
||||
};
|
||||
}, [isMounted, fetchNotifications, notifyRealtimeRefresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMounted) return;
|
||||
if (!isOpen) return;
|
||||
|
||||
updatePanelPosition();
|
||||
const handleResize = () => updatePanelPosition();
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, [isMounted, isOpen, updatePanelPosition]);
|
||||
|
||||
if (!isMounted) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
className="relative rounded-xl border border-border/70 bg-background/95 p-2.5 text-foreground shadow-sm backdrop-blur"
|
||||
title="Notifications"
|
||||
aria-label="Notifications"
|
||||
type="button"
|
||||
>
|
||||
<Bell className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const toggleOpen = () => {
|
||||
if (!isOpen) {
|
||||
updatePanelPosition();
|
||||
void fetchNotifications({ showLoader: notifications.length === 0 });
|
||||
}
|
||||
setIsOpen((prev) => !prev);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={dropdownRef} className="relative">
|
||||
{/* Bell Icon Button with Badge */}
|
||||
<button
|
||||
ref={triggerRef}
|
||||
onClick={toggleOpen}
|
||||
className="relative rounded-xl border border-border/70 bg-background/95 p-2.5 text-foreground shadow-sm backdrop-blur hover:bg-accent transition-colors"
|
||||
title="Notifications"
|
||||
type="button"
|
||||
>
|
||||
<Bell className="h-5 w-5" />
|
||||
|
||||
{/* Unread Badge */}
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 h-5 w-5 flex items-center justify-center text-xs font-bold text-white bg-red-500 rounded-full animate-pulse">
|
||||
{unreadCount > 99 ? "99+" : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Dropdown Panel */}
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={panelRef}
|
||||
style={{
|
||||
top: `${panelPosition.top}px`,
|
||||
left: `${panelPosition.left}px`,
|
||||
}}
|
||||
className="fixed w-[420px] max-w-[calc(100vw-2rem)] rounded-2xl border border-border/80 bg-background/98 shadow-2xl backdrop-blur z-[90] max-h-[70vh] flex flex-col overflow-hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-border/70 bg-muted/30 px-4 py-3">
|
||||
<div>
|
||||
<h2 className="font-semibold text-foreground">Notifications</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Renewal reminders and activity updates
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="p-1 hover:bg-accent rounded-md transition-colors"
|
||||
type="button"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Notifications List */}
|
||||
<div className="flex-1 overflow-y-auto bg-background">
|
||||
{isLoading ? (
|
||||
<div className="p-4 text-center text-muted-foreground">
|
||||
<p>Loading notifications...</p>
|
||||
</div>
|
||||
) : notifications.length === 0 ? (
|
||||
<div className="h-full flex flex-col items-center justify-center p-8 bg-gradient-to-b from-background to-muted/10">
|
||||
<div className="mb-4 p-3 rounded-full bg-muted/50">
|
||||
<Bell className="h-8 w-8 text-muted-foreground/40" />
|
||||
</div>
|
||||
<p className="font-medium text-foreground">All caught up!</p>
|
||||
<p className="text-sm text-muted-foreground text-center mt-2">
|
||||
No new notifications right now. Non-deadline notifications
|
||||
are auto-deleted after viewing.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground text-center mt-3 px-2">
|
||||
Deadline reminders for upcoming contract expirations will
|
||||
appear here.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 p-3">
|
||||
{notifications.map((notification) => (
|
||||
<NotificationItem
|
||||
key={notification.id}
|
||||
notification={notification}
|
||||
onRead={handleMarkAsRead}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer Actions */}
|
||||
{notifications.length > 0 && (
|
||||
<div className="border-t border-border/70 bg-muted/20 p-3 flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleMarkAllAsRead}
|
||||
className="flex-1"
|
||||
>
|
||||
<CheckCheck className="h-4 w-4 mr-2" />
|
||||
Mark all as read
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsOpen(false)}
|
||||
type="button"
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user