Implement reminders system with full CRUD and background processors

- Reminders service: create, update, delete, complete, snooze, dismiss
- List with filters (status, priority, assignee, entity, date range)
- My/overdue/upcoming convenience endpoints
- BullMQ processors: auto-follow-up creation (BR-060) and overdue notifications
- Snooze with presets (1h, 4h, tomorrow, next week) and custom datetime
- Un-snooze logic: snoozed reminders auto-revert to pending when snooze expires
- UI: filterable list with my/all toggle, priority badges, overdue indicators
- Permission-gated: view_own, view_all, create, assign_others
- Entity linking: reminders can link to clients, interests, or berths

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-08 16:27:34 -04:00
parent c8320023cc
commit 4fdd9e3207
15 changed files with 1458 additions and 20 deletions

View File

@@ -0,0 +1,267 @@
'use client';
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet';
import { apiFetch } from '@/lib/api/client';
import { usePermissions } from '@/hooks/use-permissions';
interface UserOption {
id: string;
displayName: string;
}
interface ReminderFormProps {
open: boolean;
onOpenChange: (open: boolean) => void;
reminder?: {
id: string;
title: string;
note: string | null;
dueAt: string;
priority: string;
assignedTo: string | null;
clientId: string | null;
interestId: string | null;
berthId: string | null;
} | null;
// Pre-fill entity link when creating from entity detail pages
defaultClientId?: string;
defaultInterestId?: string;
defaultBerthId?: string;
onSuccess: () => void;
}
export function ReminderForm({
open,
onOpenChange,
reminder,
defaultClientId,
defaultInterestId,
defaultBerthId,
onSuccess,
}: ReminderFormProps) {
const [title, setTitle] = useState('');
const [note, setNote] = useState('');
const [dueAt, setDueAt] = useState('');
const [priority, setPriority] = useState('medium');
const [assignedTo, setAssignedTo] = useState('');
const [clientId, setClientId] = useState('');
const [interestId, setInterestId] = useState('');
const [berthId, setBerthId] = useState('');
const [users, setUsers] = useState<UserOption[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const { can } = usePermissions();
const canAssignOthers = can('reminders', 'assign_others');
const isEdit = !!reminder;
useEffect(() => {
if (open && canAssignOthers) {
void apiFetch<{ data: UserOption[] }>('/api/v1/admin/users/options').then((res) =>
setUsers(res.data),
);
}
}, [open, canAssignOthers]);
useEffect(() => {
if (open) {
if (reminder) {
setTitle(reminder.title);
setNote(reminder.note ?? '');
setDueAt(reminder.dueAt.slice(0, 16)); // datetime-local format
setPriority(reminder.priority);
setAssignedTo(reminder.assignedTo ?? '');
setClientId(reminder.clientId ?? '');
setInterestId(reminder.interestId ?? '');
setBerthId(reminder.berthId ?? '');
} else {
setTitle('');
setNote('');
// Default to tomorrow 9 AM
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(9, 0, 0, 0);
setDueAt(tomorrow.toISOString().slice(0, 16));
setPriority('medium');
setAssignedTo('');
setClientId(defaultClientId ?? '');
setInterestId(defaultInterestId ?? '');
setBerthId(defaultBerthId ?? '');
}
setError(null);
}
}, [open, reminder, defaultClientId, defaultInterestId, defaultBerthId]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
setLoading(true);
try {
const body = {
title,
note: note || undefined,
dueAt: new Date(dueAt).toISOString(),
priority,
assignedTo: assignedTo || undefined,
clientId: clientId || undefined,
interestId: interestId || undefined,
berthId: berthId || undefined,
};
if (isEdit) {
await apiFetch(`/api/v1/reminders/${reminder.id}`, {
method: 'PATCH',
body,
});
} else {
await apiFetch('/api/v1/reminders', {
method: 'POST',
body,
});
}
onSuccess();
onOpenChange(false);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Something went wrong';
setError(message);
} finally {
setLoading(false);
}
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="overflow-y-auto">
<SheetHeader>
<SheetTitle>{isEdit ? 'Edit Reminder' : 'New Reminder'}</SheetTitle>
</SheetHeader>
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
<div className="space-y-2">
<Label htmlFor="reminder-title">Title</Label>
<Input
id="reminder-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Follow up with client..."
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="reminder-note">Note</Label>
<Textarea
id="reminder-note"
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="Additional details..."
rows={3}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="reminder-due">Due Date & Time</Label>
<Input
id="reminder-due"
type="datetime-local"
value={dueAt}
onChange={(e) => setDueAt(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="reminder-priority">Priority</Label>
<Select value={priority} onValueChange={setPriority}>
<SelectTrigger id="reminder-priority">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="low">Low</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="high">High</SelectItem>
<SelectItem value="urgent">Urgent</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{canAssignOthers && (
<div className="space-y-2">
<Label htmlFor="reminder-assign">Assign To</Label>
<Select value={assignedTo} onValueChange={setAssignedTo}>
<SelectTrigger id="reminder-assign">
<SelectValue placeholder="Myself" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">Myself</SelectItem>
{users.map((u) => (
<SelectItem key={u.id} value={u.id}>
{u.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="space-y-2">
<Label className="text-muted-foreground text-xs">
Link to Entity (optional paste UUIDs, or leave blank)
</Label>
<div className="grid grid-cols-1 gap-2">
<Input
placeholder="Client ID"
value={clientId}
onChange={(e) => setClientId(e.target.value)}
className="text-xs"
/>
<Input
placeholder="Interest ID"
value={interestId}
onChange={(e) => setInterestId(e.target.value)}
className="text-xs"
/>
<Input
placeholder="Berth ID"
value={berthId}
onChange={(e) => setBerthId(e.target.value)}
className="text-xs"
/>
</div>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<SheetFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={loading}
>
Cancel
</Button>
<Button type="submit" disabled={loading || !title.trim() || !dueAt}>
{loading ? 'Saving...' : isEdit ? 'Save Changes' : 'Create Reminder'}
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,328 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { type ColumnDef } from '@tanstack/react-table';
import { Plus, CheckCircle2, Clock, XCircle, AlertTriangle, Bell } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { apiFetch } from '@/lib/api/client';
import { usePermissions } from '@/hooks/use-permissions';
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;
export function ReminderList() {
const [reminders, setReminders] = useState<Reminder[]>([]);
const [loading, setLoading] = useState(true);
const [formOpen, setFormOpen] = useState(false);
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 [total, setTotal] = useState(0);
const { can } = usePermissions();
const canViewAll = can('reminders', 'view_all');
const fetchReminders = useCallback(async () => {
setLoading(true);
try {
if (viewMode === 'my') {
const res = await apiFetch<{ data: Reminder[] }>('/api/v1/reminders/my');
let filtered = res.data;
if (priorityFilter !== 'all') {
filtered = filtered.filter((r) => r.priority === priorityFilter);
}
setReminders(filtered);
setTotal(filtered.length);
} else {
const params = new URLSearchParams({ limit: '50', order: 'asc', sort: 'dueAt' });
if (statusFilter === 'active') {
params.set('status', 'pending');
} else if (statusFilter !== 'all') {
params.set('status', statusFilter);
}
if (priorityFilter !== 'all') {
params.set('priority', priorityFilter);
}
const res = await apiFetch<{
data: Reminder[];
pagination: { total: number };
}>(`/api/v1/reminders?${params}`);
setReminders(res.data);
setTotal(res.pagination.total);
}
} finally {
setLoading(false);
}
}, [viewMode, statusFilter, priorityFilter]);
useEffect(() => {
void fetchReminders();
}, [fetchReminders]);
async function handleComplete(id: string) {
await apiFetch(`/api/v1/reminders/${id}/complete`, { method: 'POST' });
await fetchReminders();
}
async function handleDismiss(id: string) {
await apiFetch(`/api/v1/reminders/${id}/dismiss`, { method: 'POST' });
await fetchReminders();
}
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 (
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="sm"
className="text-green-600 hover:text-green-700"
onClick={() => handleComplete(row.original.id)}
>
<CheckCircle2 className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => setSnoozingId(row.original.id)}>
<Clock className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground"
onClick={() => handleDismiss(row.original.id)}
>
<XCircle className="h-4 w-4" />
</Button>
</div>
);
},
enableSorting: false,
size: 120,
},
];
return (
<div>
<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="flex items-center gap-4 mb-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}
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={fetchReminders}
/>
<SnoozeDialog
open={!!snoozingId}
onOpenChange={(open) => {
if (!open) setSnoozingId(null);
}}
reminderId={snoozingId}
onSuccess={fetchReminders}
/>
</div>
);
}

View File

@@ -0,0 +1,119 @@
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { apiFetch } from '@/lib/api/client';
interface SnoozeDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
reminderId: string | null;
onSuccess: () => void;
}
const PRESETS = [
{ label: '1 hour', hours: 1 },
{ label: '4 hours', hours: 4 },
{ label: 'Tomorrow 9 AM', hours: -1 }, // special case
{ label: 'Next week', hours: -2 }, // special case
] as const;
function getPresetDate(preset: (typeof PRESETS)[number]): Date {
const now = new Date();
if (preset.hours === -1) {
// Tomorrow 9 AM
const tomorrow = new Date(now);
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(9, 0, 0, 0);
return tomorrow;
}
if (preset.hours === -2) {
// Next Monday 9 AM
const next = new Date(now);
const daysUntilMonday = (8 - next.getDay()) % 7 || 7;
next.setDate(next.getDate() + daysUntilMonday);
next.setHours(9, 0, 0, 0);
return next;
}
return new Date(now.getTime() + preset.hours * 60 * 60 * 1000);
}
export function SnoozeDialog({ open, onOpenChange, reminderId, onSuccess }: SnoozeDialogProps) {
const [customDate, setCustomDate] = useState('');
const [loading, setLoading] = useState(false);
async function handleSnooze(snoozeUntil: string) {
if (!reminderId) return;
setLoading(true);
try {
await apiFetch(`/api/v1/reminders/${reminderId}/snooze`, {
method: 'POST',
body: { snoozeUntil },
});
onSuccess();
onOpenChange(false);
} finally {
setLoading(false);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Snooze Reminder</DialogTitle>
</DialogHeader>
<div className="space-y-3 py-2">
<div className="grid grid-cols-2 gap-2">
{PRESETS.map((preset) => (
<Button
key={preset.label}
variant="outline"
className="justify-start"
disabled={loading}
onClick={() => handleSnooze(getPresetDate(preset).toISOString())}
>
{preset.label}
</Button>
))}
</div>
<div className="space-y-2 pt-2">
<Label htmlFor="custom-snooze">Custom date & time</Label>
<div className="flex gap-2">
<Input
id="custom-snooze"
type="datetime-local"
value={customDate}
onChange={(e) => setCustomDate(e.target.value)}
className="flex-1"
/>
<Button
disabled={loading || !customDate}
onClick={() => handleSnooze(new Date(customDate).toISOString())}
>
Snooze
</Button>
</div>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={loading}>
Cancel
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}