'use client'; import { useEffect, useState } from 'react'; import { useParams } from 'next/navigation'; import { Plus, LayoutList, Kanban, Archive, ArrowRight, Tag as TagIcon, TagsIcon, } from 'lucide-react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider'; import { Button } from '@/components/ui/button'; import { DataTable } from '@/components/shared/data-table'; import { FilterBar } from '@/components/shared/filter-bar'; import { SavedViewsDropdown } from '@/components/shared/saved-views-dropdown'; import { PageHeader } from '@/components/shared/page-header'; import { EmptyState } from '@/components/shared/empty-state'; import { TableSkeleton } from '@/components/shared/loading-skeleton'; import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog'; import { PermissionGate } from '@/components/shared/permission-gate'; import { InterestForm } from '@/components/interests/interest-form'; import { PipelineBoard } from '@/components/interests/pipeline-board'; import { interestFilterDefinitions } from '@/components/interests/interest-filters'; import { getInterestColumns, INTEREST_COLUMN_OPTIONS, INTEREST_DEFAULT_HIDDEN, type InterestRow, } from '@/components/interests/interest-columns'; import { ColumnPicker } from '@/components/shared/column-picker'; import { ExportListPdfButton } from '@/components/reports/export-list-pdf-button'; import { SaveViewDialog } from '@/components/shared/save-view-dialog'; import { useCreateFromUrl } from '@/hooks/use-create-from-url'; import { useTablePreferences } from '@/hooks/use-table-preferences'; import { InterestCard } from '@/components/interests/interest-card'; import { StageLegend } from '@/components/interests/stage-legend'; import { TagPicker } from '@/components/shared/tag-picker'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { usePaginatedQuery } from '@/hooks/use-paginated-query'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; import { useConfirmation } from '@/hooks/use-confirmation'; import { apiFetch } from '@/lib/api/client'; import { usePipelineStore } from '@/stores/pipeline-store'; import { PIPELINE_STAGES, STAGE_LABELS, type PipelineStage } from '@/lib/constants'; export function InterestList() { const params = useParams<{ portSlug: string }>(); const portSlug = params?.portSlug ?? ''; const queryClient = useQueryClient(); const { confirm, dialog: confirmDialog } = useConfirmation(); const { viewMode, setViewMode } = usePipelineStore(); // M-U14: surface the page title in the mobile topbar. const { setChrome } = useMobileChrome(); useEffect(() => { setChrome({ title: 'Interests', showBackButton: false }); return () => setChrome({ title: null, showBackButton: false }); }, [setChrome]); // Force the list view at mobile widths even when the user previously // toggled the kanban from desktop - the board is desktop-only. useEffect(() => { if (typeof window === 'undefined') return; if (viewMode === 'board' && window.innerWidth < 640) setViewMode('table'); }, [viewMode, setViewMode]); const [createOpen, setCreateOpen] = useState(false); useCreateFromUrl(() => setCreateOpen(true)); const [editInterest, setEditInterest] = useState(null); const [archiveInterest, setArchiveInterest] = useState(null); const [saveViewOpen, setSaveViewOpen] = useState(false); // Bulk-action dialog state const [stageDialog, setStageDialog] = useState<{ ids: string[] } | null>(null); const [stageChoice, setStageChoice] = useState('enquiry'); const [tagDialog, setTagDialog] = useState<{ ids: string[]; mode: 'add' | 'remove' } | null>( null, ); const [tagChoice, setTagChoice] = useState([]); const { data, pagination, isLoading, isFetching, sort, setSort, setPage, setPageSize, filters, setFilter, applyView, clearFilters, } = usePaginatedQuery({ queryKey: ['interests'], endpoint: '/api/v1/interests', // Surface the active sort visibly on the column header. The API // already defaults to updatedAt desc when no sort param is sent, but // without this the table renders with no active-sort indicator and // the rep can't tell what ordering is in play. Newly added / edited // deals bubble to the top of the list - the most useful default for // triage. initialSort: { field: 'updatedAt', direction: 'desc' }, filterDefinitions: interestFilterDefinitions, }); useRealtimeInvalidation({ 'interest:created': [['interests']], 'interest:updated': [['interests']], 'interest:stageChanged': [['interests']], 'interest:archived': [['interests']], 'interest:berthLinked': [['interests']], 'interest:berthUnlinked': [['interests']], }); const archiveMutation = useMutation({ mutationFn: (id: string) => apiFetch(`/api/v1/interests/${id}`, { method: 'DELETE' }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['interests'] }); setArchiveInterest(null); }, }); // Single bulk endpoint replaces the prior parallel fan-out - gives // the user a per-row failure summary and shares one server-side // permission check. const bulkMutation = useMutation({ mutationFn: async ( payload: | { action: 'archive'; ids: string[] } | { action: 'change_stage'; ids: string[]; pipelineStage: PipelineStage } | { action: 'add_tag'; ids: string[]; tagId: string } | { action: 'remove_tag'; ids: string[]; tagId: string }, ) => apiFetch<{ data: { summary: { total: number; succeeded: number; failed: number } } }>( '/api/v1/interests/bulk', { method: 'POST', body: payload }, ), onSuccess: (res) => { queryClient.invalidateQueries({ queryKey: ['interests'] }); const s = res.data.summary; if (s.failed > 0) { toast.warning( `${s.succeeded} of ${s.total} succeeded. ${s.failed} failed - check the activity log.`, ); } }, }); const columns = getInterestColumns({ portSlug, onEdit: (interest) => setEditInterest(interest), onArchive: (interest) => setArchiveInterest(interest), }); // Persisted per-user column visibility - same pattern as ClientList. // The hidden array is the source of truth; built columns stay // declared and we drive table visibility via columnVisibility. const { hidden, setHidden } = useTablePreferences('interests', INTEREST_DEFAULT_HIDDEN); const columnVisibility = Object.fromEntries(hidden.map((id) => [id, false])); return (
{/* Kanban view is desktop-only - mobile drops the toggle and falls back to the list/cards view (the board's column horizontal-scroll model is unusable at phone widths). */}
} />
{/* On the kanban view we strip filters that don't make sense * there: `pipelineStage` (the columns ARE the stages) and * `includeArchived` (the board is for active deals - the * list view is the place to see history). The board endpoint * rejects these via boardFiltersSchema if they're sent. */} f.key !== 'pipelineStage' && f.key !== 'includeArchived', ) : interestFilterDefinitions } values={filters} onChange={setFilter} onClear={clearFilters} /> {/* Tag picker - primary use case is filtering by event/yacht-show * ("Palm Beach 2026") that the rep tagged interests with at the * show. The validator already accepts `tagIds` on listInterests; * this surfaces the input in the filter UI. */}
setFilter('tagIds', ids)} placeholder="Filter by tag / event…" />
{/* Right-aligned toolbar group: saved views + column picker + stage legend. `ml-auto` pushes the group to the right edge so it sits flush with where the table extends to on desktop. Wraps to a new line on narrow viewports because the outer container is `flex-wrap`. Kanban view hides the table-only controls. */}
{viewMode === 'table' ? ( <> { applyView({ filters: savedFilters, sort: savedSort }); }} />
{viewMode === 'board' ? ( ) : isLoading ? ( ) : ( { setPage(p); setPageSize(ps); }} sort={sort} onSortChange={setSort} isLoading={isFetching && !isLoading} getRowId={(row) => row.id} bulkActions={[ { label: 'Change stage', icon: ArrowRight, onClick: (ids) => { if (ids.length === 0) return; setStageChoice('enquiry'); setStageDialog({ ids }); }, }, { label: 'Add tag', icon: TagIcon, onClick: (ids) => { if (ids.length === 0) return; setTagChoice([]); setTagDialog({ ids, mode: 'add' }); }, }, { label: 'Remove tag', icon: TagsIcon, onClick: (ids) => { if (ids.length === 0) return; setTagChoice([]); setTagDialog({ ids, mode: 'remove' }); }, }, { label: 'Archive', icon: Archive, variant: 'destructive', onClick: async (ids) => { if (ids.length === 0) return; const ok = await confirm({ title: `Archive ${ids.length} interest${ids.length === 1 ? '' : 's'}`, description: 'This can be undone from the archived list.', confirmLabel: 'Archive', }); if (!ok) return; bulkMutation.mutate({ action: 'archive', ids }); }, }, ]} cardRender={(row) => ( )} emptyState={ setCreateOpen(true) }} /> } /> )} {/* Mobile FAB - primary "New interest" affordance for the bottom-tab UX. Sits above the bottom nav (pb-safe-bottom + 70px tab height + 16px gap). Hidden on lg+ where the header button already does the job. */} {editInterest && ( !open && setEditInterest(null)} interest={editInterest as unknown as Parameters[0]['interest']} /> )} !open && setArchiveInterest(null)} entityName={archiveInterest?.clientName ?? 'Interest'} entityType="Interest" isArchived={false} onConfirm={() => archiveInterest && archiveMutation.mutate(archiveInterest.id)} isLoading={archiveMutation.isPending} /> {/* Bulk: change stage */} !o && setStageDialog(null)}> Change stage Move {stageDialog?.ids.length ?? 0} interest {stageDialog?.ids.length === 1 ? '' : 's'} to a new pipeline stage. Invalid transitions are skipped per row.
{/* Bulk: add / remove tag */} !o && setTagDialog(null)}> {tagDialog?.mode === 'add' ? 'Add tag' : 'Remove tag'} {tagDialog?.mode === 'add' ? `Add a tag to ${tagDialog?.ids.length ?? 0} selected interest${tagDialog?.ids.length === 1 ? '' : 's'}.` : `Remove a tag from ${tagDialog?.ids.length ?? 0} selected interest${tagDialog?.ids.length === 1 ? '' : 's'}. Interests that don't have the tag are unchanged.`}
setTagChoice(ids.slice(-1))} placeholder="Pick one tag…" />

Pick a single tag. To apply multiple tags, run the action once per tag.

{confirmDialog} ); }