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-17 00:29:26 -04:00
|
|
|
"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";
|
|
|
|
|
|
Add SaaS foundation: Auth.js, dashboard shell, org model, auto-assignment
Major architectural upgrade preparing Echo OCR for self-hosted SaaS deployment:
- Auth: Built-in Auth.js v5 with credentials + Authentik OIDC SSO, JWT sessions,
middleware route protection, login/signup/setup pages, registration API
- UI: Dashboard layout with collapsible sidebar nav, AppShell wrapper, route
groups for (dashboard) and (auth), new pages for events/people/reports
- Schema: Auth.js tables (Account, Session, VerificationToken), Organization,
OrgMember, Location, CollectionDay, Invitation, SystemConfig, ApiKey models;
proper User relations to ResponseCard/ActivityLog/Notification
- Permissions: Role hierarchy (owner/admin/editor/reviewer/viewer) with
action-based permission map and requirePermission/requireAuth helpers
- Onboarding: Multi-step setup wizard for first-user bootstrap (account, org,
location) with SystemConfig tracking
- Events: CollectionDay model with rrule support for recurring church services
- Auto-assign: Event-aware card assignment engine replacing getPreviousSunday()
- Migration: seed-migration.ts script for upgrading existing deployments
Made-with: Cursor
2026-04-15 00:59:38 -04:00
|
|
|
import { Header } from "@/components/layout/header";
|
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-17 00:29:26 -04:00
|
|
|
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");
|
|
|
|
|
}
|
Add SaaS foundation: Auth.js, dashboard shell, org model, auto-assignment
Major architectural upgrade preparing Echo OCR for self-hosted SaaS deployment:
- Auth: Built-in Auth.js v5 with credentials + Authentik OIDC SSO, JWT sessions,
middleware route protection, login/signup/setup pages, registration API
- UI: Dashboard layout with collapsible sidebar nav, AppShell wrapper, route
groups for (dashboard) and (auth), new pages for events/people/reports
- Schema: Auth.js tables (Account, Session, VerificationToken), Organization,
OrgMember, Location, CollectionDay, Invitation, SystemConfig, ApiKey models;
proper User relations to ResponseCard/ActivityLog/Notification
- Permissions: Role hierarchy (owner/admin/editor/reviewer/viewer) with
action-based permission map and requirePermission/requireAuth helpers
- Onboarding: Multi-step setup wizard for first-user bootstrap (account, org,
location) with SystemConfig tracking
- Events: CollectionDay model with rrule support for recurring church services
- Auto-assign: Event-aware card assignment engine replacing getPreviousSunday()
- Migration: seed-migration.ts script for upgrading existing deployments
Made-with: Cursor
2026-04-15 00:59:38 -04:00
|
|
|
|
|
|
|
|
export default function PeoplePage() {
|
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-17 00:29:26 -04:00
|
|
|
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;
|
|
|
|
|
|
Add SaaS foundation: Auth.js, dashboard shell, org model, auto-assignment
Major architectural upgrade preparing Echo OCR for self-hosted SaaS deployment:
- Auth: Built-in Auth.js v5 with credentials + Authentik OIDC SSO, JWT sessions,
middleware route protection, login/signup/setup pages, registration API
- UI: Dashboard layout with collapsible sidebar nav, AppShell wrapper, route
groups for (dashboard) and (auth), new pages for events/people/reports
- Schema: Auth.js tables (Account, Session, VerificationToken), Organization,
OrgMember, Location, CollectionDay, Invitation, SystemConfig, ApiKey models;
proper User relations to ResponseCard/ActivityLog/Notification
- Permissions: Role hierarchy (owner/admin/editor/reviewer/viewer) with
action-based permission map and requirePermission/requireAuth helpers
- Onboarding: Multi-step setup wizard for first-user bootstrap (account, org,
location) with SystemConfig tracking
- Events: CollectionDay model with rrule support for recurring church services
- Auto-assign: Event-aware card assignment engine replacing getPreviousSunday()
- Migration: seed-migration.ts script for upgrading existing deployments
Made-with: Cursor
2026-04-15 00:59:38 -04:00
|
|
|
return (
|
|
|
|
|
<div className="space-y-6">
|
|
|
|
|
<Header
|
|
|
|
|
title="People"
|
|
|
|
|
description="Contact directory from scanned response cards"
|
|
|
|
|
icon={Users}
|
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-17 00:29:26 -04:00
|
|
|
>
|
|
|
|
|
<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 “{debouncedSearch}”
|
|
|
|
|
</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>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
Add SaaS foundation: Auth.js, dashboard shell, org model, auto-assignment
Major architectural upgrade preparing Echo OCR for self-hosted SaaS deployment:
- Auth: Built-in Auth.js v5 with credentials + Authentik OIDC SSO, JWT sessions,
middleware route protection, login/signup/setup pages, registration API
- UI: Dashboard layout with collapsible sidebar nav, AppShell wrapper, route
groups for (dashboard) and (auth), new pages for events/people/reports
- Schema: Auth.js tables (Account, Session, VerificationToken), Organization,
OrgMember, Location, CollectionDay, Invitation, SystemConfig, ApiKey models;
proper User relations to ResponseCard/ActivityLog/Notification
- Permissions: Role hierarchy (owner/admin/editor/reviewer/viewer) with
action-based permission map and requirePermission/requireAuth helpers
- Onboarding: Multi-step setup wizard for first-user bootstrap (account, org,
location) with SystemConfig tracking
- Events: CollectionDay model with rrule support for recurring church services
- Auto-assign: Event-aware card assignment engine replacing getPreviousSunday()
- Migration: seed-migration.ts script for upgrading existing deployments
Made-with: Cursor
2026-04-15 00:59:38 -04:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|