Unified dashboard redesign: Quicksand font, charcoal theme, merged upload

- Switch font from Geist to Google Quicksand
- Replace indigo/violet primary with charcoal gray (rgb 31,32,29)
- Update gradient-mesh to warm neutral tones (amber/sage)
- Remove sidebar, add fixed top header bar with profile dropdown
- Merge dashboard and upload pages into single table-centric view
- Stat cards now act as clickable table filters with active highlight
- Simplified filter toolbar: search + dropdowns left, export + upload right
- Upload modal with drag-drop, per-file XHR progress, background uploads
- Global drag-and-drop listener opens upload modal from anywhere
- Table rows are clickable (navigate to detail page)
- Right-click column headers for visibility context menu
- Mobile-responsive card layout below md breakpoint
- Uploading row placeholders with animated progress bars
- Floating selection toolbar slides in at bottom on multi-select
- Delete sidebar, dashboard-hero, quick-actions, upload page

Made-with: Cursor
This commit is contained in:
Randall Stillwell 2026-03-11 15:26:56 -05:00
parent eabfb4935d
commit 7ff9724a63
14 changed files with 1154 additions and 991 deletions

View file

@ -8,7 +8,7 @@
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--font-sans: var(--font-sans); --font-sans: var(--font-sans);
--font-mono: var(--font-geist-mono); --font-mono: var(--font-mono);
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@ -48,72 +48,72 @@
} }
:root { :root {
--background: oklch(0.97 0.005 270); --background: oklch(0.97 0.003 90);
--foreground: oklch(0.17 0.02 270); --foreground: oklch(0.18 0.005 90);
--card: oklch(1 0 0 / 65%); --card: oklch(1 0 0 / 65%);
--card-foreground: oklch(0.17 0.02 270); --card-foreground: oklch(0.18 0.005 90);
--popover: oklch(0.99 0.002 270 / 90%); --popover: oklch(0.99 0.002 90 / 92%);
--popover-foreground: oklch(0.17 0.02 270); --popover-foreground: oklch(0.18 0.005 90);
--primary: oklch(0.488 0.243 264); --primary: oklch(0.20 0.005 90);
--primary-foreground: oklch(0.99 0 0); --primary-foreground: oklch(0.99 0 0);
--secondary: oklch(0.93 0.02 270 / 60%); --secondary: oklch(0.94 0.005 90 / 60%);
--secondary-foreground: oklch(0.30 0.05 270); --secondary-foreground: oklch(0.25 0.005 90);
--muted: oklch(0.94 0.01 270 / 50%); --muted: oklch(0.94 0.003 90 / 50%);
--muted-foreground: oklch(0.50 0.02 270); --muted-foreground: oklch(0.50 0.008 90);
--accent: oklch(0.90 0.05 280 / 40%); --accent: oklch(0.92 0.01 80 / 40%);
--accent-foreground: oklch(0.30 0.08 270); --accent-foreground: oklch(0.25 0.01 90);
--destructive: oklch(0.58 0.22 27); --destructive: oklch(0.58 0.22 27);
--border: oklch(0.80 0.02 270 / 30%); --border: oklch(0.82 0.005 90 / 30%);
--input: oklch(0.92 0.01 270 / 50%); --input: oklch(0.93 0.003 90 / 50%);
--ring: oklch(0.55 0.20 264); --ring: oklch(0.35 0.005 90);
--chart-1: oklch(0.65 0.20 264); --chart-1: oklch(0.55 0.12 160);
--chart-2: oklch(0.60 0.18 280); --chart-2: oklch(0.60 0.10 80);
--chart-3: oklch(0.55 0.22 300); --chart-3: oklch(0.50 0.08 45);
--chart-4: oklch(0.70 0.15 250); --chart-4: oklch(0.65 0.06 200);
--chart-5: oklch(0.50 0.20 270); --chart-5: oklch(0.45 0.10 140);
--radius: 0.75rem; --radius: 0.75rem;
--sidebar: oklch(1 0 0 / 50%); --sidebar: oklch(1 0 0 / 50%);
--sidebar-foreground: oklch(0.20 0.02 270); --sidebar-foreground: oklch(0.20 0.005 90);
--sidebar-primary: oklch(0.488 0.243 264); --sidebar-primary: oklch(0.20 0.005 90);
--sidebar-primary-foreground: oklch(0.99 0 0); --sidebar-primary-foreground: oklch(0.99 0 0);
--sidebar-accent: oklch(0.90 0.06 270 / 40%); --sidebar-accent: oklch(0.92 0.005 90 / 40%);
--sidebar-accent-foreground: oklch(0.25 0.06 270); --sidebar-accent-foreground: oklch(0.25 0.005 90);
--sidebar-border: oklch(0.80 0.02 270 / 25%); --sidebar-border: oklch(0.82 0.005 90 / 25%);
--sidebar-ring: oklch(0.55 0.20 264); --sidebar-ring: oklch(0.35 0.005 90);
} }
.dark { .dark {
--background: oklch(0.14 0.02 270); --background: oklch(0.14 0.005 90);
--foreground: oklch(0.93 0.01 270); --foreground: oklch(0.93 0.005 90);
--card: oklch(1 0 0 / 6%); --card: oklch(1 0 0 / 6%);
--card-foreground: oklch(0.93 0.01 270); --card-foreground: oklch(0.93 0.005 90);
--popover: oklch(0.20 0.02 270 / 85%); --popover: oklch(0.20 0.005 90 / 85%);
--popover-foreground: oklch(0.93 0.01 270); --popover-foreground: oklch(0.93 0.005 90);
--primary: oklch(0.65 0.20 264); --primary: oklch(0.88 0.005 90);
--primary-foreground: oklch(0.99 0 0); --primary-foreground: oklch(0.14 0.005 90);
--secondary: oklch(1 0 0 / 8%); --secondary: oklch(1 0 0 / 8%);
--secondary-foreground: oklch(0.90 0.01 270); --secondary-foreground: oklch(0.90 0.005 90);
--muted: oklch(1 0 0 / 6%); --muted: oklch(1 0 0 / 6%);
--muted-foreground: oklch(0.65 0.02 270); --muted-foreground: oklch(0.65 0.005 90);
--accent: oklch(0.50 0.15 270 / 20%); --accent: oklch(0.50 0.01 80 / 20%);
--accent-foreground: oklch(0.90 0.02 270); --accent-foreground: oklch(0.90 0.005 90);
--destructive: oklch(0.704 0.191 22.216); --destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%); --border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 10%); --input: oklch(1 0 0 / 10%);
--ring: oklch(0.65 0.20 264); --ring: oklch(0.70 0.005 90);
--chart-1: oklch(0.70 0.18 264); --chart-1: oklch(0.60 0.10 160);
--chart-2: oklch(0.65 0.16 280); --chart-2: oklch(0.65 0.08 80);
--chart-3: oklch(0.60 0.20 300); --chart-3: oklch(0.55 0.06 45);
--chart-4: oklch(0.75 0.12 250); --chart-4: oklch(0.70 0.05 200);
--chart-5: oklch(0.55 0.18 270); --chart-5: oklch(0.50 0.08 140);
--sidebar: oklch(1 0 0 / 5%); --sidebar: oklch(1 0 0 / 5%);
--sidebar-foreground: oklch(0.90 0.01 270); --sidebar-foreground: oklch(0.90 0.005 90);
--sidebar-primary: oklch(0.65 0.20 264); --sidebar-primary: oklch(0.88 0.005 90);
--sidebar-primary-foreground: oklch(0.99 0 0); --sidebar-primary-foreground: oklch(0.14 0.005 90);
--sidebar-accent: oklch(1 0 0 / 8%); --sidebar-accent: oklch(1 0 0 / 8%);
--sidebar-accent-foreground: oklch(0.90 0.02 270); --sidebar-accent-foreground: oklch(0.90 0.005 90);
--sidebar-border: oklch(1 0 0 / 8%); --sidebar-border: oklch(1 0 0 / 8%);
--sidebar-ring: oklch(0.65 0.20 264); --sidebar-ring: oklch(0.70 0.005 90);
} }
@layer base { @layer base {
@ -130,13 +130,13 @@
@layer utilities { @layer utilities {
.glass { .glass {
backdrop-filter: blur(20px) saturate(1.8); backdrop-filter: blur(20px) saturate(1.6);
-webkit-backdrop-filter: blur(20px) saturate(1.8); -webkit-backdrop-filter: blur(20px) saturate(1.6);
} }
.glass-card { .glass-card {
backdrop-filter: blur(24px) saturate(1.8); backdrop-filter: blur(24px) saturate(1.6);
-webkit-backdrop-filter: blur(24px) saturate(1.8); -webkit-backdrop-filter: blur(24px) saturate(1.6);
background: oklch(1 0 0 / 55%); background: oklch(1 0 0 / 55%);
border: 1px solid oklch(1 0 0 / 30%); border: 1px solid oklch(1 0 0 / 30%);
box-shadow: box-shadow:
@ -154,22 +154,18 @@
inset 0 1px 0 oklch(1 0 0 / 5%); inset 0 1px 0 oklch(1 0 0 / 5%);
} }
.glass-sidebar { .glass-panel {
backdrop-filter: blur(24px) saturate(1.6); backdrop-filter: blur(24px) saturate(1.6);
-webkit-backdrop-filter: blur(24px) saturate(1.6); -webkit-backdrop-filter: blur(24px) saturate(1.6);
background: oklch(1 0 0 / 60%); background: oklch(1 0 0 / 60%);
border-right: 1px solid oklch(1 0 0 / 30%); border-bottom: 1px solid oklch(1 0 0 / 30%);
box-shadow: box-shadow: 0 1px 8px oklch(0 0 0 / 4%);
4px 0 24px oklch(0 0 0 / 4%),
inset -1px 0 0 oklch(1 0 0 / 20%);
} }
.dark .glass-sidebar { .dark .glass-panel {
background: oklch(1 0 0 / 4%); background: oklch(1 0 0 / 4%);
border-right-color: oklch(1 0 0 / 8%); border-bottom-color: oklch(1 0 0 / 8%);
box-shadow: box-shadow: 0 1px 8px oklch(0 0 0 / 20%);
4px 0 24px oklch(0 0 0 / 20%),
inset -1px 0 0 oklch(1 0 0 / 3%);
} }
.glass-input { .glass-input {
@ -186,24 +182,24 @@
.gradient-mesh { .gradient-mesh {
background: background:
radial-gradient(ellipse 80% 50% at 20% 20%, oklch(0.80 0.12 264 / 15%) 0%, transparent 60%), radial-gradient(ellipse 80% 50% at 15% 15%, oklch(0.85 0.04 80 / 18%) 0%, transparent 60%),
radial-gradient(ellipse 60% 40% at 80% 80%, oklch(0.75 0.15 300 / 12%) 0%, transparent 50%), radial-gradient(ellipse 60% 40% at 85% 75%, oklch(0.80 0.04 140 / 12%) 0%, transparent 50%),
radial-gradient(ellipse 50% 60% at 50% 50%, oklch(0.85 0.08 250 / 8%) 0%, transparent 60%); radial-gradient(ellipse 50% 60% at 50% 50%, oklch(0.90 0.02 60 / 10%) 0%, transparent 60%);
} }
.dark .gradient-mesh { .dark .gradient-mesh {
background: background:
radial-gradient(ellipse 80% 50% at 20% 20%, oklch(0.35 0.15 264 / 20%) 0%, transparent 60%), radial-gradient(ellipse 80% 50% at 15% 15%, oklch(0.30 0.03 80 / 20%) 0%, transparent 60%),
radial-gradient(ellipse 60% 40% at 80% 80%, oklch(0.30 0.18 300 / 15%) 0%, transparent 50%), radial-gradient(ellipse 60% 40% at 85% 75%, oklch(0.25 0.04 140 / 15%) 0%, transparent 50%),
radial-gradient(ellipse 50% 60% at 50% 50%, oklch(0.25 0.10 250 / 10%) 0%, transparent 60%); radial-gradient(ellipse 50% 60% at 50% 50%, oklch(0.22 0.02 60 / 10%) 0%, transparent 60%);
} }
.gradient-banner { .gradient-banner {
background: linear-gradient(135deg, oklch(0.488 0.243 264) 0%, oklch(0.55 0.22 285) 50%, oklch(0.50 0.20 300) 100%); background: linear-gradient(135deg, oklch(0.20 0.005 90) 0%, oklch(0.28 0.01 80) 50%, oklch(0.22 0.008 100) 100%);
} }
.gradient-stat { .gradient-stat {
background: linear-gradient(135deg, oklch(1 0 0 / 70%) 0%, oklch(0.97 0.01 270 / 50%) 100%); background: linear-gradient(135deg, oklch(1 0 0 / 70%) 0%, oklch(0.97 0.003 90 / 50%) 100%);
backdrop-filter: blur(20px) saturate(1.5); backdrop-filter: blur(20px) saturate(1.5);
-webkit-backdrop-filter: blur(20px) saturate(1.5); -webkit-backdrop-filter: blur(20px) saturate(1.5);
border: 1px solid oklch(1 0 0 / 40%); border: 1px solid oklch(1 0 0 / 40%);

View file

@ -1,17 +1,13 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Quicksand } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { Providers } from "@/components/providers"; import { Providers } from "@/components/providers";
import { Sidebar } from "@/components/layout/sidebar"; import { TopBar } from "@/components/layout/top-bar";
const geistSans = Geist({ const quicksand = Quicksand({
variable: "--font-geist-sans", variable: "--font-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"], subsets: ["latin"],
weight: ["300", "400", "500", "600", "700"],
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
@ -26,15 +22,15 @@ export default function RootLayout({
}>) { }>) {
return ( return (
<html lang="en" suppressHydrationWarning> <html lang="en" suppressHydrationWarning>
<body <body className={`${quicksand.variable} font-sans antialiased`}>
className={`${geistSans.variable} ${geistMono.variable} font-sans antialiased`}
>
<Providers> <Providers>
<div className="relative flex min-h-screen"> <div className="relative min-h-screen">
<div className="gradient-mesh pointer-events-none fixed inset-0 z-0" /> <div className="gradient-mesh pointer-events-none fixed inset-0 z-0" />
<Sidebar /> <TopBar />
<main className="relative z-10 flex-1 pt-16 md:pt-0 md:pl-[260px]"> <main className="relative z-10 pt-16">
<div className="p-4 sm:p-6 md:p-8">{children}</div> <div className="mx-auto max-w-7xl p-4 sm:p-6 lg:p-8">
{children}
</div>
</main> </main>
</div> </div>
</Providers> </Providers>

View file

@ -1,32 +1,27 @@
import { Suspense } from "react"; import { Suspense } from "react";
import { DashboardContent } from "@/components/cards/dashboard-content"; import { DashboardContent } from "@/components/cards/dashboard-content";
import { DashboardHero } from "@/components/cards/dashboard-hero";
import { StatCards } from "@/components/cards/stat-cards";
import { QuickActions } from "@/components/cards/quick-actions";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
export default function DashboardPage() { export default function DashboardPage() {
return ( return (
<div className="space-y-6 sm:space-y-8"> <Suspense
<DashboardHero /> fallback={
<StatCards /> <div className="space-y-6">
<QuickActions /> <div>
<Skeleton className="h-8 w-48 rounded-lg" />
<div> <Skeleton className="mt-2 h-4 w-64 rounded-lg" />
<h2 className="mb-4 text-lg font-semibold tracking-tight"> </div>
All Response Cards <div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
</h2> {Array.from({ length: 4 }).map((_, i) => (
<Suspense <Skeleton key={i} className="h-28 rounded-2xl" />
fallback={ ))}
<div className="space-y-4"> </div>
<Skeleton className="h-10 w-full rounded-xl" /> <Skeleton className="h-10 w-full rounded-xl" />
<Skeleton className="h-[400px] w-full rounded-xl" /> <Skeleton className="h-[400px] w-full rounded-2xl" />
</div> </div>
} }
> >
<DashboardContent /> <DashboardContent />
</Suspense> </Suspense>
</div>
</div>
); );
} }

View file

@ -1,341 +0,0 @@
"use client";
import * as React from "react";
import { toast } from "sonner";
import {
Upload,
FileText,
Image as ImageIcon,
X,
Loader2,
CheckCircle,
AlertCircle,
FolderOpen,
RefreshCw,
} from "lucide-react";
import { Header } from "@/components/layout/header";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
type ProcessingJob = {
id: string;
fileName: string;
status: string;
totalPages: number;
processed: number;
error: string | null;
createdAt: string;
};
export default function UploadPage() {
const [files, setFiles] = React.useState<File[]>([]);
const [uploading, setUploading] = React.useState(false);
const [jobs, setJobs] = React.useState<ProcessingJob[]>([]);
const [dragOver, setDragOver] = React.useState(false);
const [retryingJobId, setRetryingJobId] = React.useState<string | null>(null);
const fileInputRef = React.useRef<HTMLInputElement>(null);
const fetchJobs = React.useCallback(async () => {
try {
const res = await fetch("/api/jobs?limit=20");
if (res.ok) {
const data = await res.json();
setJobs(data.jobs);
}
} catch {
/* ignore */
}
}, []);
React.useEffect(() => {
fetchJobs();
const interval = setInterval(fetchJobs, 3000);
return () => clearInterval(interval);
}, [fetchJobs]);
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
const dropped = Array.from(e.dataTransfer.files).filter(
(f) =>
f.type === "application/pdf" ||
f.type.startsWith("image/")
);
setFiles((prev) => [...prev, ...dropped]);
};
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const selected = Array.from(e.target.files || []);
setFiles((prev) => [...prev, ...selected]);
if (fileInputRef.current) fileInputRef.current.value = "";
};
const removeFile = (index: number) => {
setFiles((prev) => prev.filter((_, i) => i !== index));
};
const handleUpload = async () => {
if (files.length === 0) return;
setUploading(true);
try {
const formData = new FormData();
files.forEach((f) => formData.append("files", f));
const res = await fetch("/api/upload", {
method: "POST",
body: formData,
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "Upload failed");
}
const data = await res.json();
toast.success(`Uploaded ${files.length} file(s). Processing ${data.jobIds.length} job(s).`);
setFiles([]);
fetchJobs();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Upload failed");
} finally {
setUploading(false);
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case "queued":
return <Loader2 className="size-4 animate-spin text-muted-foreground" />;
case "processing":
return <Loader2 className="size-4 animate-spin text-primary" />;
case "complete":
return <CheckCircle className="size-4 text-emerald-500" />;
case "error":
return <AlertCircle className="size-4 text-red-500" />;
default:
return null;
}
};
const handleRetryJob = async (jobId: string) => {
setRetryingJobId(jobId);
try {
const res = await fetch(`/api/jobs/${jobId}/reprocess`, { method: "POST" });
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "Failed to retry job");
}
toast.success("Job reprocessing started");
fetchJobs();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to retry job");
} finally {
setRetryingJobId(null);
}
};
return (
<div className="space-y-6">
<Header
title="Upload"
description="Upload scanned response cards for OCR processing"
icon={Upload}
/>
<Card variant="glass">
<CardContent className="pt-6">
<div
onDragOver={(e) => {
e.preventDefault();
setDragOver(true);
}}
onDragLeave={() => setDragOver(false)}
onDrop={handleDrop}
className={cn(
"flex flex-col items-center justify-center rounded-2xl border-2 border-dashed p-8 sm:p-12 text-center transition-all",
dragOver
? "border-primary bg-primary/5 scale-[1.01]"
: "border-muted-foreground/20 hover:border-primary/40 hover:bg-primary/[2%]"
)}
>
<div className="mb-4 flex size-16 items-center justify-center rounded-2xl bg-primary/10">
<Upload className="size-7 text-primary" />
</div>
<h3 className="mb-1 text-base font-semibold sm:text-lg">
Drop files here or click to browse
</h3>
<p className="mb-4 text-sm text-muted-foreground">
Supports PDF, JPEG, PNG, and WebP files
</p>
<Button
variant="outline"
className="rounded-xl"
onClick={() => fileInputRef.current?.click()}
>
<FolderOpen className="mr-2 size-4" />
Choose Files
</Button>
<input
ref={fileInputRef}
type="file"
multiple
accept=".pdf,.jpg,.jpeg,.png,.webp"
className="hidden"
onChange={handleFileSelect}
/>
</div>
{files.length > 0 && (
<div className="mt-6 space-y-3">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<h4 className="text-sm font-medium">
{files.length} file{files.length !== 1 ? "s" : ""} selected
</h4>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setFiles([])}
>
Clear all
</Button>
<Button
size="sm"
className="rounded-xl"
onClick={handleUpload}
disabled={uploading}
>
{uploading ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" />
Uploading...
</>
) : (
<>
<Upload className="mr-2 size-4" />
Upload & Process
</>
)}
</Button>
</div>
</div>
<div className="space-y-2">
{files.map((file, i) => (
<div
key={`${file.name}-${i}`}
className="flex items-center gap-3 rounded-xl border border-border/50 bg-muted/30 px-3 py-2.5"
>
{file.type === "application/pdf" ? (
<FileText className="size-4 shrink-0 text-red-500" />
) : (
<ImageIcon className="size-4 shrink-0 text-blue-500" />
)}
<span className="flex-1 min-w-0 truncate text-sm">{file.name}</span>
<span className="shrink-0 text-xs text-muted-foreground">
{(file.size / 1024).toFixed(0)} KB
</span>
<Button
variant="ghost"
size="icon-xs"
onClick={() => removeFile(i)}
>
<X className="size-3" />
</Button>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
<Card variant="glass">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Loader2 className="size-4 text-primary" />
Processing Queue
</CardTitle>
</CardHeader>
<CardContent>
{jobs.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
No processing jobs yet. Upload files to get started.
</p>
) : (
<div className="space-y-2">
{jobs.map((job) => (
<div
key={job.id}
className="flex flex-col gap-2 rounded-xl border border-border/50 bg-muted/20 px-4 py-3 sm:flex-row sm:items-center sm:gap-3"
>
<div className="flex items-center gap-3 flex-1 min-w-0">
{getStatusIcon(job.status)}
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm font-medium">
{job.fileName}
</span>
<Badge
variant="secondary"
className={cn(
"capitalize text-xs",
job.status === "complete" && "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
job.status === "error" && "bg-red-500/10 text-red-700 dark:text-red-300",
job.status === "processing" && "bg-primary/10 text-primary",
)}
>
{job.status}
</Badge>
</div>
{job.totalPages > 0 && (
<div className="mt-1.5 flex items-center gap-2">
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary transition-all"
style={{
width: `${Math.round((job.processed / job.totalPages) * 100)}%`,
}}
/>
</div>
<span className="shrink-0 text-xs text-muted-foreground">
{job.processed}/{job.totalPages}
</span>
</div>
)}
{job.error && (
<p className="mt-1 text-xs text-red-500">{job.error}</p>
)}
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
{job.status === "error" && (
<Button
variant="outline"
size="sm"
className="h-7 rounded-lg px-2.5 text-xs"
onClick={() => handleRetryJob(job.id)}
disabled={retryingJobId === job.id}
>
{retryingJobId === job.id ? (
<><Loader2 className="mr-1 size-3 animate-spin" /> Retrying...</>
) : (
<><RefreshCw className="mr-1 size-3" /> Retry</>
)}
</Button>
)}
<span className="text-xs text-muted-foreground">
{new Date(job.createdAt).toLocaleTimeString()}
</span>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
}

View file

@ -3,8 +3,12 @@
import * as React from "react"; import * as React from "react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { toast } from "sonner"; import { toast } from "sonner";
import { DataTable } from "./data-table";
import { StatCards, type StatFilter } from "./stat-cards";
import { Filters } from "./filters"; import { Filters } from "./filters";
import { DataTable } from "./data-table";
import { SelectionToolbar } from "./selection-toolbar";
import { UploadModal, type UploadingFile } from "./upload-modal";
import { createColumns, type ResponseCard } from "./columns"; import { createColumns, type ResponseCard } from "./columns";
const VISIT_TYPE_OPTIONS = [ const VISIT_TYPE_OPTIONS = [
@ -35,6 +39,11 @@ export function DashboardContent() {
const [total, setTotal] = React.useState(0); const [total, setTotal] = React.useState(0);
const [loading, setLoading] = React.useState(true); const [loading, setLoading] = React.useState(true);
const [columnVisibility, setColumnVisibility] = React.useState<Record<string, boolean>>({}); const [columnVisibility, setColumnVisibility] = React.useState<Record<string, boolean>>({});
const [selectedIds, setSelectedIds] = React.useState<string[]>([]);
const [uploadModalOpen, setUploadModalOpen] = React.useState(false);
const [uploadingRows, setUploadingRows] = React.useState<UploadingFile[]>([]);
const [activeStatFilter, setActiveStatFilter] = React.useState<StatFilter>(null);
const [globalDragOver, setGlobalDragOver] = React.useState(false);
const fetchCards = React.useCallback(async () => { const fetchCards = React.useCallback(async () => {
setLoading(true); setLoading(true);
@ -71,13 +80,42 @@ export function DashboardContent() {
fetchCards(); fetchCards();
}, [fetchCards]); }, [fetchCards]);
const updateUrl = (updates: Record<string, string | number>) => { const updateUrl = React.useCallback(
const params = new URLSearchParams(searchParams.toString()); (updates: Record<string, string | number | undefined>) => {
for (const [key, value] of Object.entries(updates)) { const params = new URLSearchParams(searchParams.toString());
params.set(key, String(value)); for (const [key, value] of Object.entries(updates)) {
} if (value === undefined || value === "") {
router.push(`/?${params.toString()}`); params.delete(key);
}; } else {
params.set(key, String(value));
}
}
router.push(`/?${params.toString()}`);
},
[router, searchParams]
);
const handleStatFilterChange = React.useCallback(
(filter: StatFilter) => {
setActiveStatFilter(filter);
const params = new URLSearchParams(searchParams.toString());
params.delete("ocrStatus");
params.delete("reviewStatus");
if (filter === "complete") {
params.set("ocrStatus", "complete");
} else if (filter === "error") {
params.set("ocrStatus", "error");
} else if (filter === "unreviewed") {
params.set("reviewStatus", "unreviewed");
}
params.set("page", "1");
router.push(`/?${params.toString()}`);
},
[router, searchParams]
);
const handleBulkAction = async ( const handleBulkAction = async (
ids: string[], ids: string[],
@ -114,20 +152,20 @@ export function DashboardContent() {
} }
const headers = [ const headers = [
"Name", "Email", "Phone", "City", "State", "Gender", "DOB", "Name", "Email", "Phone", "City", "State", "Gender",
"Visit Type", "Attendance", "Service", "OCR Status", "Review Status", "Visit Type", "Attendance", "Service", "OCR Status", "Review Status",
"Confidence", "Confidence",
]; ];
const rows = data.map((c) => [ const rows = data.map((c) => [
c.name || "", c.email || "", c.cellPhone || "", c.city || "", c.name || "", c.email || "", c.cellPhone || "", c.city || "",
c.state || "", c.gender || "", "", c.visitType || "", c.state || "", c.gender || "", c.visitType || "",
c.attendanceDuration || "", c.serviceAttended || "", c.ocrStatus, c.attendanceDuration || "", c.serviceAttended || "", c.ocrStatus,
c.reviewStatus, c.ocrConfidence?.toString() || "", c.reviewStatus, c.ocrConfidence?.toString() || "",
]); ]);
const csv = [headers, ...rows].map((r) => const csv = [headers, ...rows]
r.map((v) => `"${v.replace(/"/g, '""')}"`).join(",") .map((r) => r.map((v) => `"${v.replace(/"/g, '""')}"`).join(","))
).join("\n"); .join("\n");
const blob = new Blob([csv], { type: "text/csv" }); const blob = new Blob([csv], { type: "text/csv" });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
@ -161,16 +199,110 @@ export function DashboardContent() {
[router, fetchCards] [router, fetchCards]
); );
const handleUploadStart = (files: UploadingFile[]) => {
setUploadingRows((prev) => [...prev, ...files]);
};
const handleUploadProgress = (fileId: string, progress: number) => {
setUploadingRows((prev) =>
prev.map((f) => (f.id === fileId ? { ...f, progress } : f))
);
};
const handleUploadComplete = (fileId: string) => {
setUploadingRows((prev) => prev.filter((f) => f.id !== fileId));
fetchCards();
};
const handleUploadError = (fileId: string, error: string) => {
setUploadingRows((prev) =>
prev.map((f) =>
f.id === fileId ? { ...f, status: "error" as const, error } : f
)
);
};
React.useEffect(() => {
const handleOpenUpload = () => setUploadModalOpen(true);
window.addEventListener("open-upload-modal", handleOpenUpload);
return () => window.removeEventListener("open-upload-modal", handleOpenUpload);
}, []);
React.useEffect(() => {
const handleDragOver = (e: DragEvent) => {
e.preventDefault();
if (e.dataTransfer?.types.includes("Files")) {
setGlobalDragOver(true);
}
};
const handleDragLeave = (e: DragEvent) => {
if (e.relatedTarget === null) {
setGlobalDragOver(false);
}
};
const handleDrop = (e: DragEvent) => {
e.preventDefault();
setGlobalDragOver(false);
if (e.dataTransfer?.files.length) {
setUploadModalOpen(true);
}
};
document.addEventListener("dragover", handleDragOver);
document.addEventListener("dragleave", handleDragLeave);
document.addEventListener("drop", handleDrop);
return () => {
document.removeEventListener("dragover", handleDragOver);
document.removeEventListener("dragleave", handleDragLeave);
document.removeEventListener("drop", handleDrop);
};
}, []);
const openUpload = React.useCallback(() => {
setUploadModalOpen(true);
}, []);
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Global drag overlay */}
{globalDragOver && (
<div className="pointer-events-none fixed inset-0 z-50 flex items-center justify-center bg-primary/5 backdrop-blur-sm">
<div className="rounded-2xl border-2 border-dashed border-primary/40 bg-background/80 px-12 py-8 text-center shadow-xl">
<p className="text-lg font-semibold">Drop files to upload</p>
<p className="mt-1 text-sm text-muted-foreground">
PDF, JPEG, PNG, WebP
</p>
</div>
</div>
)}
{/* Page title */}
<div>
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">
Response Cards
</h1>
<p className="mt-1 text-sm text-muted-foreground">
Manage and review scanned response cards
</p>
</div>
{/* Stat cards as filters */}
<StatCards
activeFilter={activeStatFilter}
onFilterChange={handleStatFilterChange}
/>
{/* Filter toolbar */}
<Filters <Filters
columnVisibility={columnVisibility}
onColumnVisibilityChange={setColumnVisibility}
visitTypeOptions={VISIT_TYPE_OPTIONS} visitTypeOptions={VISIT_TYPE_OPTIONS}
attendanceDurationOptions={ATTENDANCE_OPTIONS} attendanceDurationOptions={ATTENDANCE_OPTIONS}
serviceAttendedOptions={SERVICE_OPTIONS} serviceAttendedOptions={SERVICE_OPTIONS}
onExportCsv={handleExportCsv} onExportCsv={handleExportCsv}
onUploadClick={openUpload}
/> />
{/* Data table */}
<div className={loading ? "opacity-60 transition-opacity" : ""}> <div className={loading ? "opacity-60 transition-opacity" : ""}>
<DataTable <DataTable
data={data} data={data}
@ -185,11 +317,29 @@ export function DashboardContent() {
onPageChange={(p) => updateUrl({ page: p })} onPageChange={(p) => updateUrl({ page: p })}
onLimitChange={(l) => updateUrl({ limit: l, page: 1 })} onLimitChange={(l) => updateUrl({ limit: l, page: 1 })}
onSortChange={(sb, so) => updateUrl({ sortBy: sb, sortOrder: so })} onSortChange={(sb, so) => updateUrl({ sortBy: sb, sortOrder: so })}
onBulkMarkReviewed={(ids) => handleBulkAction(ids, "reviewed")} uploadingRows={uploadingRows.filter((f) => f.status === "uploading")}
onBulkMarkExported={(ids) => handleBulkAction(ids, "exported")} onSelectionChange={setSelectedIds}
onBulkDelete={(ids) => handleBulkAction(ids, "delete")}
/> />
</div> </div>
{/* Floating selection toolbar */}
<SelectionToolbar
selectedIds={selectedIds}
onMarkReviewed={(ids) => handleBulkAction(ids, "reviewed")}
onMarkExported={(ids) => handleBulkAction(ids, "exported")}
onDelete={(ids) => handleBulkAction(ids, "delete")}
onClear={() => setSelectedIds([])}
/>
{/* Upload modal */}
<UploadModal
open={uploadModalOpen}
onOpenChange={setUploadModalOpen}
onUploadStart={handleUploadStart}
onUploadProgress={handleUploadProgress}
onUploadComplete={handleUploadComplete}
onUploadError={handleUploadError}
/>
</div> </div>
); );
} }

View file

@ -1,33 +0,0 @@
"use client";
import Link from "next/link";
import { Upload, ScanLine } from "lucide-react";
export function DashboardHero() {
return (
<div className="relative overflow-hidden rounded-2xl gradient-banner p-6 sm:p-8 text-white">
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_60%_60%_at_80%_20%,oklch(1_0_0/12%),transparent)]" />
<div className="relative z-10">
<div className="flex items-center gap-2 mb-2">
<ScanLine className="size-5 opacity-80" />
<span className="text-sm font-medium text-white/80">Echo OCR</span>
</div>
<h1 className="text-xl font-bold tracking-tight sm:text-2xl lg:text-3xl">
Document Processing Center
</h1>
<p className="mt-1.5 max-w-xl text-sm text-white/75 sm:text-base">
Upload your response cards and let AI-powered OCR extract data with high accuracy.
</p>
<div className="mt-4 flex flex-wrap gap-3">
<Link
href="/upload"
className="inline-flex items-center gap-2 rounded-xl bg-white/20 px-4 py-2 text-sm font-medium text-white backdrop-blur-sm border border-white/20 transition-colors hover:bg-white/30"
>
<Upload className="size-4" />
Upload Documents
</Link>
</div>
</div>
</div>
);
}

View file

@ -1,6 +1,7 @@
"use client"; "use client";
import * as React from "react"; import * as React from "react";
import { useRouter } from "next/navigation";
import { import {
type ColumnDef, type ColumnDef,
type SortingState, type SortingState,
@ -15,9 +16,7 @@ import {
import { import {
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
CheckCircle, Loader2,
Download,
Trash2,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -29,7 +28,31 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import type { ResponseCard } from "./columns"; import type { ResponseCard } from "./columns";
import type { UploadingFile } from "./upload-modal";
const TOGGLEABLE_COLUMNS = [
{ id: "thumbnail", label: "Thumbnail" },
{ id: "name", label: "Name" },
{ id: "email", label: "Email" },
{ id: "cellPhone", label: "Phone" },
{ id: "location", label: "Location" },
{ id: "visitType", label: "Visit Type" },
{ id: "attendanceDuration", label: "Attendance" },
{ id: "serviceAttended", label: "Service" },
{ id: "ocrStatus", label: "OCR Status" },
{ id: "reviewStatus", label: "Review" },
{ id: "ocrConfidence", label: "Confidence" },
] as const;
type DataTableProps<TData> = { type DataTableProps<TData> = {
data: TData[]; data: TData[];
@ -39,14 +62,13 @@ type DataTableProps<TData> = {
limit: number; limit: number;
onPageChange?: (page: number) => void; onPageChange?: (page: number) => void;
onLimitChange?: (limit: number) => void; onLimitChange?: (limit: number) => void;
onBulkMarkReviewed?: (ids: string[]) => void;
onBulkMarkExported?: (ids: string[]) => void;
onBulkDelete?: (ids: string[]) => void;
columnVisibility?: VisibilityState; columnVisibility?: VisibilityState;
onColumnVisibilityChange?: (visibility: VisibilityState) => void; onColumnVisibilityChange?: (visibility: VisibilityState) => void;
sortBy?: string; sortBy?: string;
sortOrder?: "asc" | "desc"; sortOrder?: "asc" | "desc";
onSortChange?: (sortBy: string, sortOrder: "asc" | "desc") => void; onSortChange?: (sortBy: string, sortOrder: "asc" | "desc") => void;
uploadingRows?: UploadingFile[];
onSelectionChange?: (selectedIds: string[]) => void;
}; };
const PAGE_SIZES = [10, 20, 50, 100]; const PAGE_SIZES = [10, 20, 50, 100];
@ -59,15 +81,16 @@ export function DataTable<TData extends ResponseCard>({
limit, limit,
onPageChange, onPageChange,
onLimitChange, onLimitChange,
onBulkMarkReviewed,
onBulkMarkExported,
onBulkDelete,
columnVisibility: controlledVisibility, columnVisibility: controlledVisibility,
onColumnVisibilityChange, onColumnVisibilityChange,
sortBy: controlledSortBy, sortBy: controlledSortBy,
sortOrder: controlledSortOrder, sortOrder: controlledSortOrder,
onSortChange, onSortChange,
uploadingRows = [],
onSelectionChange,
}: DataTableProps<TData>) { }: DataTableProps<TData>) {
const router = useRouter();
const sorting: SortingState = const sorting: SortingState =
controlledSortBy && controlledSortOrder controlledSortBy && controlledSortOrder
? [{ id: controlledSortBy, desc: controlledSortOrder === "desc" }] ? [{ id: controlledSortBy, desc: controlledSortOrder === "desc" }]
@ -93,6 +116,11 @@ export function DataTable<TData extends ResponseCard>({
const setVisibility = const setVisibility =
onColumnVisibilityChange ?? setInternalVisibility; onColumnVisibilityChange ?? setInternalVisibility;
const [contextMenu, setContextMenu] = React.useState<{
x: number;
y: number;
} | null>(null);
const table = useReactTable({ const table = useReactTable({
data, data,
columns, columns,
@ -122,8 +150,14 @@ export function DataTable<TData extends ResponseCard>({
}); });
const selectedRows = table.getFilteredSelectedRowModel().rows; const selectedRows = table.getFilteredSelectedRowModel().rows;
const selectedCount = selectedRows.length; const selectedIds = React.useMemo(
const selectedIds = selectedRows.map((r) => r.original.id); () => selectedRows.map((r) => r.original.id),
[selectedRows]
);
React.useEffect(() => {
onSelectionChange?.(selectedIds);
}, [selectedIds, onSelectionChange]);
const totalPages = Math.ceil(totalCount / limit); const totalPages = Math.ceil(totalCount / limit);
const canPrev = page > 1; const canPrev = page > 1;
@ -131,67 +165,54 @@ export function DataTable<TData extends ResponseCard>({
const start = (page - 1) * limit + 1; const start = (page - 1) * limit + 1;
const end = Math.min(page * limit, totalCount); const end = Math.min(page * limit, totalCount);
const handleRowClick = (e: React.MouseEvent, rowId: string) => {
const target = e.target as HTMLElement;
if (
target.closest("button") ||
target.closest("input") ||
target.closest('[data-slot="checkbox"]') ||
target.closest('[data-slot="dropdown-menu"]')
) {
return;
}
router.push(`/cards/${rowId}`);
};
const handleHeaderContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY });
};
React.useEffect(() => {
if (!contextMenu) return;
const close = () => setContextMenu(null);
window.addEventListener("click", close);
window.addEventListener("contextmenu", close);
return () => {
window.removeEventListener("click", close);
window.removeEventListener("contextmenu", close);
};
}, [contextMenu]);
const isColumnVisible = (id: string) => visibility[id] !== false;
const toggleColumn = (id: string, visible: boolean) => {
setVisibility({ ...visibility, [id]: visible });
};
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{selectedCount > 0 && ( {/* Desktop table */}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3 glass-card rounded-2xl px-4 py-3"> <div className="glass-card overflow-hidden rounded-2xl hidden md:block">
<span className="text-sm font-medium">
{selectedCount} row{selectedCount !== 1 ? "s" : ""} selected
</span>
<div className="flex flex-wrap items-center gap-2">
{onBulkMarkReviewed && (
<Button
variant="outline"
size="sm"
className="rounded-xl"
onClick={() => onBulkMarkReviewed(selectedIds)}
>
<CheckCircle className="mr-1.5 size-4" />
Mark Reviewed
</Button>
)}
{onBulkMarkExported && (
<Button
variant="outline"
size="sm"
className="rounded-xl"
onClick={() => onBulkMarkExported(selectedIds)}
>
<Download className="mr-1.5 size-4" />
Mark Exported
</Button>
)}
{onBulkDelete && (
<Button
variant="outline"
size="sm"
className="rounded-xl text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={() => onBulkDelete(selectedIds)}
>
<Trash2 className="mr-1.5 size-4" />
Delete
</Button>
)}
<Button
variant="ghost"
size="sm"
className="rounded-xl"
onClick={() => table.toggleAllPageRowsSelected(false)}
>
Clear
</Button>
</div>
</div>
)}
<div className="glass-card overflow-hidden rounded-2xl">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table> <Table>
<TableHeader> <TableHeader onContextMenu={handleHeaderContextMenu}>
{table.getHeaderGroups().map((headerGroup) => ( {table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}> <TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => ( {headerGroup.headers.map((header) => (
<TableHead key={header.id} className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground"> <TableHead
key={header.id}
className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground"
>
{header.isPlaceholder {header.isPlaceholder
? null ? null
: flexRender( : flexRender(
@ -204,12 +225,45 @@ export function DataTable<TData extends ResponseCard>({
))} ))}
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{uploadingRows.map((uf) => (
<TableRow key={uf.id} className="opacity-70">
<TableCell className="px-4 py-3" />
<TableCell className="px-4 py-3" />
<TableCell className="px-4 py-3">
<span className="font-medium text-muted-foreground">
{uf.name}
</span>
</TableCell>
<TableCell className="px-4 py-3" colSpan={6} />
<TableCell className="px-4 py-3">
<div className="flex items-center gap-2">
<Loader2 className="size-3.5 animate-spin text-primary" />
<div className="flex items-center gap-2">
<div className="h-1.5 w-20 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${uf.progress}%` }}
/>
</div>
<span className="text-xs text-muted-foreground">
{uf.progress}%
</span>
</div>
</div>
</TableCell>
<TableCell className="px-4 py-3" />
<TableCell className="px-4 py-3" />
<TableCell className="px-4 py-3" />
</TableRow>
))}
{table.getRowModel().rows?.length ? ( {table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => ( table.getRowModel().rows.map((row) => (
<TableRow <TableRow
key={row.id} key={row.id}
data-state={row.getIsSelected() && "selected"} data-state={row.getIsSelected() && "selected"}
className="transition-colors hover:bg-muted/30" className="cursor-pointer transition-colors hover:bg-muted/30"
onClick={(e) => handleRowClick(e, row.original.id)}
> >
{row.getVisibleCells().map((cell) => ( {row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className="px-4 py-3"> <TableCell key={cell.id} className="px-4 py-3">
@ -222,20 +276,120 @@ export function DataTable<TData extends ResponseCard>({
</TableRow> </TableRow>
)) ))
) : ( ) : (
<TableRow> uploadingRows.length === 0 && (
<TableCell <TableRow>
colSpan={columns.length} <TableCell
className="h-24 text-center text-muted-foreground" colSpan={columns.length}
> className="h-24 text-center text-muted-foreground"
No results. >
</TableCell> No results.
</TableRow> </TableCell>
</TableRow>
)
)} )}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
</div> </div>
{/* Mobile card layout */}
<div className="space-y-3 md:hidden">
{uploadingRows.map((uf) => (
<div
key={uf.id}
className="glass-card rounded-2xl p-4 opacity-70"
>
<div className="flex items-center gap-3">
<Loader2 className="size-4 animate-spin text-primary" />
<div className="flex-1 min-w-0">
<p className="truncate text-sm font-medium">{uf.name}</p>
<div className="mt-1.5 flex items-center gap-2">
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${uf.progress}%` }}
/>
</div>
<span className="text-xs text-muted-foreground">
{uf.progress}%
</span>
</div>
</div>
</div>
</div>
))}
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<div
key={row.id}
className={cn(
"glass-card cursor-pointer rounded-2xl p-4 transition-all hover:shadow-lg active:scale-[0.99]",
row.getIsSelected() && "ring-2 ring-primary/30"
)}
onClick={(e) => handleRowClick(e, row.original.id)}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<p className="truncate text-sm font-semibold">
{row.original.name ?? "Unnamed"}
</p>
{row.original.email && (
<p className="truncate text-xs text-muted-foreground mt-0.5">
{row.original.email}
</p>
)}
</div>
<div className="flex items-center gap-1.5">
<Badge
variant="secondary"
className={cn(
"capitalize text-[10px] px-1.5 py-0.5",
row.original.ocrStatus === "complete"
? "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300"
: row.original.ocrStatus === "error"
? "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300"
: "bg-muted text-muted-foreground"
)}
>
{row.original.ocrStatus}
</Badge>
</div>
</div>
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
{row.original.cellPhone && (
<span>{row.original.cellPhone}</span>
)}
{row.original.visitType && (
<span>{row.original.visitType}</span>
)}
{row.original.ocrConfidence != null && (
<span
className={cn(
"font-medium",
row.original.ocrConfidence >= 75
? "text-green-600 dark:text-green-400"
: row.original.ocrConfidence >= 50
? "text-amber-600 dark:text-amber-400"
: "text-red-600 dark:text-red-400"
)}
>
{Math.round(row.original.ocrConfidence)}%
</span>
)}
</div>
</div>
))
) : (
uploadingRows.length === 0 && (
<div className="py-12 text-center text-sm text-muted-foreground">
No results.
</div>
)
)}
</div>
{/* Pagination */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground"> <div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
<span> <span>
@ -306,6 +460,39 @@ export function DataTable<TData extends ResponseCard>({
</Button> </Button>
</div> </div>
</div> </div>
{/* Right-click column visibility context menu */}
{contextMenu && (
<DropdownMenu
open={true}
onOpenChange={(open) => {
if (!open) setContextMenu(null);
}}
>
<DropdownMenuContent
style={{
position: "fixed",
left: contextMenu.x,
top: contextMenu.y,
}}
className="w-48"
>
<DropdownMenuLabel>Toggle Columns</DropdownMenuLabel>
<DropdownMenuSeparator />
{TOGGLEABLE_COLUMNS.map((col) => (
<DropdownMenuCheckboxItem
key={col.id}
checked={isColumnVisible(col.id)}
onCheckedChange={(checked) =>
toggleColumn(col.id, checked !== false)
}
>
{col.label}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</div> </div>
); );
} }

View file

@ -2,16 +2,9 @@
import * as React from "react"; import * as React from "react";
import { useRouter, usePathname, useSearchParams } from "next/navigation"; import { useRouter, usePathname, useSearchParams } from "next/navigation";
import { Search, Download, Columns3, SlidersHorizontal } from "lucide-react"; import { Search, Download, Upload } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { import {
Select, Select,
@ -20,72 +13,35 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { cn } from "@/lib/utils";
const REVIEW_OPTIONS = [
{ value: "", label: "All" },
{ value: "unreviewed", label: "Unreviewed" },
{ value: "reviewed", label: "Reviewed" },
{ value: "exported", label: "Exported" },
] as const;
const OCR_OPTIONS = [
{ value: "", label: "All" },
{ value: "complete", label: "Complete" },
{ value: "error", label: "Error" },
{ value: "pending", label: "Pending" },
] as const;
const TOGGLEABLE_COLUMNS = [
{ id: "thumbnail", label: "Thumbnail" },
{ id: "name", label: "Name" },
{ id: "email", label: "Email" },
{ id: "cellPhone", label: "Phone" },
{ id: "location", label: "Location" },
{ id: "visitType", label: "Visit Type" },
{ id: "attendanceDuration", label: "Attendance" },
{ id: "serviceAttended", label: "Service" },
{ id: "ocrStatus", label: "OCR Status" },
{ id: "reviewStatus", label: "Review" },
{ id: "ocrConfidence", label: "Confidence" },
] as const;
export type FiltersProps = { export type FiltersProps = {
search?: string; search?: string;
reviewStatus?: string;
ocrStatus?: string;
visitType?: string; visitType?: string;
attendanceDuration?: string; attendanceDuration?: string;
serviceAttended?: string; serviceAttended?: string;
visitTypeOptions?: string[]; visitTypeOptions?: string[];
attendanceDurationOptions?: string[]; attendanceDurationOptions?: string[];
serviceAttendedOptions?: string[]; serviceAttendedOptions?: string[];
columnVisibility?: Record<string, boolean>;
onColumnVisibilityChange?: (visibility: Record<string, boolean>) => void;
onExportCsv?: () => void; onExportCsv?: () => void;
onUploadClick?: () => void;
}; };
export function Filters({ export function Filters({
search: initialSearch = "", search: initialSearch = "",
reviewStatus: initialReviewStatus = "",
ocrStatus: initialOcrStatus = "",
visitType: initialVisitType = "", visitType: initialVisitType = "",
attendanceDuration: initialAttendanceDuration = "", attendanceDuration: initialAttendanceDuration = "",
serviceAttended: initialServiceAttended = "", serviceAttended: initialServiceAttended = "",
visitTypeOptions = [], visitTypeOptions = [],
attendanceDurationOptions = [], attendanceDurationOptions = [],
serviceAttendedOptions = [], serviceAttendedOptions = [],
columnVisibility = {},
onColumnVisibilityChange,
onExportCsv, onExportCsv,
onUploadClick,
}: FiltersProps) { }: FiltersProps) {
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const search = searchParams.get("search") ?? initialSearch; const search = searchParams.get("search") ?? initialSearch;
const reviewStatus = searchParams.get("reviewStatus") ?? initialReviewStatus;
const ocrStatus = searchParams.get("ocrStatus") ?? initialOcrStatus;
const visitType = searchParams.get("visitType") ?? initialVisitType; const visitType = searchParams.get("visitType") ?? initialVisitType;
const attendanceDuration = const attendanceDuration =
searchParams.get("attendanceDuration") ?? initialAttendanceDuration; searchParams.get("attendanceDuration") ?? initialAttendanceDuration;
@ -113,89 +69,32 @@ export function Filters({
updateParams({ search: v || undefined }); updateParams({ search: v || undefined });
}; };
const handleSearchSubmit = (e: React.FormEvent) => {
e.preventDefault();
};
const isColumnVisible = (id: string) => columnVisibility[id] !== false;
const toggleColumn = (id: string, visible: boolean) => {
onColumnVisibilityChange?.({
...columnVisibility,
[id]: visible,
});
};
return ( return (
<div className="glass-card rounded-2xl p-4 sm:p-5"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-col gap-4"> <div className="flex flex-1 flex-col gap-2 sm:flex-row sm:items-center">
<form onSubmit={handleSearchSubmit} className="flex flex-col gap-3 sm:flex-row sm:items-center"> <div className="relative w-full sm:max-w-xs">
<div className="relative w-full sm:max-w-sm"> <Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" /> <Input
<Input type="search"
type="search" placeholder="Search..."
placeholder="Search name, email, phone..." value={search}
value={search} onChange={handleSearchChange}
onChange={handleSearchChange} className="pl-9 rounded-xl"
className="pl-9 rounded-xl" />
/> </div>
</div>
<div className="flex items-center gap-2 overflow-x-auto pb-1 sm:pb-0">
<SlidersHorizontal className="size-3.5 shrink-0 text-muted-foreground" />
<div className="flex items-center gap-1.5 shrink-0">
<span className="text-[11px] font-medium text-muted-foreground uppercase tracking-wide">
Review:
</span>
{REVIEW_OPTIONS.map((opt) => (
<Badge
key={opt.value || "all"}
variant={reviewStatus === opt.value ? "default" : "outline"}
className={cn(
"cursor-pointer shrink-0 transition-all min-h-[28px] px-2.5 text-xs",
reviewStatus === opt.value && "bg-primary text-primary-foreground shadow-sm"
)}
onClick={() => updateParams({ reviewStatus: opt.value || undefined })}
>
{opt.label}
</Badge>
))}
</div>
<div className="mx-1 h-4 w-px bg-border/50 shrink-0 hidden sm:block" />
<div className="flex items-center gap-1.5 shrink-0">
<span className="text-[11px] font-medium text-muted-foreground uppercase tracking-wide">
OCR:
</span>
{OCR_OPTIONS.map((opt) => (
<Badge
key={opt.value || "all"}
variant={ocrStatus === opt.value ? "default" : "outline"}
className={cn(
"cursor-pointer shrink-0 transition-all min-h-[28px] px-2.5 text-xs",
ocrStatus === opt.value && "bg-primary text-primary-foreground shadow-sm"
)}
onClick={() => updateParams({ ocrStatus: opt.value || undefined })}
>
{opt.label}
</Badge>
))}
</div>
</div>
</form>
<div className="flex flex-wrap items-center gap-2 sm:gap-3"> <div className="flex items-center gap-2 overflow-x-auto">
<Select <Select
value={visitType || "__all__"} value={visitType || "__all__"}
onValueChange={(v: string | null) => onValueChange={(v: string | null) =>
updateParams({ updateParams({ visitType: !v || v === "__all__" ? undefined : v })
visitType: !v || v === "__all__" ? undefined : v,
})
} }
> >
<SelectTrigger className="w-full sm:w-[170px] rounded-xl"> <SelectTrigger className="w-[140px] rounded-xl">
<SelectValue placeholder="Visit Type" /> <SelectValue placeholder="Visit Type" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="__all__">All visit types</SelectItem> <SelectItem value="__all__">All visits</SelectItem>
{visitTypeOptions.map((opt) => ( {visitTypeOptions.map((opt) => (
<SelectItem key={opt} value={opt}> <SelectItem key={opt} value={opt}>
{opt} {opt}
@ -203,6 +102,7 @@ export function Filters({
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<Select <Select
value={attendanceDuration || "__all__"} value={attendanceDuration || "__all__"}
onValueChange={(v: string | null) => onValueChange={(v: string | null) =>
@ -211,7 +111,7 @@ export function Filters({
}) })
} }
> >
<SelectTrigger className="w-full sm:w-[170px] rounded-xl"> <SelectTrigger className="w-[140px] rounded-xl">
<SelectValue placeholder="Attendance" /> <SelectValue placeholder="Attendance" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -223,6 +123,7 @@ export function Filters({
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<Select <Select
value={serviceAttended || "__all__"} value={serviceAttended || "__all__"}
onValueChange={(v: string | null) => onValueChange={(v: string | null) =>
@ -231,7 +132,7 @@ export function Filters({
}) })
} }
> >
<SelectTrigger className="w-full sm:w-[160px] rounded-xl"> <SelectTrigger className="w-[140px] rounded-xl">
<SelectValue placeholder="Service" /> <SelectValue placeholder="Service" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -243,40 +144,28 @@ export function Filters({
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<div className="flex items-center gap-2 w-full sm:w-auto sm:ml-auto">
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="outline" size="sm" className="rounded-xl" />
}
>
<Columns3 className="mr-2 size-4" />
<span className="hidden sm:inline">Columns</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
{TOGGLEABLE_COLUMNS.map((col) => (
<DropdownMenuCheckboxItem
key={col.id}
checked={isColumnVisible(col.id)}
onCheckedChange={(checked) =>
toggleColumn(col.id, checked !== false)
}
>
{col.label}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{onExportCsv && (
<Button variant="outline" size="sm" className="rounded-xl" onClick={onExportCsv}>
<Download className="mr-2 size-4" />
<span className="hidden sm:inline">Export CSV</span>
</Button>
)}
</div>
</div> </div>
</div> </div>
<div className="flex shrink-0 items-center gap-2">
{onExportCsv && (
<Button
variant="outline"
size="sm"
className="rounded-xl"
onClick={onExportCsv}
>
<Download className="size-4" />
<span className="hidden sm:inline ml-1.5">Export</span>
</Button>
)}
{onUploadClick && (
<Button size="sm" className="rounded-xl" onClick={onUploadClick}>
<Upload className="size-4" />
<span className="hidden sm:inline ml-1.5">Upload</span>
</Button>
)}
</div>
</div> </div>
); );
} }

View file

@ -1,61 +0,0 @@
"use client";
import Link from "next/link";
import { Upload, FileDown, Clock, Settings } from "lucide-react";
import { cn } from "@/lib/utils";
const actions = [
{
label: "Upload Documents",
description: "PDF, JPG, PNG formats",
href: "/upload",
icon: Upload,
accent: "text-primary bg-primary/10",
},
{
label: "Export Results",
description: "TXT, DOCX, CSV formats",
href: "/#",
icon: FileDown,
accent: "text-emerald-600 bg-emerald-500/10 dark:text-emerald-400",
},
{
label: "Recent Files",
description: "View processing history",
href: "/",
icon: Clock,
accent: "text-amber-600 bg-amber-500/10 dark:text-amber-400",
},
{
label: "Settings",
description: "Customize your experience",
href: "/settings",
icon: Settings,
accent: "text-violet-600 bg-violet-500/10 dark:text-violet-400",
},
];
export function QuickActions() {
return (
<div className="grid grid-cols-2 gap-3 sm:gap-4 lg:grid-cols-4">
{actions.map((action) => {
const Icon = action.icon;
return (
<Link
key={action.label}
href={action.href}
className="glass-card group flex items-center gap-3 rounded-2xl p-4 transition-all hover:scale-[1.02] hover:shadow-lg sm:p-5"
>
<div className={cn("flex size-10 shrink-0 items-center justify-center rounded-xl transition-transform group-hover:scale-110", action.accent)}>
<Icon className="size-5" />
</div>
<div className="min-w-0">
<p className="text-sm font-semibold truncate">{action.label}</p>
<p className="text-xs text-muted-foreground hidden sm:block">{action.description}</p>
</div>
</Link>
);
})}
</div>
);
}

View file

@ -0,0 +1,91 @@
"use client";
import {
CheckCircle,
Download,
Trash2,
X,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
interface SelectionToolbarProps {
selectedIds: string[];
onMarkReviewed?: (ids: string[]) => void;
onMarkExported?: (ids: string[]) => void;
onDelete?: (ids: string[]) => void;
onClear: () => void;
}
export function SelectionToolbar({
selectedIds,
onMarkReviewed,
onMarkExported,
onDelete,
onClear,
}: SelectionToolbarProps) {
const count = selectedIds.length;
return (
<div
className={cn(
"fixed bottom-6 left-1/2 z-50 -translate-x-1/2 transition-all duration-300 ease-out",
count > 0
? "translate-y-0 opacity-100"
: "pointer-events-none translate-y-4 opacity-0"
)}
>
<div className="glass-card flex items-center gap-2 rounded-2xl px-4 py-2.5 shadow-xl sm:gap-3 sm:px-5">
<span className="shrink-0 text-sm font-medium">
{count} selected
</span>
<div className="h-4 w-px bg-border/50" />
<div className="flex items-center gap-1.5">
{onMarkReviewed && (
<Button
variant="ghost"
size="sm"
className="rounded-xl"
onClick={() => onMarkReviewed(selectedIds)}
>
<CheckCircle className="size-4" />
<span className="hidden sm:inline ml-1">Reviewed</span>
</Button>
)}
{onMarkExported && (
<Button
variant="ghost"
size="sm"
className="rounded-xl"
onClick={() => onMarkExported(selectedIds)}
>
<Download className="size-4" />
<span className="hidden sm:inline ml-1">Export</span>
</Button>
)}
{onDelete && (
<Button
variant="ghost"
size="sm"
className="rounded-xl text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={() => onDelete(selectedIds)}
>
<Trash2 className="size-4" />
<span className="hidden sm:inline ml-1">Delete</span>
</Button>
)}
</div>
<div className="h-4 w-px bg-border/50" />
<Button
variant="ghost"
size="sm"
className="rounded-xl"
onClick={onClear}
>
<X className="size-4" />
<span className="hidden sm:inline ml-1">Clear</span>
</Button>
</div>
</div>
);
}

View file

@ -6,8 +6,6 @@ import {
CheckCircle, CheckCircle,
AlertTriangle, AlertTriangle,
Clock, Clock,
TrendingUp,
TrendingDown,
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -17,16 +15,23 @@ type Stats = {
byReviewStatus: Record<string, number>; byReviewStatus: Record<string, number>;
}; };
export type StatFilter = "all" | "complete" | "error" | "unreviewed" | null;
type StatCardData = { type StatCardData = {
label: string; label: string;
value: number; value: number;
icon: React.ElementType; icon: React.ElementType;
trend?: string; filterKey: StatFilter;
trendUp?: boolean;
accentClass: string; accentClass: string;
activeRing: string;
}; };
export function StatCards() { interface StatCardsProps {
activeFilter: StatFilter;
onFilterChange: (filter: StatFilter) => void;
}
export function StatCards({ activeFilter, onFilterChange }: StatCardsProps) {
const [stats, setStats] = React.useState<Stats | null>(null); const [stats, setStats] = React.useState<Stats | null>(null);
React.useEffect(() => { React.useEffect(() => {
@ -39,10 +44,10 @@ export function StatCards() {
const cards: StatCardData[] = React.useMemo(() => { const cards: StatCardData[] = React.useMemo(() => {
if (!stats) { if (!stats) {
return [ return [
{ label: "Total Cards", value: 0, icon: FileText, accentClass: "text-primary bg-primary/10" }, { label: "Total Cards", value: 0, icon: FileText, filterKey: "all", accentClass: "text-primary bg-primary/10", activeRing: "ring-primary/40" },
{ label: "OCR Complete", value: 0, icon: CheckCircle, accentClass: "text-emerald-600 bg-emerald-500/10 dark:text-emerald-400" }, { label: "OCR Complete", value: 0, icon: CheckCircle, filterKey: "complete", accentClass: "text-emerald-600 bg-emerald-500/10 dark:text-emerald-400", activeRing: "ring-emerald-500/40" },
{ label: "Errors", value: 0, icon: AlertTriangle, accentClass: "text-red-600 bg-red-500/10 dark:text-red-400" }, { label: "Errors", value: 0, icon: AlertTriangle, filterKey: "error", accentClass: "text-red-600 bg-red-500/10 dark:text-red-400", activeRing: "ring-red-500/40" },
{ label: "Pending Review", value: 0, icon: Clock, accentClass: "text-amber-600 bg-amber-500/10 dark:text-amber-400" }, { label: "Pending Review", value: 0, icon: Clock, filterKey: "unreviewed", accentClass: "text-amber-600 bg-amber-500/10 dark:text-amber-400", activeRing: "ring-amber-500/40" },
]; ];
} }
return [ return [
@ -50,52 +55,70 @@ export function StatCards() {
label: "Total Cards", label: "Total Cards",
value: stats.total, value: stats.total,
icon: FileText, icon: FileText,
filterKey: "all" as StatFilter,
accentClass: "text-primary bg-primary/10", accentClass: "text-primary bg-primary/10",
activeRing: "ring-primary/40",
}, },
{ {
label: "OCR Complete", label: "OCR Complete",
value: stats.byOcrStatus["complete"] || 0, value: stats.byOcrStatus["complete"] || 0,
icon: CheckCircle, icon: CheckCircle,
filterKey: "complete" as StatFilter,
accentClass: "text-emerald-600 bg-emerald-500/10 dark:text-emerald-400", accentClass: "text-emerald-600 bg-emerald-500/10 dark:text-emerald-400",
trendUp: true, activeRing: "ring-emerald-500/40",
}, },
{ {
label: "Errors", label: "Errors",
value: stats.byOcrStatus["error"] || 0, value: stats.byOcrStatus["error"] || 0,
icon: AlertTriangle, icon: AlertTriangle,
filterKey: "error" as StatFilter,
accentClass: "text-red-600 bg-red-500/10 dark:text-red-400", accentClass: "text-red-600 bg-red-500/10 dark:text-red-400",
trendUp: false, activeRing: "ring-red-500/40",
}, },
{ {
label: "Pending Review", label: "Pending Review",
value: stats.byReviewStatus["unreviewed"] || 0, value: stats.byReviewStatus["unreviewed"] || 0,
icon: Clock, icon: Clock,
filterKey: "unreviewed" as StatFilter,
accentClass: "text-amber-600 bg-amber-500/10 dark:text-amber-400", accentClass: "text-amber-600 bg-amber-500/10 dark:text-amber-400",
activeRing: "ring-amber-500/40",
}, },
]; ];
}, [stats]); }, [stats]);
const handleClick = (filterKey: StatFilter) => {
if (activeFilter === filterKey) {
onFilterChange(null);
} else {
onFilterChange(filterKey);
}
};
return ( return (
<div className="grid grid-cols-2 gap-3 sm:gap-4 lg:grid-cols-4"> <div className="grid grid-cols-2 gap-3 sm:gap-4 lg:grid-cols-4">
{cards.map((card) => { {cards.map((card) => {
const Icon = card.icon; const Icon = card.icon;
const isActive = activeFilter === card.filterKey;
return ( return (
<div <button
key={card.label} key={card.label}
className="gradient-stat flex flex-col gap-3 rounded-2xl p-4 sm:p-5" type="button"
onClick={() => handleClick(card.filterKey)}
className={cn(
"gradient-stat flex flex-col gap-3 rounded-2xl p-4 text-left transition-all sm:p-5",
"hover:scale-[1.02] hover:shadow-lg active:scale-[0.98]",
isActive && `ring-2 ${card.activeRing} shadow-lg`
)}
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className={cn("flex size-10 items-center justify-center rounded-xl", card.accentClass)}> <div
className={cn(
"flex size-10 items-center justify-center rounded-xl",
card.accentClass
)}
>
<Icon className="size-5" /> <Icon className="size-5" />
</div> </div>
{card.trendUp !== undefined && card.value > 0 && (
<div className={cn(
"flex items-center gap-0.5 text-xs font-medium",
card.trendUp ? "text-emerald-600 dark:text-emerald-400" : "text-red-500"
)}>
{card.trendUp ? <TrendingUp className="size-3" /> : <TrendingDown className="size-3" />}
</div>
)}
</div> </div>
<div> <div>
<p className="text-2xl font-bold tracking-tight sm:text-3xl"> <p className="text-2xl font-bold tracking-tight sm:text-3xl">
@ -105,7 +128,7 @@ export function StatCards() {
{card.label} {card.label}
</p> </p>
</div> </div>
</div> </button>
); );
})} })}
</div> </div>

View file

@ -0,0 +1,311 @@
"use client";
import * as React from "react";
import { toast } from "sonner";
import {
Upload,
FileText,
Image as ImageIcon,
X,
Loader2,
CheckCircle,
AlertCircle,
FolderOpen,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
DialogClose,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
export type UploadingFile = {
id: string;
name: string;
size: number;
progress: number;
status: "uploading" | "complete" | "error";
error?: string;
};
interface UploadModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onUploadStart?: (files: UploadingFile[]) => void;
onUploadProgress?: (fileId: string, progress: number) => void;
onUploadComplete?: (fileId: string) => void;
onUploadError?: (fileId: string, error: string) => void;
}
export function UploadModal({
open,
onOpenChange,
onUploadStart,
onUploadProgress,
onUploadComplete,
onUploadError,
}: UploadModalProps) {
const [stagedFiles, setStagedFiles] = React.useState<File[]>([]);
const [uploadingFiles, setUploadingFiles] = React.useState<UploadingFile[]>([]);
const [dragOver, setDragOver] = React.useState(false);
const fileInputRef = React.useRef<HTMLInputElement>(null);
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragOver(false);
const dropped = Array.from(e.dataTransfer.files).filter(
(f) => f.type === "application/pdf" || f.type.startsWith("image/")
);
if (dropped.length) {
setStagedFiles((prev) => [...prev, ...dropped]);
}
};
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const selected = Array.from(e.target.files || []);
setStagedFiles((prev) => [...prev, ...selected]);
if (fileInputRef.current) fileInputRef.current.value = "";
};
const removeStaged = (index: number) => {
setStagedFiles((prev) => prev.filter((_, i) => i !== index));
};
const uploadFiles = async () => {
if (stagedFiles.length === 0) return;
const newUploading: UploadingFile[] = stagedFiles.map((f, i) => ({
id: `upload-${Date.now()}-${i}`,
name: f.name,
size: f.size,
progress: 0,
status: "uploading" as const,
}));
setUploadingFiles((prev) => [...prev, ...newUploading]);
onUploadStart?.(newUploading);
const filesToUpload = [...stagedFiles];
setStagedFiles([]);
for (let i = 0; i < filesToUpload.length; i++) {
const file = filesToUpload[i];
const uploadEntry = newUploading[i];
try {
const xhr = new XMLHttpRequest();
const formData = new FormData();
formData.append("files", file);
await new Promise<void>((resolve, reject) => {
xhr.upload.addEventListener("progress", (e) => {
if (e.lengthComputable) {
const pct = Math.round((e.loaded / e.total) * 100);
setUploadingFiles((prev) =>
prev.map((uf) =>
uf.id === uploadEntry.id ? { ...uf, progress: pct } : uf
)
);
onUploadProgress?.(uploadEntry.id, pct);
}
});
xhr.addEventListener("load", () => {
if (xhr.status >= 200 && xhr.status < 300) {
setUploadingFiles((prev) =>
prev.map((uf) =>
uf.id === uploadEntry.id
? { ...uf, progress: 100, status: "complete" }
: uf
)
);
onUploadComplete?.(uploadEntry.id);
resolve();
} else {
const errMsg = "Upload failed";
setUploadingFiles((prev) =>
prev.map((uf) =>
uf.id === uploadEntry.id
? { ...uf, status: "error", error: errMsg }
: uf
)
);
onUploadError?.(uploadEntry.id, errMsg);
reject(new Error(errMsg));
}
});
xhr.addEventListener("error", () => {
const errMsg = "Network error";
setUploadingFiles((prev) =>
prev.map((uf) =>
uf.id === uploadEntry.id
? { ...uf, status: "error", error: errMsg }
: uf
)
);
onUploadError?.(uploadEntry.id, errMsg);
reject(new Error(errMsg));
});
xhr.open("POST", "/api/upload");
xhr.send(formData);
});
} catch {
// error already handled in XHR callbacks
}
}
const completeCount = filesToUpload.length;
toast.success(`Uploaded ${completeCount} file(s). Processing started.`);
};
const handleClose = () => {
onOpenChange(false);
setTimeout(() => {
setStagedFiles([]);
setUploadingFiles((prev) => prev.filter((f) => f.status === "uploading"));
}, 200);
};
const allDone = uploadingFiles.length > 0 && uploadingFiles.every((f) => f.status !== "uploading");
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Upload className="size-5" />
Upload Documents
</DialogTitle>
<DialogDescription>
Drag and drop files or browse to upload. You can close this dialog and uploads will continue in the background.
</DialogDescription>
</DialogHeader>
<div
onDragOver={(e) => {
e.preventDefault();
setDragOver(true);
}}
onDragLeave={() => setDragOver(false)}
onDrop={handleDrop}
className={cn(
"flex flex-col items-center justify-center rounded-xl border-2 border-dashed p-8 text-center transition-all",
dragOver
? "border-primary bg-primary/5 scale-[1.01]"
: "border-muted-foreground/20 hover:border-primary/30"
)}
>
<div className="mb-3 flex size-12 items-center justify-center rounded-xl bg-primary/10">
<Upload className="size-5 text-primary" />
</div>
<p className="mb-1 text-sm font-medium">Drop files here</p>
<p className="mb-3 text-xs text-muted-foreground">PDF, JPEG, PNG, WebP</p>
<Button
variant="outline"
size="sm"
className="rounded-xl"
onClick={() => fileInputRef.current?.click()}
>
<FolderOpen className="size-4" />
Browse Files
</Button>
<input
ref={fileInputRef}
type="file"
multiple
accept=".pdf,.jpg,.jpeg,.png,.webp"
className="hidden"
onChange={handleFileSelect}
/>
</div>
{(stagedFiles.length > 0 || uploadingFiles.length > 0) && (
<div className="max-h-48 space-y-1.5 overflow-y-auto">
{stagedFiles.map((file, i) => (
<div
key={`staged-${file.name}-${i}`}
className="flex items-center gap-2.5 rounded-lg border border-border/50 bg-muted/20 px-3 py-2"
>
{file.type === "application/pdf" ? (
<FileText className="size-4 shrink-0 text-red-500" />
) : (
<ImageIcon className="size-4 shrink-0 text-blue-500" />
)}
<span className="flex-1 min-w-0 truncate text-sm">{file.name}</span>
<span className="shrink-0 text-xs text-muted-foreground">
{(file.size / 1024).toFixed(0)} KB
</span>
<Button variant="ghost" size="icon-xs" onClick={() => removeStaged(i)}>
<X className="size-3" />
</Button>
</div>
))}
{uploadingFiles.map((uf) => (
<div
key={uf.id}
className="flex items-center gap-2.5 rounded-lg border border-border/50 bg-muted/20 px-3 py-2"
>
{uf.status === "uploading" && (
<Loader2 className="size-4 shrink-0 animate-spin text-primary" />
)}
{uf.status === "complete" && (
<CheckCircle className="size-4 shrink-0 text-emerald-500" />
)}
{uf.status === "error" && (
<AlertCircle className="size-4 shrink-0 text-red-500" />
)}
<span className="flex-1 min-w-0 truncate text-sm">{uf.name}</span>
{uf.status === "uploading" && (
<div className="flex items-center gap-2">
<div className="h-1.5 w-16 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${uf.progress}%` }}
/>
</div>
<span className="shrink-0 text-xs text-muted-foreground">
{uf.progress}%
</span>
</div>
)}
{uf.status === "complete" && (
<span className="text-xs text-emerald-600">Done</span>
)}
{uf.status === "error" && (
<span className="text-xs text-red-500">{uf.error}</span>
)}
</div>
))}
</div>
)}
<DialogFooter>
<DialogClose render={<Button variant="outline" />}>
{allDone ? "Done" : "Cancel"}
</DialogClose>
{stagedFiles.length > 0 && (
<Button onClick={uploadFiles} className="rounded-xl">
<Upload className="size-4" />
Upload {stagedFiles.length} File{stagedFiles.length !== 1 ? "s" : ""}
</Button>
)}
{uploadingFiles.some((f) => f.status === "uploading") && (
<Button variant="outline" onClick={handleClose} className="rounded-xl">
Continue in Background
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -1,176 +0,0 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useTheme } from "next-themes";
import {
LayoutDashboard,
Upload,
Settings,
Moon,
Sun,
ScanLine,
Menu,
} from "lucide-react";
import { useState } from "react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
SheetTrigger,
} from "@/components/ui/sheet";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
const navItems = [
{ href: "/", label: "Dashboard", icon: LayoutDashboard },
{ href: "/upload", label: "Upload", icon: Upload },
{ href: "/settings", label: "Settings", icon: Settings },
];
function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
const pathname = usePathname();
return (
<nav className="flex flex-col gap-1 px-3">
{navItems.map((item) => {
const isActive =
pathname === item.href ||
(item.href !== "/" && pathname.startsWith(item.href));
const Icon = item.icon;
return (
<Link
key={item.href}
href={item.href}
onClick={onNavigate}
className={cn(
"flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium transition-all min-h-[44px]",
isActive
? "bg-primary/10 text-primary shadow-sm dark:bg-primary/15"
: "text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-foreground"
)}
>
<Icon className={cn("size-[18px] shrink-0", isActive ? "text-primary" : "opacity-60")} />
{item.label}
</Link>
);
})}
</nav>
);
}
function ThemeToggle() {
const { theme, setTheme } = useTheme();
return (
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon"
className="size-9 rounded-xl text-sidebar-foreground/60 hover:bg-sidebar-accent hover:text-sidebar-foreground"
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
/>
}
>
<Sun className="size-[18px] dark:hidden" />
<Moon className="hidden size-[18px] dark:block" />
<span className="sr-only">Toggle theme</span>
</TooltipTrigger>
<TooltipContent side="right">
{theme === "dark" ? "Switch to light" : "Switch to dark"}
</TooltipContent>
</Tooltip>
);
}
function SidebarContent({ onNavigate }: { onNavigate?: () => void }) {
return (
<>
<div className="flex h-16 shrink-0 items-center gap-3 px-5">
<div className="flex size-9 items-center justify-center rounded-xl gradient-banner shadow-md">
<ScanLine className="size-[18px] text-white" />
</div>
<div className="flex flex-col">
<span className="text-sm font-bold tracking-tight text-sidebar-foreground">
Echo OCR
</span>
<span className="text-[11px] text-muted-foreground">
Document Scanner
</span>
</div>
</div>
<div className="flex flex-1 flex-col gap-1 overflow-auto py-4">
<div className="mb-2 px-5">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">
Menu
</span>
</div>
<NavLinks onNavigate={onNavigate} />
</div>
<div className="shrink-0 border-t border-sidebar-border/50 p-3">
<div className="flex items-center justify-between rounded-xl px-2 py-1">
<div className="flex items-center gap-2">
<div className="flex size-7 items-center justify-center rounded-lg bg-primary/10 text-primary">
{/* Theme icon inline */}
<Sun className="size-3.5 dark:hidden" />
<Moon className="hidden size-3.5 dark:block" />
</div>
<span className="text-xs font-medium text-muted-foreground">Theme</span>
</div>
<ThemeToggle />
</div>
</div>
</>
);
}
export function Sidebar() {
const [open, setOpen] = useState(false);
return (
<>
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger
render={
<Button
variant="ghost"
size="icon"
className="fixed left-3 top-3.5 z-40 size-10 rounded-xl glass-card md:hidden"
/>
}
>
<Menu className="size-5" />
<span className="sr-only">Open menu</span>
</SheetTrigger>
<SheetContent
side="left"
className="w-[260px] border-sidebar-border/50 glass-sidebar p-0"
showCloseButton={true}
>
<div className="flex h-full w-full flex-col">
<SidebarContent onNavigate={() => setOpen(false)} />
</div>
</SheetContent>
</Sheet>
<aside
className={cn(
"fixed inset-y-0 left-0 z-30 hidden w-[260px] flex-col glass-sidebar",
"md:flex"
)}
>
<SidebarContent />
</aside>
</>
);
}

View file

@ -0,0 +1,136 @@
"use client";
import Link from "next/link";
import { useTheme } from "next-themes";
import {
Upload,
Bell,
Moon,
Sun,
Settings,
LogOut,
ScanLine,
User,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuLabel,
} from "@/components/ui/dropdown-menu";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
export function TopBar() {
const { theme, setTheme } = useTheme();
const handleUploadClick = () => {
window.dispatchEvent(new CustomEvent("open-upload-modal"));
};
return (
<header className="glass-panel fixed inset-x-0 top-0 z-40 flex h-16 items-center justify-between px-4 sm:px-6 lg:px-8">
<Link href="/" className="flex items-center gap-2.5">
<div className="flex size-9 items-center justify-center rounded-xl gradient-banner shadow-sm">
<ScanLine className="size-[18px] text-white" />
</div>
<span className="text-base font-bold tracking-tight">Echo OCR</span>
</Link>
<div className="flex items-center gap-1 sm:gap-2">
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="sm"
className="hidden rounded-xl sm:inline-flex"
onClick={handleUploadClick}
/>
}
>
<Upload className="size-4" />
<span className="ml-1.5">Upload</span>
</TooltipTrigger>
<TooltipContent>Upload documents</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon"
className="size-9 rounded-xl sm:hidden"
onClick={handleUploadClick}
/>
}
>
<Upload className="size-4" />
</TooltipTrigger>
<TooltipContent>Upload documents</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon"
className="size-9 rounded-xl"
/>
}
>
<Bell className="size-4" />
<span className="sr-only">Notifications</span>
</TooltipTrigger>
<TooltipContent>Notifications</TooltipContent>
</Tooltip>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="ghost" size="icon" className="size-9 rounded-xl" />
}
>
<Avatar size="sm">
<AvatarFallback>
<User className="size-3.5" />
</AvatarFallback>
</Avatar>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={8} className="w-48">
<DropdownMenuLabel>My Account</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
render={<Link href="/settings" />}
>
<Settings className="size-4" />
Settings
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
>
<Sun className="size-4 dark:hidden" />
<Moon className="hidden size-4 dark:block" />
{theme === "dark" ? "Light Mode" : "Dark Mode"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">
<LogOut className="size-4" />
Sign Out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
);
}