diff --git a/src/components/admin/inquiry-inbox.tsx b/src/components/admin/inquiry-inbox.tsx index 7573cb61..d846f262 100644 --- a/src/components/admin/inquiry-inbox.tsx +++ b/src/components/admin/inquiry-inbox.tsx @@ -127,6 +127,9 @@ export function InquiryInbox() { const phone = pickPhone(row.payload); triageMutation.mutate({ id: row.id, state: 'converted' }); const qs = new URLSearchParams(); + // create=1 auto-opens the new-client sheet; the client-list page + // reads the prefill_* params and hydrates the form (P-4.5). + qs.set('create', '1'); if (name) qs.set('prefill_name', name); if (email) qs.set('prefill_email', email); if (phone) qs.set('prefill_phone', phone); diff --git a/src/components/clients/client-form.tsx b/src/components/clients/client-form.tsx index 163a2e97..9fd75b36 100644 --- a/src/components/clients/client-form.tsx +++ b/src/components/clients/client-form.tsx @@ -40,6 +40,18 @@ interface ClientFormProps { * or opening the create-interest dialog pre-filled with that * clientId. Skipped in edit mode. */ onUseExistingClient?: (clientId: string) => void; + /** Optional initial values for the create flow — used by the + * inquiry-inbox "Convert to client" triage step (P-4.5) so the rep + * doesn't retype values they just read in the inbox. The + * `sourceInquiryId` is persisted to `clients.source_inquiry_id` on + * save, preserving the inquiry → client lineage for reporting. */ + prefill?: { + fullName?: string; + email?: string; + phone?: string; + source?: 'website' | 'manual' | 'referral' | 'broker' | 'other'; + sourceInquiryId?: string; + }; /** If provided, form is in edit mode */ client?: { id: string; @@ -63,7 +75,13 @@ interface ClientFormProps { }; } -export function ClientForm({ open, onOpenChange, client, onUseExistingClient }: ClientFormProps) { +export function ClientForm({ + open, + onOpenChange, + client, + onUseExistingClient, + prefill, +}: ClientFormProps) { const queryClient = useQueryClient(); const isEdit = !!client; @@ -126,13 +144,35 @@ export function ClientForm({ open, onOpenChange, client, onUseExistingClient }: tagIds: client.tags?.map((t) => t.id) ?? [], }); } else if (!client && open) { + // P-4.5: when the inquiry-inbox triage flow opens the form via + // `?create=1&prefill_*`, hydrate the initial values so the rep + // doesn't retype data they just reviewed. `sourceInquiryId` + // gets persisted on save (clients.source_inquiry_id column) so + // the inquiry → client lineage survives for the conversion- + // funnel chart. + const contacts: CreateClientInput['contacts'] = []; + if (prefill?.email) { + contacts.push({ channel: 'email', value: prefill.email, isPrimary: true }); + } + if (prefill?.phone) { + contacts.push({ + channel: 'phone', + value: prefill.phone, + isPrimary: contacts.length === 0, + }); + } + if (contacts.length === 0) { + contacts.push({ channel: 'email', value: '', isPrimary: true }); + } reset({ - fullName: '', - contacts: [{ channel: 'email', value: '', isPrimary: true }], + fullName: prefill?.fullName ?? '', + contacts, + source: prefill?.source, + sourceInquiryId: prefill?.sourceInquiryId, tagIds: [], }); } - }, [client, open, reset]); + }, [client, open, reset, prefill]); const mutation = useMutation({ mutationFn: async (data: CreateClientInput) => { diff --git a/src/components/clients/client-list.tsx b/src/components/clients/client-list.tsx index 755ddc23..19ba506a 100644 --- a/src/components/clients/client-list.tsx +++ b/src/components/clients/client-list.tsx @@ -1,7 +1,7 @@ 'use client'; -import { useState } from 'react'; -import { useParams } from 'next/navigation'; +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'; @@ -52,6 +52,32 @@ export function ClientList() { 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(null); const [archiveClient, setArchiveClient] = useState(null); const [tagDialog, setTagDialog] = useState<{ ids: string[]; mode: 'add' | 'remove' } | null>( @@ -317,7 +343,7 @@ export function ClientList() { - + {editClient && ( 0) { + // Batch the related-entity workflow lookups in parallel — the + // pre-2026-05-14 sequential loop fired ~50 queries on a busy client + // (direct + each company + each yacht + each related client), each + // round-trip blocking the next. Now every lookup runs concurrently + // via Promise.all; total wall-clock collapses to "slowest single + // query" instead of "sum of every query". Future fully-batched UNION + // query in PRE-DEPLOY-PLAN follow-ups. + const [directResult, companyResults, yachtResults, clientResults] = await Promise.all([ + fetchWorkflowGroupRows(portId, eq(directColumn, entityId)), + Promise.all( + related.companies.map(async ({ id, name }) => ({ + name, + result: await fetchWorkflowGroupRows(portId, eq(documents.companyId, id)), + })), + ), + Promise.all( + related.yachts.map(async ({ id, name }) => ({ + name, + result: await fetchWorkflowGroupRows(portId, eq(documents.yachtId, id)), + })), + ), + Promise.all( + related.clients.map(async ({ id, name }) => ({ + name, + result: await fetchWorkflowGroupRows(portId, eq(documents.clientId, id)), + })), + ), + ]); + + if (directResult.rows.length > 0) { groups.push({ label: 'DIRECTLY ATTACHED', source: 'direct', - workflows: direct.rows, - total: direct.total, + workflows: directResult.rows, + total: directResult.total, }); } - for (const { id, name } of related.companies) { - const g = await fetchWorkflowGroupRows(portId, eq(documents.companyId, id)); - if (g.rows.length === 0) continue; + for (const { name, result } of companyResults) { + if (result.rows.length === 0) continue; groups.push({ label: `FROM COMPANY: ${name.toUpperCase()}`, source: 'company', - workflows: g.rows, - total: g.total, + workflows: result.rows, + total: result.total, }); } - for (const { id, name } of related.yachts) { - const g = await fetchWorkflowGroupRows(portId, eq(documents.yachtId, id)); - if (g.rows.length === 0) continue; + for (const { name, result } of yachtResults) { + if (result.rows.length === 0) continue; groups.push({ label: `FROM YACHT: ${name.toUpperCase()}`, source: 'yacht', - workflows: g.rows, - total: g.total, + workflows: result.rows, + total: result.total, }); } - for (const { id, name } of related.clients) { - const g = await fetchWorkflowGroupRows(portId, eq(documents.clientId, id)); - if (g.rows.length === 0) continue; + for (const { name, result } of clientResults) { + if (result.rows.length === 0) continue; groups.push({ label: `FROM CLIENT: ${name.toUpperCase()}`, source: 'client', - workflows: g.rows, - total: g.total, + workflows: result.rows, + total: result.total, }); } diff --git a/src/lib/validators/clients.ts b/src/lib/validators/clients.ts index a0c39a07..cbefa7bd 100644 --- a/src/lib/validators/clients.ts +++ b/src/lib/validators/clients.ts @@ -34,6 +34,10 @@ export const createClientSchema = z.object({ timezone: optionalIanaTimezoneSchema.optional(), source: z.enum(['website', 'manual', 'referral', 'broker', 'other']).optional(), sourceDetails: z.string().optional(), + /** When the client was created from a website-inquiry triage, points + * back at the originating `website_submissions.id`. Drives the + * conversion-funnel-by-source chart. Migration 0065 installs the FK. */ + sourceInquiryId: z.string().optional(), tagIds: z.array(z.string()).optional().default([]), });