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-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%);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 * 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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";
|
"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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
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>
|
||||||
|
|
|
||||||
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