329 lines
11 KiB
TypeScript
329 lines
11 KiB
TypeScript
|
|
'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>
|
||
|
|
);
|
||
|
|
}
|