Files
pn-new-crm/src/components/clients/client-list.tsx
Matt a77b3c670a feat(ux): P-4.5 inquiry linkage + docs N+1 parallelization
Step 4 (in progress) — first slice of UX features.

P-4.5: inquiry → client linkage now survives the triage conversion.

- inquiry-inbox.tsx adds `?create=1` to the redirect so the new-client
  sheet auto-opens (the existing prefill_* params were already being
  written but the form never opened).
- client-list.tsx reads prefill_name / prefill_email / prefill_phone /
  prefill_source / prefill_inquiry_id from useSearchParams and passes
  them to ClientForm via a typed `prefill` prop.
- ClientForm hydrates the create-flow initial values from the prefill
  AND threads `sourceInquiryId` through to the createClient mutation.
- createClientSchema accepts `sourceInquiryId`; the existing service
  spread already passes it to drizzle's insert.

Net effect: a website inquiry that gets converted now lands as a
client row with `clients.source_inquiry_id` populated. The conversion
funnel-by-source chart (Step 6) can attribute the win back to the
originating inquiry.

Documents tab N+1: `listInflightWorkflowsAggregatedByEntity` previously
walked direct + every company + every yacht + every related client
sequentially. On a busy client (~25 related entities) this was ~50
sequential round-trips with cumulative latency. Replaced with a single
`Promise.all` over the four lookup groups + nested Promise.all over
the per-entity queries within each group. Same query count, but wall-
clock collapses from "sum of every query" to "max single round-trip"
(typically <100ms now vs >1s before).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 15:37:23 +02:00

382 lines
14 KiB
TypeScript

'use client';
import { useMemo, useState } from 'react';
import { useParams, useSearchParams } 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';
import { toastError } from '@/lib/api/toast-error';
export function ClientList() {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const queryClient = useQueryClient();
const [createOpen, setCreateOpen] = useState(false);
useCreateFromUrl(() => setCreateOpen(true));
// P-4.5: inquiry-inbox triage flow lands on
// /[port]/clients?create=1&prefill_name=…&prefill_email=…&prefill_phone=…
// &prefill_source=website&prefill_inquiry_id=…
// Hydrate the create form so the rep doesn't retype.
const searchParams = useSearchParams();
const createPrefill = useMemo(() => {
if (!searchParams) return undefined;
const name = searchParams.get('prefill_name');
const email = searchParams.get('prefill_email');
const phone = searchParams.get('prefill_phone');
const source = searchParams.get('prefill_source');
const inquiryId = searchParams.get('prefill_inquiry_id');
if (!name && !email && !phone && !inquiryId) return undefined;
const allowedSources = ['website', 'manual', 'referral', 'broker', 'other'] as const;
type Src = (typeof allowedSources)[number];
const isSrc = (s: string | null): s is Src =>
!!s && (allowedSources as readonly string[]).includes(s);
return {
fullName: name ?? undefined,
email: email ?? undefined,
phone: phone ?? undefined,
source: isSrc(source) ? source : undefined,
sourceInquiryId: inquiryId ?? undefined,
};
}, [searchParams]);
const [editClient, setEditClient] = useState<ClientRow | null>(null);
const [archiveClient, setArchiveClient] = useState<ClientRow | null>(null);
const [tagDialog, setTagDialog] = useState<{ ids: string[]; mode: 'add' | 'remove' } | null>(
null,
);
const [tagChoice, setTagChoice] = useState<string[]>([]);
const [bulkDeleteIds, setBulkDeleteIds] = useState<string[]>([]);
const [bulkArchiveIds, setBulkArchiveIds] = useState<string[]>([]);
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,
setAllFilters,
clearFilters,
} = usePaginatedQuery<ClientRow>({
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) => {
toastError(err, '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 (
<div className="space-y-4">
<PageHeader title="Clients" description="Manage your client records" variant="gradient" />
<div className="flex flex-wrap items-center gap-2">
<FilterBar
filters={clientFilterDefinitions}
values={filters}
onChange={setFilter}
onClear={clearFilters}
/>
<SavedViewsDropdown
entityType="clients"
onApplyView={(savedFilters, _savedSort) => {
// Atomic replace — sequential setFilter() calls dropped all
// but the last value (each one read stale `filters` from
// closure and overwrote). setAllFilters writes the whole
// saved view in one setState.
setAllFilters(savedFilters);
}}
/>
<ColumnPicker
columns={CLIENT_COLUMN_OPTIONS}
hidden={hidden}
onChange={setHidden}
onSaveView={() => setSaveViewOpen(true)}
/>
{/* New Client moved out of PageHeader actions and into the
filter row. Saves a row on mobile (no more dedicated
actions strip). ml-auto keeps the primary action at the
far-right edge, which is where reps look first. */}
<PermissionGate resource="clients" action="create">
<Button size="sm" className="ml-auto" onClick={() => setCreateOpen(true)}>
<Plus className="mr-1.5 h-4 w-4" aria-hidden />
New Client
</Button>
</PermissionGate>
</div>
<SaveViewDialog
open={saveViewOpen}
onOpenChange={setSaveViewOpen}
entityType="clients"
currentFilters={filters}
currentSort={sort}
/>
{isLoading ? (
<TableSkeleton />
) : (
<DataTable
columns={columns}
columnVisibility={columnVisibility}
data={data}
pagination={pagination}
onPaginationChange={(p, ps) => {
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) => (
<ClientCard
client={row.original}
portSlug={portSlug}
onEdit={setEditClient}
onArchive={setArchiveClient}
/>
)}
emptyState={
<EmptyState
title="No clients found"
description="Get started by adding your first client."
action={{ label: 'New Client', onClick: () => setCreateOpen(true) }}
/>
}
/>
)}
{/* Bulk tag add/remove */}
<Dialog open={!!tagDialog} onOpenChange={(o) => !o && setTagDialog(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{tagDialog?.mode === 'add' ? 'Add tag' : 'Remove tag'}</DialogTitle>
<DialogDescription>
{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.`}
</DialogDescription>
</DialogHeader>
<div className="py-2">
<TagPicker
selectedIds={tagChoice}
onChange={(ids) => setTagChoice(ids.slice(-1))}
placeholder="Pick one tag…"
/>
<p className="text-xs text-muted-foreground mt-2">
Pick a single tag. To apply multiple tags, run the action once per tag.
</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setTagDialog(null)}>
Cancel
</Button>
<Button
disabled={bulkMutation.isPending || tagChoice.length === 0}
onClick={() => {
if (!tagDialog || tagChoice.length === 0) return;
const tagId = tagChoice[0];
if (!tagId) return;
bulkMutation.mutate(
{
action: tagDialog.mode === 'add' ? 'add_tag' : 'remove_tag',
ids: tagDialog.ids,
tagId,
},
{ onSettled: () => setTagDialog(null) },
);
}}
>
Apply
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ClientForm open={createOpen} onOpenChange={setCreateOpen} prefill={createPrefill} />
{editClient && (
<ClientForm
open={!!editClient}
onOpenChange={(open) => !open && setEditClient(null)}
client={editClient as unknown as NonNullable<Parameters<typeof ClientForm>[0]['client']>}
/>
)}
<ArchiveConfirmDialog
open={!!archiveClient}
onOpenChange={(open) => !open && setArchiveClient(null)}
entityName={archiveClient?.fullName ?? ''}
entityType="Client"
isArchived={false}
onConfirm={() => archiveClient && archiveMutation.mutate(archiveClient.id)}
isLoading={archiveMutation.isPending}
/>
<BulkHardDeleteDialog
open={bulkDeleteIds.length > 0}
onOpenChange={(open) => !open && setBulkDeleteIds([])}
clientIds={bulkDeleteIds}
onDeleted={() => setBulkDeleteIds([])}
/>
<BulkArchiveWizard
open={bulkArchiveIds.length > 0}
onOpenChange={(open) => !open && setBulkArchiveIds([])}
clientIds={bulkArchiveIds}
onSuccess={() => setBulkArchiveIds([])}
/>
</div>
);
}