'use client'; import { useState } from 'react'; import { useParams } from 'next/navigation'; import { Plus, Archive, Tag as TagIcon, TagsIcon, Trash2 } from 'lucide-react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; 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 { SaveViewDialog } from '@/components/shared/save-view-dialog'; 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 { TagPicker } from '@/components/shared/tag-picker'; import { BulkHardDeleteDialog } from '@/components/clients/bulk-hard-delete-dialog'; import { BulkArchiveWizard } from '@/components/clients/bulk-archive-wizard'; import { usePermissions } from '@/hooks/use-permissions'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { ClientForm } from '@/components/clients/client-form'; import { clientFilterDefinitions } from '@/components/clients/client-filters'; import { ClientCard } from '@/components/clients/client-card'; import { CLIENT_COLUMN_OPTIONS, CLIENT_DEFAULT_HIDDEN, getClientColumns, type ClientRow, } from '@/components/clients/client-columns'; import { ColumnPicker } from '@/components/shared/column-picker'; import { useCreateFromUrl } from '@/hooks/use-create-from-url'; import { usePaginatedQuery } from '@/hooks/use-paginated-query'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; import { useTablePreferences } from '@/hooks/use-table-preferences'; import { apiFetch } from '@/lib/api/client'; export function ClientList() { const params = useParams<{ portSlug: string }>(); const portSlug = params?.portSlug ?? ''; const queryClient = useQueryClient(); const [createOpen, setCreateOpen] = useState(false); useCreateFromUrl(() => setCreateOpen(true)); const [editClient, setEditClient] = useState(null); const [archiveClient, setArchiveClient] = useState(null); const [tagDialog, setTagDialog] = useState<{ ids: string[]; mode: 'add' | 'remove' } | null>( null, ); const [tagChoice, setTagChoice] = useState([]); const [bulkDeleteIds, setBulkDeleteIds] = useState([]); const [bulkArchiveIds, setBulkArchiveIds] = useState([]); const [saveViewOpen, setSaveViewOpen] = useState(false); const { can } = usePermissions(); const canHardDelete = can('admin', 'permanently_delete_clients'); const canBulkArchive = can('clients', 'delete'); const canBulkTag = can('clients', 'edit'); const { data, pagination, isLoading, isFetching, sort, setSort, setPage, setPageSize, filters, setFilter, clearFilters, } = usePaginatedQuery({ queryKey: ['clients'], endpoint: '/api/v1/clients', filterDefinitions: clientFilterDefinitions, }); useRealtimeInvalidation({ 'client:created': [['clients']], 'client:updated': [['clients']], 'client:archived': [['clients']], 'client:restored': [['clients']], }); const archiveMutation = useMutation({ mutationFn: (id: string) => apiFetch(`/api/v1/clients/${id}`, { method: 'DELETE' }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['clients'] }); setArchiveClient(null); }, }); const bulkMutation = useMutation({ mutationFn: async ( payload: | { action: 'archive'; ids: string[] } | { 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/clients/bulk', { method: 'POST', body: payload }, ), onSuccess: (res) => { queryClient.invalidateQueries({ queryKey: ['clients'] }); const s = res.data.summary; if (s.failed > 0) { toast.warning(`${s.succeeded} of ${s.total} succeeded. ${s.failed} failed.`); } else if (s.succeeded > 0) { toast.success(`${s.succeeded} client${s.succeeded === 1 ? '' : 's'} updated.`); } }, onError: (err: unknown) => { toast.error(err instanceof Error ? err.message : 'Bulk action failed'); }, }); const columns = getClientColumns({ portSlug, onEdit: (client) => setEditClient(client), onArchive: (client) => setArchiveClient(client), }); // Per-user column visibility, persisted into user_profiles.preferences // via /api/v1/me. Hidden IDs are the source of truth — `actions` and // `select` columns aren't user-toggleable so they're never in the // hidden set. New columns surface for existing users by default. const { hidden, setHidden } = useTablePreferences('clients', CLIENT_DEFAULT_HIDDEN); const columnVisibility = Object.fromEntries(hidden.map((id) => [id, false])); return (
{ clearFilters(); Object.entries(savedFilters).forEach(([key, val]) => setFilter(key, val)); }} />
{isLoading ? ( ) : ( { setPage(p); setPageSize(ps); }} sort={sort} onSortChange={setSort} isLoading={isFetching && !isLoading} getRowId={(row) => row.id} bulkActions={[ ...(canBulkTag ? [ { label: 'Add tag', icon: TagIcon, onClick: (ids: string[]) => { if (ids.length === 0) return; setTagChoice([]); setTagDialog({ ids, mode: 'add' }); }, }, { label: 'Remove tag', icon: TagsIcon, onClick: (ids: string[]) => { if (ids.length === 0) return; setTagChoice([]); setTagDialog({ ids, mode: 'remove' }); }, }, ] : []), ...(canBulkArchive ? [ { label: 'Archive', icon: Archive, variant: 'destructive' as const, onClick: (ids: string[]) => { if (ids.length === 0) return; setBulkArchiveIds(ids); }, }, ] : []), ...(canHardDelete ? [ { label: 'Permanently delete (archived only)', icon: Trash2, variant: 'destructive' as const, onClick: (ids: string[]) => { if (ids.length === 0) return; setBulkDeleteIds(ids); }, }, ] : []), ]} cardRender={(row) => ( )} emptyState={ setCreateOpen(true) }} /> } /> )} {/* Bulk tag add/remove */} !o && setTagDialog(null)}> {tagDialog?.mode === 'add' ? 'Add tag' : 'Remove tag'} {tagDialog?.mode === 'add' ? `Add a tag to ${tagDialog?.ids.length ?? 0} selected client${tagDialog?.ids.length === 1 ? '' : 's'}.` : `Remove a tag from ${tagDialog?.ids.length ?? 0} selected client${tagDialog?.ids.length === 1 ? '' : 's'}. Clients without 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.

{editClient && ( !open && setEditClient(null)} client={editClient as unknown as NonNullable[0]['client']>} /> )} !open && setArchiveClient(null)} entityName={archiveClient?.fullName ?? ''} entityType="Client" isArchived={false} onConfirm={() => archiveClient && archiveMutation.mutate(archiveClient.id)} isLoading={archiveMutation.isPending} /> 0} onOpenChange={(open) => !open && setBulkDeleteIds([])} clientIds={bulkDeleteIds} onDeleted={() => setBulkDeleteIds([])} /> 0} onOpenChange={(open) => !open && setBulkArchiveIds([])} clientIds={bulkArchiveIds} onSuccess={() => setBulkArchiveIds([])} />
); }