Files
pn-new-crm/src/components/reminders/reminder-list.tsx

348 lines
11 KiB
TypeScript
Raw Normal View History

'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 { 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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { apiFetch } from '@/lib/api/client';
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;
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 params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
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>
}
/>
fix(ux): pass-3 — yacht/company headers, reminder filters wrap, client tab counts Five small fixes from the third audit pass on previously-unchecked surfaces: Yacht detail header (mobile): - Stack the action cluster (Edit / Transfer / Archive) below the title block on phone widths. Previously the three buttons crowded the right side enough to truncate the status pill to "A..." and force the owner name to wrap to two lines. Same fix that landed for berth / client / company headers. Company detail header (mobile): - Same mobile stacking fix; legal-name + Tax-ID metadata no longer wraps awkwardly. Company detail Incorporation Date (all viewports): - Strip the time portion of the ISO timestamp before passing to the inline editor. Previously rendered the raw "2019-03-14T00:00:00.000Z" Postgres-serialized form. Now reads "2019-03-14" and round-trips through the YYYY-MM-DD inline editor cleanly. Reminders list filter row: - Allow flex-wrap on the My/All tabs + status filter + priority filter cluster. At 390px, the priority filter dropdown was being pushed off the right edge of the screen. Client detail tab counts: - Add interestCount + noteCount to getClientById response, surface as badges on the Interests + Notes tabs. Brings them into parity with Yachts/Companies/Reservations/Addresses which already showed counts; Files + Activity are still stubs and don't get a count yet. Verification: 0 tsc errors, 926/926 vitest passing, lint clean. Out of scope (deferred): - Residential clients / interests pages still render plain HTML tables on phone widths (header columns clip at the right edge). Needs the DataView card-on-mobile treatment that the main /clients and /interests pages already have. Substantial separate work. - Phone contacts in the legacy seed have value set but valueE164 NULL, so InlinePhoneField shows "—" even though metadata is technically populated. Fix is a one-time backfill via libphonenumber-js, not a UI change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 17:09:27 +02:00
{/* 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={fetchReminders}
/>
<SnoozeDialog
open={!!snoozingId}
onOpenChange={(open) => {
if (!open) setSnoozingId(null);
}}
reminderId={snoozingId}
onSuccess={fetchReminders}
/>
</div>
);
}