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:
parent
eabfb4935d
commit
7ff9724a63
14 changed files with 1154 additions and 991 deletions
|
|
@ -8,7 +8,7 @@
|
|||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--font-mono: var(--font-mono);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
|
|
@ -48,72 +48,72 @@
|
|||
}
|
||||
|
||||
:root {
|
||||
--background: oklch(0.97 0.005 270);
|
||||
--foreground: oklch(0.17 0.02 270);
|
||||
--background: oklch(0.97 0.003 90);
|
||||
--foreground: oklch(0.18 0.005 90);
|
||||
--card: oklch(1 0 0 / 65%);
|
||||
--card-foreground: oklch(0.17 0.02 270);
|
||||
--popover: oklch(0.99 0.002 270 / 90%);
|
||||
--popover-foreground: oklch(0.17 0.02 270);
|
||||
--primary: oklch(0.488 0.243 264);
|
||||
--card-foreground: oklch(0.18 0.005 90);
|
||||
--popover: oklch(0.99 0.002 90 / 92%);
|
||||
--popover-foreground: oklch(0.18 0.005 90);
|
||||
--primary: oklch(0.20 0.005 90);
|
||||
--primary-foreground: oklch(0.99 0 0);
|
||||
--secondary: oklch(0.93 0.02 270 / 60%);
|
||||
--secondary-foreground: oklch(0.30 0.05 270);
|
||||
--muted: oklch(0.94 0.01 270 / 50%);
|
||||
--muted-foreground: oklch(0.50 0.02 270);
|
||||
--accent: oklch(0.90 0.05 280 / 40%);
|
||||
--accent-foreground: oklch(0.30 0.08 270);
|
||||
--secondary: oklch(0.94 0.005 90 / 60%);
|
||||
--secondary-foreground: oklch(0.25 0.005 90);
|
||||
--muted: oklch(0.94 0.003 90 / 50%);
|
||||
--muted-foreground: oklch(0.50 0.008 90);
|
||||
--accent: oklch(0.92 0.01 80 / 40%);
|
||||
--accent-foreground: oklch(0.25 0.01 90);
|
||||
--destructive: oklch(0.58 0.22 27);
|
||||
--border: oklch(0.80 0.02 270 / 30%);
|
||||
--input: oklch(0.92 0.01 270 / 50%);
|
||||
--ring: oklch(0.55 0.20 264);
|
||||
--chart-1: oklch(0.65 0.20 264);
|
||||
--chart-2: oklch(0.60 0.18 280);
|
||||
--chart-3: oklch(0.55 0.22 300);
|
||||
--chart-4: oklch(0.70 0.15 250);
|
||||
--chart-5: oklch(0.50 0.20 270);
|
||||
--border: oklch(0.82 0.005 90 / 30%);
|
||||
--input: oklch(0.93 0.003 90 / 50%);
|
||||
--ring: oklch(0.35 0.005 90);
|
||||
--chart-1: oklch(0.55 0.12 160);
|
||||
--chart-2: oklch(0.60 0.10 80);
|
||||
--chart-3: oklch(0.50 0.08 45);
|
||||
--chart-4: oklch(0.65 0.06 200);
|
||||
--chart-5: oklch(0.45 0.10 140);
|
||||
--radius: 0.75rem;
|
||||
--sidebar: oklch(1 0 0 / 50%);
|
||||
--sidebar-foreground: oklch(0.20 0.02 270);
|
||||
--sidebar-primary: oklch(0.488 0.243 264);
|
||||
--sidebar-foreground: oklch(0.20 0.005 90);
|
||||
--sidebar-primary: oklch(0.20 0.005 90);
|
||||
--sidebar-primary-foreground: oklch(0.99 0 0);
|
||||
--sidebar-accent: oklch(0.90 0.06 270 / 40%);
|
||||
--sidebar-accent-foreground: oklch(0.25 0.06 270);
|
||||
--sidebar-border: oklch(0.80 0.02 270 / 25%);
|
||||
--sidebar-ring: oklch(0.55 0.20 264);
|
||||
--sidebar-accent: oklch(0.92 0.005 90 / 40%);
|
||||
--sidebar-accent-foreground: oklch(0.25 0.005 90);
|
||||
--sidebar-border: oklch(0.82 0.005 90 / 25%);
|
||||
--sidebar-ring: oklch(0.35 0.005 90);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.14 0.02 270);
|
||||
--foreground: oklch(0.93 0.01 270);
|
||||
--background: oklch(0.14 0.005 90);
|
||||
--foreground: oklch(0.93 0.005 90);
|
||||
--card: oklch(1 0 0 / 6%);
|
||||
--card-foreground: oklch(0.93 0.01 270);
|
||||
--popover: oklch(0.20 0.02 270 / 85%);
|
||||
--popover-foreground: oklch(0.93 0.01 270);
|
||||
--primary: oklch(0.65 0.20 264);
|
||||
--primary-foreground: oklch(0.99 0 0);
|
||||
--card-foreground: oklch(0.93 0.005 90);
|
||||
--popover: oklch(0.20 0.005 90 / 85%);
|
||||
--popover-foreground: oklch(0.93 0.005 90);
|
||||
--primary: oklch(0.88 0.005 90);
|
||||
--primary-foreground: oklch(0.14 0.005 90);
|
||||
--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-foreground: oklch(0.65 0.02 270);
|
||||
--accent: oklch(0.50 0.15 270 / 20%);
|
||||
--accent-foreground: oklch(0.90 0.02 270);
|
||||
--muted-foreground: oklch(0.65 0.005 90);
|
||||
--accent: oklch(0.50 0.01 80 / 20%);
|
||||
--accent-foreground: oklch(0.90 0.005 90);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 10%);
|
||||
--ring: oklch(0.65 0.20 264);
|
||||
--chart-1: oklch(0.70 0.18 264);
|
||||
--chart-2: oklch(0.65 0.16 280);
|
||||
--chart-3: oklch(0.60 0.20 300);
|
||||
--chart-4: oklch(0.75 0.12 250);
|
||||
--chart-5: oklch(0.55 0.18 270);
|
||||
--ring: oklch(0.70 0.005 90);
|
||||
--chart-1: oklch(0.60 0.10 160);
|
||||
--chart-2: oklch(0.65 0.08 80);
|
||||
--chart-3: oklch(0.55 0.06 45);
|
||||
--chart-4: oklch(0.70 0.05 200);
|
||||
--chart-5: oklch(0.50 0.08 140);
|
||||
--sidebar: oklch(1 0 0 / 5%);
|
||||
--sidebar-foreground: oklch(0.90 0.01 270);
|
||||
--sidebar-primary: oklch(0.65 0.20 264);
|
||||
--sidebar-primary-foreground: oklch(0.99 0 0);
|
||||
--sidebar-foreground: oklch(0.90 0.005 90);
|
||||
--sidebar-primary: oklch(0.88 0.005 90);
|
||||
--sidebar-primary-foreground: oklch(0.14 0.005 90);
|
||||
--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-ring: oklch(0.65 0.20 264);
|
||||
--sidebar-ring: oklch(0.70 0.005 90);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
|
@ -130,13 +130,13 @@
|
|||
|
||||
@layer utilities {
|
||||
.glass {
|
||||
backdrop-filter: blur(20px) saturate(1.8);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(1.8);
|
||||
backdrop-filter: blur(20px) saturate(1.6);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(1.6);
|
||||
}
|
||||
|
||||
.glass-card {
|
||||
backdrop-filter: blur(24px) saturate(1.8);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(1.8);
|
||||
backdrop-filter: blur(24px) saturate(1.6);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(1.6);
|
||||
background: oklch(1 0 0 / 55%);
|
||||
border: 1px solid oklch(1 0 0 / 30%);
|
||||
box-shadow:
|
||||
|
|
@ -154,22 +154,18 @@
|
|||
inset 0 1px 0 oklch(1 0 0 / 5%);
|
||||
}
|
||||
|
||||
.glass-sidebar {
|
||||
.glass-panel {
|
||||
backdrop-filter: blur(24px) saturate(1.6);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(1.6);
|
||||
background: oklch(1 0 0 / 60%);
|
||||
border-right: 1px solid oklch(1 0 0 / 30%);
|
||||
box-shadow:
|
||||
4px 0 24px oklch(0 0 0 / 4%),
|
||||
inset -1px 0 0 oklch(1 0 0 / 20%);
|
||||
border-bottom: 1px solid oklch(1 0 0 / 30%);
|
||||
box-shadow: 0 1px 8px oklch(0 0 0 / 4%);
|
||||
}
|
||||
|
||||
.dark .glass-sidebar {
|
||||
.dark .glass-panel {
|
||||
background: oklch(1 0 0 / 4%);
|
||||
border-right-color: oklch(1 0 0 / 8%);
|
||||
box-shadow:
|
||||
4px 0 24px oklch(0 0 0 / 20%),
|
||||
inset -1px 0 0 oklch(1 0 0 / 3%);
|
||||
border-bottom-color: oklch(1 0 0 / 8%);
|
||||
box-shadow: 0 1px 8px oklch(0 0 0 / 20%);
|
||||
}
|
||||
|
||||
.glass-input {
|
||||
|
|
@ -186,24 +182,24 @@
|
|||
|
||||
.gradient-mesh {
|
||||
background:
|
||||
radial-gradient(ellipse 80% 50% at 20% 20%, oklch(0.80 0.12 264 / 15%) 0%, transparent 60%),
|
||||
radial-gradient(ellipse 60% 40% at 80% 80%, oklch(0.75 0.15 300 / 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 80% 50% at 15% 15%, oklch(0.85 0.04 80 / 18%) 0%, transparent 60%),
|
||||
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.90 0.02 60 / 10%) 0%, transparent 60%);
|
||||
}
|
||||
|
||||
.dark .gradient-mesh {
|
||||
background:
|
||||
radial-gradient(ellipse 80% 50% at 20% 20%, oklch(0.35 0.15 264 / 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 50% 60% at 50% 50%, oklch(0.25 0.10 250 / 10%) 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 85% 75%, oklch(0.25 0.04 140 / 15%) 0%, transparent 50%),
|
||||
radial-gradient(ellipse 50% 60% at 50% 50%, oklch(0.22 0.02 60 / 10%) 0%, transparent 60%);
|
||||
}
|
||||
|
||||
.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 {
|
||||
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);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(1.5);
|
||||
border: 1px solid oklch(1 0 0 / 40%);
|
||||
|
|
|
|||
|
|
@ -1,17 +1,13 @@
|
|||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { Quicksand } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { Providers } from "@/components/providers";
|
||||
import { Sidebar } from "@/components/layout/sidebar";
|
||||
import { TopBar } from "@/components/layout/top-bar";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
const quicksand = Quicksand({
|
||||
variable: "--font-sans",
|
||||
subsets: ["latin"],
|
||||
weight: ["300", "400", "500", "600", "700"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
|
|
@ -26,15 +22,15 @@ export default function RootLayout({
|
|||
}>) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} font-sans antialiased`}
|
||||
>
|
||||
<body className={`${quicksand.variable} font-sans antialiased`}>
|
||||
<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" />
|
||||
<Sidebar />
|
||||
<main className="relative z-10 flex-1 pt-16 md:pt-0 md:pl-[260px]">
|
||||
<div className="p-4 sm:p-6 md:p-8">{children}</div>
|
||||
<TopBar />
|
||||
<main className="relative z-10 pt-16">
|
||||
<div className="mx-auto max-w-7xl p-4 sm:p-6 lg:p-8">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</Providers>
|
||||
|
|
|
|||
|
|
@ -1,32 +1,27 @@
|
|||
import { Suspense } from "react";
|
||||
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";
|
||||
|
||||
export default function DashboardPage() {
|
||||
return (
|
||||
<div className="space-y-6 sm:space-y-8">
|
||||
<DashboardHero />
|
||||
<StatCards />
|
||||
<QuickActions />
|
||||
|
||||
<div>
|
||||
<h2 className="mb-4 text-lg font-semibold tracking-tight">
|
||||
All Response Cards
|
||||
</h2>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-10 w-full rounded-xl" />
|
||||
<Skeleton className="h-[400px] w-full rounded-xl" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<DashboardContent />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Skeleton className="h-8 w-48 rounded-lg" />
|
||||
<Skeleton className="mt-2 h-4 w-64 rounded-lg" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-28 rounded-2xl" />
|
||||
))}
|
||||
</div>
|
||||
<Skeleton className="h-10 w-full rounded-xl" />
|
||||
<Skeleton className="h-[400px] w-full rounded-2xl" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<DashboardContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,8 +3,12 @@
|
|||
import * as React from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { DataTable } from "./data-table";
|
||||
|
||||
import { StatCards, type StatFilter } from "./stat-cards";
|
||||
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";
|
||||
|
||||
const VISIT_TYPE_OPTIONS = [
|
||||
|
|
@ -35,6 +39,11 @@ export function DashboardContent() {
|
|||
const [total, setTotal] = React.useState(0);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
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 () => {
|
||||
setLoading(true);
|
||||
|
|
@ -71,13 +80,42 @@ export function DashboardContent() {
|
|||
fetchCards();
|
||||
}, [fetchCards]);
|
||||
|
||||
const updateUrl = (updates: Record<string, string | number>) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
params.set(key, String(value));
|
||||
}
|
||||
router.push(`/?${params.toString()}`);
|
||||
};
|
||||
const updateUrl = React.useCallback(
|
||||
(updates: Record<string, string | number | undefined>) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (value === undefined || value === "") {
|
||||
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 (
|
||||
ids: string[],
|
||||
|
|
@ -114,20 +152,20 @@ export function DashboardContent() {
|
|||
}
|
||||
|
||||
const headers = [
|
||||
"Name", "Email", "Phone", "City", "State", "Gender", "DOB",
|
||||
"Name", "Email", "Phone", "City", "State", "Gender",
|
||||
"Visit Type", "Attendance", "Service", "OCR Status", "Review Status",
|
||||
"Confidence",
|
||||
];
|
||||
const rows = data.map((c) => [
|
||||
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.reviewStatus, c.ocrConfidence?.toString() || "",
|
||||
]);
|
||||
|
||||
const csv = [headers, ...rows].map((r) =>
|
||||
r.map((v) => `"${v.replace(/"/g, '""')}"`).join(",")
|
||||
).join("\n");
|
||||
const csv = [headers, ...rows]
|
||||
.map((r) => r.map((v) => `"${v.replace(/"/g, '""')}"`).join(","))
|
||||
.join("\n");
|
||||
|
||||
const blob = new Blob([csv], { type: "text/csv" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
|
@ -161,16 +199,110 @@ export function DashboardContent() {
|
|||
[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 (
|
||||
<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
|
||||
columnVisibility={columnVisibility}
|
||||
onColumnVisibilityChange={setColumnVisibility}
|
||||
visitTypeOptions={VISIT_TYPE_OPTIONS}
|
||||
attendanceDurationOptions={ATTENDANCE_OPTIONS}
|
||||
serviceAttendedOptions={SERVICE_OPTIONS}
|
||||
onExportCsv={handleExportCsv}
|
||||
onUploadClick={openUpload}
|
||||
/>
|
||||
|
||||
{/* Data table */}
|
||||
<div className={loading ? "opacity-60 transition-opacity" : ""}>
|
||||
<DataTable
|
||||
data={data}
|
||||
|
|
@ -185,11 +317,29 @@ export function DashboardContent() {
|
|||
onPageChange={(p) => updateUrl({ page: p })}
|
||||
onLimitChange={(l) => updateUrl({ limit: l, page: 1 })}
|
||||
onSortChange={(sb, so) => updateUrl({ sortBy: sb, sortOrder: so })}
|
||||
onBulkMarkReviewed={(ids) => handleBulkAction(ids, "reviewed")}
|
||||
onBulkMarkExported={(ids) => handleBulkAction(ids, "exported")}
|
||||
onBulkDelete={(ids) => handleBulkAction(ids, "delete")}
|
||||
uploadingRows={uploadingRows.filter((f) => f.status === "uploading")}
|
||||
onSelectionChange={setSelectedIds}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
type ColumnDef,
|
||||
type SortingState,
|
||||
|
|
@ -15,9 +16,7 @@ import {
|
|||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
CheckCircle,
|
||||
Download,
|
||||
Trash2,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -29,7 +28,31 @@ import {
|
|||
TableHeader,
|
||||
TableRow,
|
||||
} 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 { 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> = {
|
||||
data: TData[];
|
||||
|
|
@ -39,14 +62,13 @@ type DataTableProps<TData> = {
|
|||
limit: number;
|
||||
onPageChange?: (page: number) => void;
|
||||
onLimitChange?: (limit: number) => void;
|
||||
onBulkMarkReviewed?: (ids: string[]) => void;
|
||||
onBulkMarkExported?: (ids: string[]) => void;
|
||||
onBulkDelete?: (ids: string[]) => void;
|
||||
columnVisibility?: VisibilityState;
|
||||
onColumnVisibilityChange?: (visibility: VisibilityState) => void;
|
||||
sortBy?: string;
|
||||
sortOrder?: "asc" | "desc";
|
||||
onSortChange?: (sortBy: string, sortOrder: "asc" | "desc") => void;
|
||||
uploadingRows?: UploadingFile[];
|
||||
onSelectionChange?: (selectedIds: string[]) => void;
|
||||
};
|
||||
|
||||
const PAGE_SIZES = [10, 20, 50, 100];
|
||||
|
|
@ -59,15 +81,16 @@ export function DataTable<TData extends ResponseCard>({
|
|||
limit,
|
||||
onPageChange,
|
||||
onLimitChange,
|
||||
onBulkMarkReviewed,
|
||||
onBulkMarkExported,
|
||||
onBulkDelete,
|
||||
columnVisibility: controlledVisibility,
|
||||
onColumnVisibilityChange,
|
||||
sortBy: controlledSortBy,
|
||||
sortOrder: controlledSortOrder,
|
||||
onSortChange,
|
||||
uploadingRows = [],
|
||||
onSelectionChange,
|
||||
}: DataTableProps<TData>) {
|
||||
const router = useRouter();
|
||||
|
||||
const sorting: SortingState =
|
||||
controlledSortBy && controlledSortOrder
|
||||
? [{ id: controlledSortBy, desc: controlledSortOrder === "desc" }]
|
||||
|
|
@ -93,6 +116,11 @@ export function DataTable<TData extends ResponseCard>({
|
|||
const setVisibility =
|
||||
onColumnVisibilityChange ?? setInternalVisibility;
|
||||
|
||||
const [contextMenu, setContextMenu] = React.useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
|
|
@ -122,8 +150,14 @@ export function DataTable<TData extends ResponseCard>({
|
|||
});
|
||||
|
||||
const selectedRows = table.getFilteredSelectedRowModel().rows;
|
||||
const selectedCount = selectedRows.length;
|
||||
const selectedIds = selectedRows.map((r) => r.original.id);
|
||||
const selectedIds = React.useMemo(
|
||||
() => selectedRows.map((r) => r.original.id),
|
||||
[selectedRows]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
onSelectionChange?.(selectedIds);
|
||||
}, [selectedIds, onSelectionChange]);
|
||||
|
||||
const totalPages = Math.ceil(totalCount / limit);
|
||||
const canPrev = page > 1;
|
||||
|
|
@ -131,67 +165,54 @@ export function DataTable<TData extends ResponseCard>({
|
|||
const start = (page - 1) * limit + 1;
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
{selectedCount > 0 && (
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3 glass-card rounded-2xl px-4 py-3">
|
||||
<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">
|
||||
{/* Desktop table */}
|
||||
<div className="glass-card overflow-hidden rounded-2xl hidden md:block">
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableHeader onContextMenu={handleHeaderContextMenu}>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{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
|
||||
? null
|
||||
: flexRender(
|
||||
|
|
@ -204,12 +225,45 @@ export function DataTable<TData extends ResponseCard>({
|
|||
))}
|
||||
</TableHeader>
|
||||
<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.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
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) => (
|
||||
<TableCell key={cell.id} className="px-4 py-3">
|
||||
|
|
@ -222,20 +276,120 @@ export function DataTable<TData extends ResponseCard>({
|
|||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center text-muted-foreground"
|
||||
>
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
uploadingRows.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center text-muted-foreground"
|
||||
>
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</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-wrap items-center gap-3 text-sm text-muted-foreground">
|
||||
<span>
|
||||
|
|
@ -306,6 +460,39 @@ export function DataTable<TData extends ResponseCard>({
|
|||
</Button>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,16 +2,9 @@
|
|||
|
||||
import * as React from "react";
|
||||
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 {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
|
|
@ -20,72 +13,35 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} 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 = {
|
||||
search?: string;
|
||||
reviewStatus?: string;
|
||||
ocrStatus?: string;
|
||||
visitType?: string;
|
||||
attendanceDuration?: string;
|
||||
serviceAttended?: string;
|
||||
visitTypeOptions?: string[];
|
||||
attendanceDurationOptions?: string[];
|
||||
serviceAttendedOptions?: string[];
|
||||
columnVisibility?: Record<string, boolean>;
|
||||
onColumnVisibilityChange?: (visibility: Record<string, boolean>) => void;
|
||||
onExportCsv?: () => void;
|
||||
onUploadClick?: () => void;
|
||||
};
|
||||
|
||||
export function Filters({
|
||||
search: initialSearch = "",
|
||||
reviewStatus: initialReviewStatus = "",
|
||||
ocrStatus: initialOcrStatus = "",
|
||||
visitType: initialVisitType = "",
|
||||
attendanceDuration: initialAttendanceDuration = "",
|
||||
serviceAttended: initialServiceAttended = "",
|
||||
visitTypeOptions = [],
|
||||
attendanceDurationOptions = [],
|
||||
serviceAttendedOptions = [],
|
||||
columnVisibility = {},
|
||||
onColumnVisibilityChange,
|
||||
onExportCsv,
|
||||
onUploadClick,
|
||||
}: FiltersProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
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 attendanceDuration =
|
||||
searchParams.get("attendanceDuration") ?? initialAttendanceDuration;
|
||||
|
|
@ -113,89 +69,32 @@ export function Filters({
|
|||
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 (
|
||||
<div className="glass-card rounded-2xl p-4 sm:p-5">
|
||||
<div className="flex flex-col gap-4">
|
||||
<form onSubmit={handleSearchSubmit} className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<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" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search name, email, phone..."
|
||||
value={search}
|
||||
onChange={handleSearchChange}
|
||||
className="pl-9 rounded-xl"
|
||||
/>
|
||||
</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-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-1 flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<div className="relative w-full sm:max-w-xs">
|
||||
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search..."
|
||||
value={search}
|
||||
onChange={handleSearchChange}
|
||||
className="pl-9 rounded-xl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
|
||||
<div className="flex items-center gap-2 overflow-x-auto">
|
||||
<Select
|
||||
value={visitType || "__all__"}
|
||||
onValueChange={(v: string | null) =>
|
||||
updateParams({
|
||||
visitType: !v || v === "__all__" ? undefined : v,
|
||||
})
|
||||
updateParams({ 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" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">All visit types</SelectItem>
|
||||
<SelectItem value="__all__">All visits</SelectItem>
|
||||
{visitTypeOptions.map((opt) => (
|
||||
<SelectItem key={opt} value={opt}>
|
||||
{opt}
|
||||
|
|
@ -203,6 +102,7 @@ export function Filters({
|
|||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={attendanceDuration || "__all__"}
|
||||
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" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -223,6 +123,7 @@ export function Filters({
|
|||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={serviceAttended || "__all__"}
|
||||
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" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -243,40 +144,28 @@ export function Filters({
|
|||
))}
|
||||
</SelectContent>
|
||||
</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 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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
91
src/components/cards/selection-toolbar.tsx
Normal file
91
src/components/cards/selection-toolbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -6,8 +6,6 @@ import {
|
|||
CheckCircle,
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
|
@ -17,16 +15,23 @@ type Stats = {
|
|||
byReviewStatus: Record<string, number>;
|
||||
};
|
||||
|
||||
export type StatFilter = "all" | "complete" | "error" | "unreviewed" | null;
|
||||
|
||||
type StatCardData = {
|
||||
label: string;
|
||||
value: number;
|
||||
icon: React.ElementType;
|
||||
trend?: string;
|
||||
trendUp?: boolean;
|
||||
filterKey: StatFilter;
|
||||
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);
|
||||
|
||||
React.useEffect(() => {
|
||||
|
|
@ -39,10 +44,10 @@ export function StatCards() {
|
|||
const cards: StatCardData[] = React.useMemo(() => {
|
||||
if (!stats) {
|
||||
return [
|
||||
{ label: "Total Cards", value: 0, icon: FileText, accentClass: "text-primary bg-primary/10" },
|
||||
{ label: "OCR Complete", value: 0, icon: CheckCircle, accentClass: "text-emerald-600 bg-emerald-500/10 dark:text-emerald-400" },
|
||||
{ label: "Errors", value: 0, icon: AlertTriangle, accentClass: "text-red-600 bg-red-500/10 dark:text-red-400" },
|
||||
{ label: "Pending Review", value: 0, icon: Clock, accentClass: "text-amber-600 bg-amber-500/10 dark:text-amber-400" },
|
||||
{ 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, 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, 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, filterKey: "unreviewed", accentClass: "text-amber-600 bg-amber-500/10 dark:text-amber-400", activeRing: "ring-amber-500/40" },
|
||||
];
|
||||
}
|
||||
return [
|
||||
|
|
@ -50,52 +55,70 @@ export function StatCards() {
|
|||
label: "Total Cards",
|
||||
value: stats.total,
|
||||
icon: FileText,
|
||||
filterKey: "all" as StatFilter,
|
||||
accentClass: "text-primary bg-primary/10",
|
||||
activeRing: "ring-primary/40",
|
||||
},
|
||||
{
|
||||
label: "OCR Complete",
|
||||
value: stats.byOcrStatus["complete"] || 0,
|
||||
icon: CheckCircle,
|
||||
filterKey: "complete" as StatFilter,
|
||||
accentClass: "text-emerald-600 bg-emerald-500/10 dark:text-emerald-400",
|
||||
trendUp: true,
|
||||
activeRing: "ring-emerald-500/40",
|
||||
},
|
||||
{
|
||||
label: "Errors",
|
||||
value: stats.byOcrStatus["error"] || 0,
|
||||
icon: AlertTriangle,
|
||||
filterKey: "error" as StatFilter,
|
||||
accentClass: "text-red-600 bg-red-500/10 dark:text-red-400",
|
||||
trendUp: false,
|
||||
activeRing: "ring-red-500/40",
|
||||
},
|
||||
{
|
||||
label: "Pending Review",
|
||||
value: stats.byReviewStatus["unreviewed"] || 0,
|
||||
icon: Clock,
|
||||
filterKey: "unreviewed" as StatFilter,
|
||||
accentClass: "text-amber-600 bg-amber-500/10 dark:text-amber-400",
|
||||
activeRing: "ring-amber-500/40",
|
||||
},
|
||||
];
|
||||
}, [stats]);
|
||||
|
||||
const handleClick = (filterKey: StatFilter) => {
|
||||
if (activeFilter === filterKey) {
|
||||
onFilterChange(null);
|
||||
} else {
|
||||
onFilterChange(filterKey);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3 sm:gap-4 lg:grid-cols-4">
|
||||
{cards.map((card) => {
|
||||
const Icon = card.icon;
|
||||
const isActive = activeFilter === card.filterKey;
|
||||
return (
|
||||
<div
|
||||
<button
|
||||
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={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" />
|
||||
</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>
|
||||
<p className="text-2xl font-bold tracking-tight sm:text-3xl">
|
||||
|
|
@ -105,7 +128,7 @@ export function StatCards() {
|
|||
{card.label}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
|
|
|||
311
src/components/cards/upload-modal.tsx
Normal file
311
src/components/cards/upload-modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
136
src/components/layout/top-bar.tsx
Normal file
136
src/components/layout/top-bar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue