Files
pn-new-crm/src/components/reminders/reminder-list.tsx
Matt 4233aa3ac3 fix(audit-wave-9): standardize on Sheet for previews; doctrine in CLAUDE.md
Swap the one outlier (client-interests-tab.tsx) from Vaul Drawer to
Sheet side=right so every detail-preview surface uses the same
primitive. Document the doctrine: Sheet for side panels on both desktop
and mobile; Vaul Drawer reserved for mobile-only bottom-sheet UX
(currently just MoreSheet).

Closes ui/ux M11.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 11:50:07 +02:00

408 lines
14 KiB
TypeScript

'use client';
import { useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { type ColumnDef } from '@tanstack/react-table';
import { Plus, CheckCircle2, Clock, Pencil, XCircle, AlertTriangle, Bell } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
import { useParams } from 'next/navigation';
import { DataTable } from '@/components/shared/data-table';
import { PageHeader } from '@/components/shared/page-header';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { apiFetch } from '@/lib/api/client';
import { useCreateFromUrl } from '@/hooks/use-create-from-url';
import { usePermissions } from '@/hooks/use-permissions';
import { ReminderCard } from './reminder-card';
import { ReminderForm } from './reminder-form';
import { SnoozeDialog } from './snooze-dialog';
interface Reminder {
id: string;
title: string;
note: string | null;
dueAt: string;
priority: 'low' | 'medium' | 'high' | 'urgent';
status: 'pending' | 'snoozed' | 'completed' | 'dismissed';
assignedTo: string | null;
createdBy: string;
clientId: string | null;
interestId: string | null;
berthId: string | null;
autoGenerated: boolean;
snoozedUntil: string | null;
completedAt: string | null;
createdAt: string;
client?: { id: string; fullName: string } | null;
interest?: { id: string; pipelineStage: string } | null;
berth?: { id: string; mooringNumber: string } | null;
}
const PRIORITY_CONFIG = {
urgent: { label: 'Urgent', className: 'bg-red-600 text-white' },
high: { label: 'High', className: 'bg-orange-500 text-white' },
medium: { label: 'Medium', className: 'bg-blue-500 text-white' },
low: { label: 'Low', className: 'bg-gray-400 text-white' },
} as const;
const STATUS_CONFIG = {
pending: { label: 'Pending', icon: Bell },
snoozed: { label: 'Snoozed', icon: Clock },
completed: { label: 'Completed', icon: CheckCircle2 },
dismissed: { label: 'Dismissed', icon: XCircle },
} as const;
interface ReminderListProps {
/**
* Embedded mode (used by the Inbox page) drops the PageHeader and
* surfaces the "New Reminder" button inline so the section can render
* alongside the Alerts section without duplicating page chrome.
*/
embedded?: boolean;
}
export function ReminderList({ embedded = false }: ReminderListProps = {}) {
const [formOpen, setFormOpen] = useState(false);
useCreateFromUrl(() => setFormOpen(true));
const [editingReminder, setEditingReminder] = useState<Reminder | null>(null);
const [snoozingId, setSnoozingId] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<'my' | 'all'>('my');
const [statusFilter, setStatusFilter] = useState<string>('active');
const [priorityFilter, setPriorityFilter] = useState<string>('all');
const { can } = usePermissions();
const canViewAll = can('reminders', 'view_all');
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const queryClient = useQueryClient();
// useQuery replaces the prior useEffect(fetch+setState) pattern.
// The query key captures every filter so a switch refetches; the
// mutation handlers below invalidate-by-prefix to refresh after
// complete/dismiss.
const remindersQuery = useQuery<{ reminders: Reminder[]; total: number }>({
queryKey: ['reminders', viewMode, statusFilter, priorityFilter],
queryFn: async () => {
if (viewMode === 'my') {
const res = await apiFetch<{ data: Reminder[] }>('/api/v1/reminders/my');
const filtered =
priorityFilter === 'all'
? res.data
: res.data.filter((r) => r.priority === priorityFilter);
return { reminders: filtered, total: filtered.length };
}
const sp = new URLSearchParams({ limit: '50', order: 'asc', sort: 'dueAt' });
if (statusFilter === 'active') sp.set('status', 'pending');
else if (statusFilter !== 'all') sp.set('status', statusFilter);
if (priorityFilter !== 'all') sp.set('priority', priorityFilter);
const res = await apiFetch<{
data: Reminder[];
pagination: { total: number };
}>(`/api/v1/reminders?${sp}`);
return { reminders: res.data, total: res.pagination.total };
},
});
const reminders = remindersQuery.data?.reminders ?? [];
const total = remindersQuery.data?.total ?? 0;
const loading = remindersQuery.isLoading;
async function handleComplete(id: string) {
await apiFetch(`/api/v1/reminders/${id}/complete`, { method: 'POST' });
void queryClient.invalidateQueries({ queryKey: ['reminders'] });
}
async function handleDismiss(id: string) {
await apiFetch(`/api/v1/reminders/${id}/dismiss`, { method: 'POST' });
void queryClient.invalidateQueries({ queryKey: ['reminders'] });
}
function isOverdue(dueAt: string, status: string): boolean {
return (status === 'pending' || status === 'snoozed') && new Date(dueAt) < new Date();
}
const columns: ColumnDef<Reminder, unknown>[] = [
{
accessorKey: 'priority',
header: '',
cell: ({ row }) => {
const config = PRIORITY_CONFIG[row.original.priority];
return <Badge className={`${config.className} text-[10px] px-1.5`}>{config.label}</Badge>;
},
size: 70,
},
{
accessorKey: 'title',
header: 'Reminder',
cell: ({ row }) => {
const overdue = isOverdue(row.original.dueAt, row.original.status);
return (
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-2">
<span className="font-medium">{row.original.title}</span>
{row.original.autoGenerated && (
<Badge variant="outline" className="text-[10px]">
Auto
</Badge>
)}
{overdue && <AlertTriangle className="h-3.5 w-3.5 text-destructive" />}
</div>
{row.original.client && (
<span className="text-xs text-muted-foreground">
Client: {row.original.client.fullName}
</span>
)}
{row.original.berth && (
<span className="text-xs text-muted-foreground">
Berth: {row.original.berth.mooringNumber}
</span>
)}
</div>
);
},
},
{
accessorKey: 'dueAt',
header: 'Due',
cell: ({ row }) => {
const overdue = isOverdue(row.original.dueAt, row.original.status);
const date = new Date(row.original.dueAt);
return (
<div className={overdue ? 'text-destructive font-medium' : ''}>
<div className="text-sm">{date.toLocaleDateString()}</div>
<div className="text-xs text-muted-foreground">
{formatDistanceToNow(date, { addSuffix: true })}
</div>
</div>
);
},
},
{
accessorKey: 'status',
header: 'Status',
cell: ({ row }) => {
const config = STATUS_CONFIG[row.original.status];
const Icon = config.icon;
return (
<div className="flex items-center gap-1.5 text-sm">
<Icon className="h-3.5 w-3.5" />
{config.label}
</div>
);
},
},
{
id: 'actions',
header: '',
cell: ({ row }) => {
if (row.original.status === 'completed' || row.original.status === 'dismissed') {
return null;
}
return (
<TooltipProvider delayDuration={150}>
<div className="flex items-center justify-end gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
aria-label="Mark complete"
className="text-green-600 hover:text-green-700"
onClick={() => handleComplete(row.original.id)}
>
<CheckCircle2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Mark complete</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
aria-label="Snooze"
onClick={() => setSnoozingId(row.original.id)}
>
<Clock className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Snooze</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
aria-label="Edit reminder"
className="text-muted-foreground hover:text-foreground"
onClick={() => {
setEditingReminder(row.original);
setFormOpen(true);
}}
>
<Pencil className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Edit</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
aria-label="Dismiss"
className="text-muted-foreground hover:text-foreground"
onClick={() => handleDismiss(row.original.id)}
>
<XCircle className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Dismiss</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
);
},
enableSorting: false,
size: 160,
},
];
return (
<div>
{!embedded ? (
<PageHeader
title="Reminders"
description={`${total} reminder${total !== 1 ? 's' : ''}`}
actions={
<Button
onClick={() => {
setEditingReminder(null);
setFormOpen(true);
}}
>
<Plus className="mr-1.5 h-4 w-4" />
New Reminder
</Button>
}
/>
) : (
<div className="mb-3 flex justify-end">
<Button
size="sm"
onClick={() => {
setEditingReminder(null);
setFormOpen(true);
}}
>
<Plus className="mr-1.5 h-4 w-4" />
New Reminder
</Button>
</div>
)}
{/* Wrap on phone widths so the priority filter doesn't get pushed
off-screen by the My/All tabs + status filter taking the full row. */}
<div className="flex flex-wrap items-center gap-3 mb-4 sm:gap-4">
{canViewAll && (
<Tabs value={viewMode} onValueChange={(v) => setViewMode(v as 'my' | 'all')}>
<TabsList>
<TabsTrigger value="my">My Reminders</TabsTrigger>
<TabsTrigger value="all">All Reminders</TabsTrigger>
</TabsList>
</Tabs>
)}
{viewMode === 'all' && (
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="pending">Pending</SelectItem>
<SelectItem value="snoozed">Snoozed</SelectItem>
<SelectItem value="completed">Completed</SelectItem>
<SelectItem value="dismissed">Dismissed</SelectItem>
<SelectItem value="all">All</SelectItem>
</SelectContent>
</Select>
)}
<Select value={priorityFilter} onValueChange={setPriorityFilter}>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Priorities</SelectItem>
<SelectItem value="urgent">Urgent</SelectItem>
<SelectItem value="high">High</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="low">Low</SelectItem>
</SelectContent>
</Select>
</div>
<DataTable
columns={columns}
data={reminders}
isLoading={loading}
getRowId={(row) => row.id}
cardRender={(row) => (
<ReminderCard
reminder={row.original}
portSlug={portSlug}
onComplete={handleComplete}
onSnooze={setSnoozingId}
onDismiss={handleDismiss}
onEdit={(r) => {
setEditingReminder(r);
setFormOpen(true);
}}
/>
)}
emptyState={
<div className="text-center py-8">
<Bell className="mx-auto h-8 w-8 text-muted-foreground mb-2" />
<p className="text-muted-foreground">No reminders.</p>
<Button
variant="link"
onClick={() => {
setEditingReminder(null);
setFormOpen(true);
}}
className="mt-2"
>
Create your first reminder
</Button>
</div>
}
/>
<ReminderForm
open={formOpen}
onOpenChange={setFormOpen}
reminder={editingReminder}
onSuccess={() => queryClient.invalidateQueries({ queryKey: ['reminders'] })}
/>
<SnoozeDialog
open={!!snoozingId}
onOpenChange={(open) => {
if (!open) setSnoozingId(null);
}}
reminderId={snoozingId}
onSuccess={() => queryClient.invalidateQueries({ queryKey: ['reminders'] })}
/>
</div>
);
}