'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 = { 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 = new Set([ 'clients', 'yachts', 'companies', 'residential_clients', ]); const SOURCE_BADGE_CLASS: Record = { 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 = { 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 = { 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(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(); const { data: notes = [], isLoading } = useQuery({ 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 (
{/* Create note form */}