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>
2026-04-08 16:27:34 -04:00
|
|
|
'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">
|
2026-05-04 22:57:01 +02:00
|
|
|
Link to Entity (optional - paste UUIDs, or leave blank)
|
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>
2026-04-08 16:27:34 -04:00
|
|
|
</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>
|
|
|
|
|
);
|
|
|
|
|
}
|