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

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

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

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

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

404 lines
14 KiB
TypeScript

"use client";
import * as React from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import {
Users,
Search,
MoreHorizontal,
Eye,
Merge,
Trash2,
Link2,
Loader2,
ChevronLeft,
ChevronRight,
CalendarPlus,
} from "lucide-react";
import { formatDistanceToNow, differenceInDays, format } from "date-fns";
import { Header } from "@/components/layout/header";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
type PersonRow = {
id: string;
firstName: string;
lastName: string;
email: string | null;
cellPhone: string | null;
createdAt: string;
updatedAt: string;
_count: { cards: number };
cards?: { createdAt: string }[];
};
type PeopleResponse = {
people: PersonRow[];
total: number;
page: number;
limit: number;
};
function formatDate(dateStr: string): string {
const d = new Date(dateStr);
if (differenceInDays(new Date(), d) < 30) {
return formatDistanceToNow(d, { addSuffix: true });
}
return format(d, "MMM d, yyyy");
}
export default function PeoplePage() {
const router = useRouter();
const [data, setData] = React.useState<PeopleResponse | null>(null);
const [loading, setLoading] = React.useState(true);
const [search, setSearch] = React.useState("");
const [debouncedSearch, setDebouncedSearch] = React.useState("");
const [page, setPage] = React.useState(1);
const [linking, setLinking] = React.useState(false);
const [deletingId, setDeletingId] = React.useState<string | null>(null);
const limit = 20;
React.useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearch(search);
setPage(1);
}, 300);
return () => clearTimeout(timer);
}, [search]);
const fetchPeople = React.useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams({
page: String(page),
limit: String(limit),
});
if (debouncedSearch) params.set("search", debouncedSearch);
const res = await fetch(`/api/people?${params.toString()}`);
if (!res.ok) throw new Error();
const json = await res.json();
setData(json);
} catch {
toast.error("Failed to load people");
} finally {
setLoading(false);
}
}, [page, debouncedSearch]);
React.useEffect(() => {
fetchPeople();
}, [fetchPeople]);
const totalPages = data ? Math.max(1, Math.ceil(data.total / limit)) : 1;
const newThisMonth = React.useMemo(() => {
if (!data) return 0;
const now = new Date();
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
return data.people.filter(
(p) => new Date(p.createdAt) >= startOfMonth
).length;
}, [data]);
const handleLinkCards = async () => {
setLinking(true);
try {
const res = await fetch("/api/people/link", { method: "POST" });
if (!res.ok) throw new Error();
const result = await res.json();
toast.success(
`Linked ${result.linked} card${result.linked !== 1 ? "s" : ""}, created ${result.created} new ${result.created !== 1 ? "people" : "person"}`
);
fetchPeople();
} catch {
toast.error("Failed to link cards");
} finally {
setLinking(false);
}
};
const handleDelete = async (personId: string) => {
setDeletingId(personId);
try {
const res = await fetch(`/api/people/${personId}`, { method: "DELETE" });
if (!res.ok) throw new Error();
toast.success("Person deleted");
fetchPeople();
} catch {
toast.error("Failed to delete person");
} finally {
setDeletingId(null);
}
};
if (loading && !data) {
return (
<div className="space-y-6">
<Skeleton className="h-10 w-64 rounded-xl" />
<Skeleton className="h-4 w-80 rounded-lg" />
<Skeleton className="h-10 w-full rounded-xl" />
<div className="grid grid-cols-2 gap-3">
<Skeleton className="h-20 rounded-2xl" />
<Skeleton className="h-20 rounded-2xl" />
</div>
<div className="space-y-2">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full rounded-xl" />
))}
</div>
</div>
);
}
const people = data?.people ?? [];
const isEmpty = !loading && people.length === 0 && !debouncedSearch;
const noResults = !loading && people.length === 0 && !!debouncedSearch;
return (
<div className="space-y-6">
<Header
title="People"
description="Contact directory from scanned response cards"
icon={Users}
>
<Button
variant="outline"
size="sm"
className="rounded-xl"
onClick={handleLinkCards}
disabled={linking}
>
{linking ? (
<>
<Loader2 className="mr-1 size-4 animate-spin" /> Linking...
</>
) : (
<>
<Link2 className="mr-1 size-4" /> Link Unlinked Cards
</>
)}
</Button>
</Header>
{isEmpty ? (
<div className="glass-card flex flex-col items-center justify-center rounded-2xl p-12 text-center">
<Users className="mb-4 size-12 text-muted-foreground/40" />
<h2 className="text-lg font-semibold">No people yet</h2>
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
People are automatically created when response cards are processed.
</p>
</div>
) : (
<>
<div className="relative">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search by name or email..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="gradient-stat flex flex-col gap-1 rounded-2xl p-4">
<div className="flex size-9 items-center justify-center rounded-xl bg-primary/10 text-primary">
<Users className="size-5" />
</div>
<p className="mt-1 text-2xl font-bold tracking-tight">
{data?.total.toLocaleString() ?? "—"}
</p>
<p className="text-xs text-muted-foreground">Total People</p>
</div>
<div className="gradient-stat flex flex-col gap-1 rounded-2xl p-4">
<div className="flex size-9 items-center justify-center rounded-xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">
<CalendarPlus className="size-5" />
</div>
<p className="mt-1 text-2xl font-bold tracking-tight">
{newThisMonth}
</p>
<p className="text-xs text-muted-foreground">New This Month</p>
</div>
</div>
{noResults ? (
<div className="glass-card flex flex-col items-center justify-center rounded-2xl p-12 text-center">
<Search className="mb-4 size-10 text-muted-foreground/40" />
<h2 className="text-lg font-semibold">No results</h2>
<p className="mt-1 text-sm text-muted-foreground">
No people match &ldquo;{debouncedSearch}&rdquo;
</p>
</div>
) : (
<div className="glass-card overflow-hidden rounded-2xl">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead className="hidden sm:table-cell">
Email
</TableHead>
<TableHead className="hidden md:table-cell">
Phone
</TableHead>
<TableHead className="text-center">Cards</TableHead>
<TableHead className="hidden lg:table-cell">
Last Seen
</TableHead>
<TableHead className="w-10" />
</TableRow>
</TableHeader>
<TableBody>
{loading
? Array.from({ length: 5 }).map((_, i) => (
<TableRow key={i}>
<TableCell>
<Skeleton className="h-5 w-32 rounded" />
</TableCell>
<TableCell className="hidden sm:table-cell">
<Skeleton className="h-4 w-40 rounded" />
</TableCell>
<TableCell className="hidden md:table-cell">
<Skeleton className="h-4 w-28 rounded" />
</TableCell>
<TableCell className="text-center">
<Skeleton className="mx-auto h-5 w-8 rounded-full" />
</TableCell>
<TableCell className="hidden lg:table-cell">
<Skeleton className="h-4 w-24 rounded" />
</TableCell>
<TableCell>
<Skeleton className="h-6 w-6 rounded" />
</TableCell>
</TableRow>
))
: people.map((person) => {
const lastSeen =
person.cards?.[0]?.createdAt ?? person.updatedAt;
return (
<TableRow key={person.id}>
<TableCell>
<button
className="font-medium text-foreground hover:text-primary hover:underline"
onClick={() =>
router.push(`/people/${person.id}`)
}
>
{person.firstName} {person.lastName}
</button>
</TableCell>
<TableCell className="hidden text-muted-foreground sm:table-cell">
{person.email || "—"}
</TableCell>
<TableCell className="hidden text-muted-foreground md:table-cell">
{person.cellPhone || "—"}
</TableCell>
<TableCell className="text-center">
<Badge variant="secondary">
{person._count.cards}
</Badge>
</TableCell>
<TableCell className="hidden text-muted-foreground lg:table-cell">
{formatDate(lastSeen)}
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
size="icon-xs"
/>
}
>
<MoreHorizontal className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() =>
router.push(`/people/${person.id}`)
}
>
<Eye className="mr-2 size-4" /> View
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
router.push(
`/people/${person.id}?action=merge`
)
}
>
<Merge className="mr-2 size-4" /> Merge
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
disabled={deletingId === person.id}
onClick={() => handleDelete(person.id)}
>
<Trash2 className="mr-2 size-4" /> Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
{totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Page {page} of {totalPages}
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
className="rounded-xl"
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
>
<ChevronLeft className="mr-1 size-4" /> Previous
</Button>
<Button
variant="outline"
size="sm"
className="rounded-xl"
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
Next <ChevronRight className="ml-1 size-4" />
</Button>
</div>
</div>
)}
</>
)}
</div>
);
}