Cleared 4 rule buckets (37 violations, including 5 real bugs) and
silenced 1 informational bucket from the Next 16 / react-hooks v7
upgrade. Cleared rules promoted from `warn` back to `error` so new
regressions block CI.
Real bug fixes:
- `interest-contact-log-tab.tsx`: `useMemo` used for side effects
(5 setState calls inside a memo body); converted to `useEffect`.
- `PieChart.tsx`: cumulative `let angle` mutation in a render-phase
`map`; converted to `reduce` so the slice array is built without
re-assignment.
- `documents-hub.tsx`: `useMemo(() => ({ count: 0 }))` used as a
mutable drag counter; converted to `useRef`.
- `notes-list.tsx`: `Date.now()` read during render for note-edit
countdown (impure) → pinned to a `now` state ticked every 30s.
- `onboarding-checklist.tsx` / `user-profile.tsx` /
`user-settings.tsx`: `useEffect(() => void load(), [])` with the
`load` function declared AFTER the effect — relied on hoisting,
trips Compiler's "access before declared" rule. Declared inside
the effect.
Pattern fixes (intentional cache-via-ref → state or layout-effect):
- 6 `ref.current = x` writes during render moved into layout
effects (`use-realtime-invalidation`, `settings-form-card`,
`inbox`).
- 3 `ref.current` reads during render (search totals cache,
scanner file ref) rewritten to backed-by-state.
- `use-is-mobile.ts` rewritten on `useSyncExternalStore` to avoid
the SSR-then-rehydrate setState dance.
- `use-notifications.ts` rewritten to write socket pushes directly
into the React Query cache via `setQueryData`, removing a local
state mirror.
Rule config (`eslint.config.mjs`):
- `react-hooks/purity` → error (was warn, cleared)
- `react-hooks/set-state-in-render` → error (was warn, cleared)
- `react-hooks/immutability` → error (was warn, cleared)
- `react-hooks/refs` → error (was warn, cleared)
- `react-hooks/incompatible-library` → off (informational only)
- `react-hooks/set-state-in-effect` → warn (51 remaining, all the
useEffect→fetch→setState data-fetch pattern; migration to
useQuery tracked in BACKLOG)
Verified: tsc clean, eslint 0 errors / 69 warnings (down from 105),
vitest 1315/1315, next build green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
344 lines
13 KiB
TypeScript
344 lines
13 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useState } from 'react';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { formatDistanceToNow } from 'date-fns';
|
|
import { Lock, Pencil, Trash2, Send, Loader2 } from 'lucide-react';
|
|
import { useAutoAnimate } from '@formkit/auto-animate/react';
|
|
|
|
import { Button } from '@/components/ui/button';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
|
import { apiFetch } from '@/lib/api/client';
|
|
|
|
type NoteSource =
|
|
| 'client'
|
|
| 'interest'
|
|
| 'yacht'
|
|
| 'company'
|
|
| 'residential_client'
|
|
| 'residential_interest';
|
|
|
|
interface Note {
|
|
id: string;
|
|
content: string;
|
|
authorId: string;
|
|
authorName?: string;
|
|
isLocked: boolean;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
/** Aggregated-mode only: which child entity this note came from. */
|
|
source?: NoteSource;
|
|
sourceId?: string;
|
|
sourceLabel?: string;
|
|
}
|
|
|
|
type NotesEntityType =
|
|
| 'clients'
|
|
| 'interests'
|
|
| 'yachts'
|
|
| 'companies'
|
|
| 'residential_clients'
|
|
| 'residential_interests';
|
|
|
|
/** Maps the entity-type the list is rendered for to the `source` value
|
|
* the aggregator uses when a note came from THAT entity itself
|
|
* (vs. a related entity). Used to decide whether a note is editable
|
|
* in-place or read-only with an "Open source" affordance. */
|
|
const SELF_SOURCE: Record<NotesEntityType, NoteSource | null> = {
|
|
clients: 'client',
|
|
yachts: 'yacht',
|
|
companies: 'company',
|
|
residential_clients: 'residential_client',
|
|
// Aggregate-mode is only meaningful for the entities above. Interests
|
|
// and residential_interests are leaf nodes — there's nothing to roll
|
|
// up to them.
|
|
interests: null,
|
|
residential_interests: null,
|
|
};
|
|
|
|
const AGGREGATABLE: ReadonlySet<NotesEntityType> = new Set<NotesEntityType>([
|
|
'clients',
|
|
'yachts',
|
|
'companies',
|
|
'residential_clients',
|
|
]);
|
|
|
|
const SOURCE_BADGE_CLASS: Record<NoteSource, string> = {
|
|
client: 'bg-violet-100 text-violet-900',
|
|
interest: 'bg-blue-100 text-blue-900',
|
|
yacht: 'bg-emerald-100 text-emerald-900',
|
|
company: 'bg-amber-100 text-amber-900',
|
|
residential_client: 'bg-violet-100 text-violet-900',
|
|
residential_interest: 'bg-blue-100 text-blue-900',
|
|
};
|
|
|
|
const SOURCE_LABEL: Record<NoteSource, string> = {
|
|
client: 'Client',
|
|
interest: 'Interest',
|
|
yacht: 'Yacht',
|
|
company: 'Company',
|
|
residential_client: 'Resident',
|
|
residential_interest: 'Inquiry',
|
|
};
|
|
|
|
interface NotesListProps {
|
|
entityType: NotesEntityType;
|
|
entityId: string;
|
|
currentUserId?: string;
|
|
/**
|
|
* Aggregate-on-read: union the entity's own notes with notes from
|
|
* related entities (interests, owned yachts / company yachts, owner
|
|
* client). Cross-source notes render with a source chip and are
|
|
* read-only here — open the source entity's page to edit.
|
|
*
|
|
* Supported for entityType in {clients, yachts, companies,
|
|
* residential_clients}. Ignored for interests / residential_interests.
|
|
*/
|
|
aggregate?: boolean;
|
|
}
|
|
|
|
const NOTE_EDIT_WINDOW_MS = 15 * 60 * 1000; // 15 minutes
|
|
|
|
/** Sort by source then chronologically inside each source.
|
|
* Used by the aggregated view's "Group by source" toggle. */
|
|
function sortByGroup(notes: Note[]): Note[] {
|
|
const sourceOrder: Record<string, number> = {
|
|
client: 0,
|
|
company: 1,
|
|
yacht: 2,
|
|
interest: 3,
|
|
residential_client: 0,
|
|
residential_interest: 1,
|
|
};
|
|
return [...notes].sort((a, b) => {
|
|
const aRank = sourceOrder[a.source ?? ''] ?? 99;
|
|
const bRank = sourceOrder[b.source ?? ''] ?? 99;
|
|
if (aRank !== bRank) return aRank - bRank;
|
|
const aLabel = a.sourceLabel ?? '';
|
|
const bLabel = b.sourceLabel ?? '';
|
|
if (aLabel !== bLabel) return aLabel.localeCompare(bLabel);
|
|
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
|
});
|
|
}
|
|
|
|
export function NotesList({ entityType, entityId, currentUserId, aggregate }: NotesListProps) {
|
|
const queryClient = useQueryClient();
|
|
const [newNote, setNewNote] = useState('');
|
|
const [editingId, setEditingId] = useState<string | null>(null);
|
|
const [editContent, setEditContent] = useState('');
|
|
const [groupBySource, setGroupBySource] = useState(false);
|
|
// Wall-clock 'now' ticked every 30s so the per-note "Xm left to edit"
|
|
// countdown decrements on screen. Reading `Date.now()` directly inside
|
|
// render is impure (different value every call); pinning to a state
|
|
// value means React Compiler can memoize cleanly.
|
|
const [now, setNow] = useState(() => Date.now());
|
|
useEffect(() => {
|
|
const id = setInterval(() => setNow(Date.now()), 30_000);
|
|
return () => clearInterval(id);
|
|
}, []);
|
|
|
|
const aggregateOn = !!aggregate && AGGREGATABLE.has(entityType);
|
|
const baseEndpoint = `/api/v1/${entityType}/${entityId}/notes`;
|
|
const listEndpoint = aggregateOn ? `${baseEndpoint}?aggregate=true` : baseEndpoint;
|
|
const queryKey = [entityType, entityId, 'notes', aggregateOn ? 'aggregated' : 'own'];
|
|
|
|
// Smooth animation when notes are added / edited / deleted — replaces
|
|
// the abrupt re-render with a per-row fade/slide.
|
|
const [animateRef] = useAutoAnimate<HTMLDivElement>();
|
|
|
|
const { data: notes = [], isLoading } = useQuery<Note[]>({
|
|
queryKey,
|
|
queryFn: () => apiFetch<{ data: Note[] }>(listEndpoint).then((r) => r.data),
|
|
});
|
|
|
|
// Mutations always target the parent entity (client). Aggregated
|
|
// notes from interests/yachts are read-only here — the rep edits
|
|
// them on the source entity's page (we surface a "Open source" link
|
|
// below). Keeping mutations against `baseEndpoint` keeps the POST
|
|
// route handler clean.
|
|
const createMutation = useMutation({
|
|
mutationFn: (content: string) => apiFetch(baseEndpoint, { method: 'POST', body: { content } }),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey });
|
|
setNewNote('');
|
|
},
|
|
});
|
|
|
|
const updateMutation = useMutation({
|
|
mutationFn: ({ noteId, content }: { noteId: string; content: string }) =>
|
|
apiFetch(`${baseEndpoint}/${noteId}`, { method: 'PATCH', body: { content } }),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey });
|
|
setEditingId(null);
|
|
},
|
|
});
|
|
|
|
const deleteMutation = useMutation({
|
|
mutationFn: (noteId: string) => apiFetch(`${baseEndpoint}/${noteId}`, { method: 'DELETE' }),
|
|
onSuccess: () => queryClient.invalidateQueries({ queryKey }),
|
|
});
|
|
|
|
function canEdit(note: Note): boolean {
|
|
if (note.authorId !== currentUserId) return false;
|
|
if (note.isLocked) return false;
|
|
// Aggregated view: only notes from THIS entity itself are editable
|
|
// in-place. Notes pulled in from related entities (e.g. interests
|
|
// surfaced under a client) must be edited on the source page so the
|
|
// owning entity's timeline records the change.
|
|
const selfSource = SELF_SOURCE[entityType];
|
|
if (aggregateOn && note.source && note.source !== selfSource) return false;
|
|
const elapsed = now - new Date(note.createdAt).getTime();
|
|
return elapsed < NOTE_EDIT_WINDOW_MS;
|
|
}
|
|
|
|
function getTimeRemaining(note: Note): string | null {
|
|
const elapsed = now - new Date(note.createdAt).getTime();
|
|
const remaining = NOTE_EDIT_WINDOW_MS - elapsed;
|
|
if (remaining <= 0) return null;
|
|
const mins = Math.ceil(remaining / 60000);
|
|
return `${mins}m left to edit`;
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Create note form */}
|
|
<div className="space-y-2">
|
|
<Textarea
|
|
placeholder="Add a note..."
|
|
value={newNote}
|
|
onChange={(e) => setNewNote(e.target.value)}
|
|
rows={3}
|
|
/>
|
|
<div className="flex justify-end">
|
|
<Button
|
|
size="sm"
|
|
disabled={!newNote.trim() || createMutation.isPending}
|
|
onClick={() => createMutation.mutate(newNote.trim())}
|
|
>
|
|
{createMutation.isPending ? (
|
|
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
|
|
) : (
|
|
<Send className="mr-1.5 h-4 w-4" />
|
|
)}
|
|
Add Note
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Aggregated-mode controls — sort toggle. Only renders when
|
|
* aggregation is on and there's actually content to group. */}
|
|
{aggregateOn && notes.length > 0 && (
|
|
<div className="flex items-center justify-end gap-2 text-xs text-muted-foreground">
|
|
<span>View:</span>
|
|
<button
|
|
type="button"
|
|
className={!groupBySource ? 'font-medium text-foreground' : 'hover:text-foreground'}
|
|
onClick={() => setGroupBySource(false)}
|
|
>
|
|
Chronological
|
|
</button>
|
|
<span>·</span>
|
|
<button
|
|
type="button"
|
|
className={groupBySource ? 'font-medium text-foreground' : 'hover:text-foreground'}
|
|
onClick={() => setGroupBySource(true)}
|
|
>
|
|
Group by source
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Notes list */}
|
|
{isLoading ? (
|
|
<div className="text-center py-8 text-muted-foreground">Loading notes...</div>
|
|
) : notes.length === 0 ? (
|
|
<div className="text-center py-8 text-muted-foreground">No notes yet</div>
|
|
) : (
|
|
<div ref={animateRef} className="space-y-3">
|
|
{(groupBySource ? sortByGroup(notes) : notes).map((note) => (
|
|
<div key={note.id} className="flex gap-3 p-3 rounded-lg border bg-card">
|
|
<Avatar className="h-8 w-8 shrink-0">
|
|
<AvatarFallback className="text-xs">
|
|
{(note.authorName ?? 'U').charAt(0).toUpperCase()}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<div className="flex-1 min-w-0 space-y-1">
|
|
<div className="flex items-center gap-2 text-sm flex-wrap">
|
|
<span className="font-medium">{note.authorName ?? 'User'}</span>
|
|
<span className="text-muted-foreground">
|
|
{formatDistanceToNow(new Date(note.createdAt), { addSuffix: true })}
|
|
</span>
|
|
{aggregateOn &&
|
|
note.source &&
|
|
note.source !== SELF_SOURCE[entityType] &&
|
|
note.sourceLabel && (
|
|
<span
|
|
className={`inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium ${SOURCE_BADGE_CLASS[note.source]}`}
|
|
title={`From ${note.source}`}
|
|
>
|
|
{SOURCE_LABEL[note.source]} · {note.sourceLabel}
|
|
</span>
|
|
)}
|
|
{note.isLocked && <Lock className="h-3 w-3 text-muted-foreground" />}
|
|
{canEdit(note) && (
|
|
<span className="text-xs text-muted-foreground">{getTimeRemaining(note)}</span>
|
|
)}
|
|
</div>
|
|
{editingId === note.id ? (
|
|
<div className="space-y-2">
|
|
<Textarea
|
|
value={editContent}
|
|
onChange={(e) => setEditContent(e.target.value)}
|
|
rows={3}
|
|
/>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
size="sm"
|
|
disabled={!editContent.trim() || updateMutation.isPending}
|
|
onClick={() =>
|
|
updateMutation.mutate({ noteId: note.id, content: editContent.trim() })
|
|
}
|
|
>
|
|
Save
|
|
</Button>
|
|
<Button size="sm" variant="ghost" onClick={() => setEditingId(null)}>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<p className="text-sm whitespace-pre-wrap">{note.content}</p>
|
|
)}
|
|
</div>
|
|
{canEdit(note) && editingId !== note.id && (
|
|
<div className="flex gap-1 shrink-0">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7"
|
|
onClick={() => {
|
|
setEditingId(note.id);
|
|
setEditContent(note.content);
|
|
}}
|
|
>
|
|
<Pencil className="h-3.5 w-3.5" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7 text-destructive"
|
|
onClick={() => deleteMutation.mutate(note.id)}
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|