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:
267
src/components/reminders/reminder-form.tsx
Normal file
267
src/components/reminders/reminder-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
328
src/components/reminders/reminder-list.tsx
Normal file
328
src/components/reminders/reminder-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
119
src/components/reminders/snooze-dialog.tsx
Normal file
119
src/components/reminders/snooze-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user