fix(audit-wave-9): standardize on Sheet for previews; doctrine in CLAUDE.md

Swap the one outlier (client-interests-tab.tsx) from Vaul Drawer to
Sheet side=right so every detail-preview surface uses the same
primitive. Document the doctrine: Sheet for side panels on both desktop
and mobile; Vaul Drawer reserved for mobile-only bottom-sheet UX
(currently just MoreSheet).

Closes ui/ux M11.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-13 11:50:07 +02:00
parent b2588ecdd8
commit 4233aa3ac3
94 changed files with 1674 additions and 895 deletions

View File

@@ -1,6 +1,7 @@
'use client';
import { useState, useEffect } from 'react';
import { useState, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -45,7 +46,20 @@ interface ReminderFormProps {
onSuccess: () => void;
}
export function ReminderForm({
export function ReminderForm(props: ReminderFormProps) {
// Key-based remount: the body is keyed on `open + reminder.id` so its
// useState initializers re-run on each open with the correct seed.
// Replaces the prior useEffect-driven open→reset that the Compiler
// flagged as set-state-in-effect.
return (
<ReminderFormBody
key={props.open ? `open:${props.reminder?.id ?? 'new'}` : 'closed'}
{...props}
/>
);
}
function ReminderFormBody({
open,
onOpenChange,
reminder,
@@ -54,58 +68,33 @@ export function ReminderForm({
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 isEdit = !!reminder;
// Tomorrow 9am default for new-reminder dueAt.
const defaultDueAt = useMemo(() => {
const t = new Date();
t.setDate(t.getDate() + 1);
t.setHours(9, 0, 0, 0);
return t.toISOString().slice(0, 16);
}, []);
const [title, setTitle] = useState(reminder?.title ?? '');
const [note, setNote] = useState(reminder?.note ?? '');
const [dueAt, setDueAt] = useState(reminder ? reminder.dueAt.slice(0, 16) : defaultDueAt);
const [priority, setPriority] = useState(reminder?.priority ?? 'medium');
const [assignedTo, setAssignedTo] = useState(reminder?.assignedTo ?? '');
const [clientId, setClientId] = useState(reminder?.clientId ?? defaultClientId ?? '');
const [interestId, setInterestId] = useState(reminder?.interestId ?? defaultInterestId ?? '');
const [berthId, setBerthId] = useState(reminder?.berthId ?? defaultBerthId ?? '');
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]);
// useQuery replaces the prior useEffect(fetch+setState) pattern.
const usersQuery = useQuery<{ data: UserOption[] }>({
queryKey: ['admin', 'users', 'options'],
queryFn: () => apiFetch('/api/v1/admin/users/options'),
enabled: open && canAssignOthers,
});
const users = usersQuery.data?.data ?? [];
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();

View File

@@ -1,6 +1,7 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { type ColumnDef } from '@tanstack/react-table';
import { Plus, CheckCircle2, Clock, Pencil, XCircle, AlertTriangle, Bell } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
@@ -71,8 +72,6 @@ interface ReminderListProps {
}
export function ReminderList({ embedded = false }: ReminderListProps = {}) {
const [reminders, setReminders] = useState<Reminder[]>([]);
const [loading, setLoading] = useState(true);
const [formOpen, setFormOpen] = useState(false);
useCreateFromUrl(() => setFormOpen(true));
const [editingReminder, setEditingReminder] = useState<Reminder | null>(null);
@@ -80,57 +79,50 @@ export function ReminderList({ embedded = false }: ReminderListProps = {}) {
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 queryClient = useQueryClient();
const fetchReminders = useCallback(async () => {
setLoading(true);
try {
// useQuery replaces the prior useEffect(fetch+setState) pattern.
// The query key captures every filter so a switch refetches; the
// mutation handlers below invalidate-by-prefix to refresh after
// complete/dismiss.
const remindersQuery = useQuery<{ reminders: Reminder[]; total: number }>({
queryKey: ['reminders', viewMode, statusFilter, priorityFilter],
queryFn: async () => {
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);
const filtered =
priorityFilter === 'all'
? res.data
: res.data.filter((r) => r.priority === priorityFilter);
return { reminders: filtered, total: filtered.length };
}
} finally {
setLoading(false);
}
}, [viewMode, statusFilter, priorityFilter]);
useEffect(() => {
void fetchReminders();
}, [fetchReminders]);
const sp = new URLSearchParams({ limit: '50', order: 'asc', sort: 'dueAt' });
if (statusFilter === 'active') sp.set('status', 'pending');
else if (statusFilter !== 'all') sp.set('status', statusFilter);
if (priorityFilter !== 'all') sp.set('priority', priorityFilter);
const res = await apiFetch<{
data: Reminder[];
pagination: { total: number };
}>(`/api/v1/reminders?${sp}`);
return { reminders: res.data, total: res.pagination.total };
},
});
const reminders = remindersQuery.data?.reminders ?? [];
const total = remindersQuery.data?.total ?? 0;
const loading = remindersQuery.isLoading;
async function handleComplete(id: string) {
await apiFetch(`/api/v1/reminders/${id}/complete`, { method: 'POST' });
await fetchReminders();
void queryClient.invalidateQueries({ queryKey: ['reminders'] });
}
async function handleDismiss(id: string) {
await apiFetch(`/api/v1/reminders/${id}/dismiss`, { method: 'POST' });
await fetchReminders();
void queryClient.invalidateQueries({ queryKey: ['reminders'] });
}
function isOverdue(dueAt: string, status: string): boolean {
@@ -399,7 +391,7 @@ export function ReminderList({ embedded = false }: ReminderListProps = {}) {
open={formOpen}
onOpenChange={setFormOpen}
reminder={editingReminder}
onSuccess={fetchReminders}
onSuccess={() => queryClient.invalidateQueries({ queryKey: ['reminders'] })}
/>
<SnoozeDialog
@@ -408,7 +400,7 @@ export function ReminderList({ embedded = false }: ReminderListProps = {}) {
if (!open) setSnoozingId(null);
}}
reminderId={snoozingId}
onSuccess={fetchReminders}
onSuccess={() => queryClient.invalidateQueries({ queryKey: ['reminders'] })}
/>
</div>
);