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>
This commit is contained in:
@@ -127,6 +127,9 @@ export function InquiryInbox() {
|
|||||||
const phone = pickPhone(row.payload);
|
const phone = pickPhone(row.payload);
|
||||||
triageMutation.mutate({ id: row.id, state: 'converted' });
|
triageMutation.mutate({ id: row.id, state: 'converted' });
|
||||||
const qs = new URLSearchParams();
|
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 (name) qs.set('prefill_name', name);
|
||||||
if (email) qs.set('prefill_email', email);
|
if (email) qs.set('prefill_email', email);
|
||||||
if (phone) qs.set('prefill_phone', phone);
|
if (phone) qs.set('prefill_phone', phone);
|
||||||
|
|||||||
@@ -40,6 +40,18 @@ interface ClientFormProps {
|
|||||||
* or opening the create-interest dialog pre-filled with that
|
* or opening the create-interest dialog pre-filled with that
|
||||||
* clientId. Skipped in edit mode. */
|
* clientId. Skipped in edit mode. */
|
||||||
onUseExistingClient?: (clientId: string) => void;
|
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 */
|
/** If provided, form is in edit mode */
|
||||||
client?: {
|
client?: {
|
||||||
id: string;
|
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 queryClient = useQueryClient();
|
||||||
const isEdit = !!client;
|
const isEdit = !!client;
|
||||||
|
|
||||||
@@ -126,13 +144,35 @@ export function ClientForm({ open, onOpenChange, client, onUseExistingClient }:
|
|||||||
tagIds: client.tags?.map((t) => t.id) ?? [],
|
tagIds: client.tags?.map((t) => t.id) ?? [],
|
||||||
});
|
});
|
||||||
} else if (!client && open) {
|
} 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({
|
reset({
|
||||||
fullName: '',
|
fullName: prefill?.fullName ?? '',
|
||||||
contacts: [{ channel: 'email', value: '', isPrimary: true }],
|
contacts,
|
||||||
|
source: prefill?.source,
|
||||||
|
sourceInquiryId: prefill?.sourceInquiryId,
|
||||||
tagIds: [],
|
tagIds: [],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [client, open, reset]);
|
}, [client, open, reset, prefill]);
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: async (data: CreateClientInput) => {
|
mutationFn: async (data: CreateClientInput) => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams, useSearchParams } from 'next/navigation';
|
||||||
import { Plus, Archive, Tag as TagIcon, TagsIcon, Trash2 } from 'lucide-react';
|
import { Plus, Archive, Tag as TagIcon, TagsIcon, Trash2 } from 'lucide-react';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -52,6 +52,32 @@ export function ClientList() {
|
|||||||
|
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
useCreateFromUrl(() => setCreateOpen(true));
|
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 [editClient, setEditClient] = useState<ClientRow | null>(null);
|
||||||
const [archiveClient, setArchiveClient] = useState<ClientRow | null>(null);
|
const [archiveClient, setArchiveClient] = useState<ClientRow | null>(null);
|
||||||
const [tagDialog, setTagDialog] = useState<{ ids: string[]; mode: 'add' | 'remove' } | null>(
|
const [tagDialog, setTagDialog] = useState<{ ids: string[]; mode: 'add' | 'remove' } | null>(
|
||||||
@@ -317,7 +343,7 @@ export function ClientList() {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<ClientForm open={createOpen} onOpenChange={setCreateOpen} />
|
<ClientForm open={createOpen} onOpenChange={setCreateOpen} prefill={createPrefill} />
|
||||||
|
|
||||||
{editClient && (
|
{editClient && (
|
||||||
<ClientForm
|
<ClientForm
|
||||||
|
|||||||
@@ -2262,46 +2262,71 @@ export async function listInflightWorkflowsAggregatedByEntity(
|
|||||||
? documents.companyId
|
? documents.companyId
|
||||||
: documents.yachtId;
|
: documents.yachtId;
|
||||||
|
|
||||||
const direct = await fetchWorkflowGroupRows(portId, eq(directColumn, entityId));
|
// Batch the related-entity workflow lookups in parallel — the
|
||||||
if (direct.rows.length > 0) {
|
// 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({
|
groups.push({
|
||||||
label: 'DIRECTLY ATTACHED',
|
label: 'DIRECTLY ATTACHED',
|
||||||
source: 'direct',
|
source: 'direct',
|
||||||
workflows: direct.rows,
|
workflows: directResult.rows,
|
||||||
total: direct.total,
|
total: directResult.total,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const { id, name } of related.companies) {
|
for (const { name, result } of companyResults) {
|
||||||
const g = await fetchWorkflowGroupRows(portId, eq(documents.companyId, id));
|
if (result.rows.length === 0) continue;
|
||||||
if (g.rows.length === 0) continue;
|
|
||||||
groups.push({
|
groups.push({
|
||||||
label: `FROM COMPANY: ${name.toUpperCase()}`,
|
label: `FROM COMPANY: ${name.toUpperCase()}`,
|
||||||
source: 'company',
|
source: 'company',
|
||||||
workflows: g.rows,
|
workflows: result.rows,
|
||||||
total: g.total,
|
total: result.total,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const { id, name } of related.yachts) {
|
for (const { name, result } of yachtResults) {
|
||||||
const g = await fetchWorkflowGroupRows(portId, eq(documents.yachtId, id));
|
if (result.rows.length === 0) continue;
|
||||||
if (g.rows.length === 0) continue;
|
|
||||||
groups.push({
|
groups.push({
|
||||||
label: `FROM YACHT: ${name.toUpperCase()}`,
|
label: `FROM YACHT: ${name.toUpperCase()}`,
|
||||||
source: 'yacht',
|
source: 'yacht',
|
||||||
workflows: g.rows,
|
workflows: result.rows,
|
||||||
total: g.total,
|
total: result.total,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const { id, name } of related.clients) {
|
for (const { name, result } of clientResults) {
|
||||||
const g = await fetchWorkflowGroupRows(portId, eq(documents.clientId, id));
|
if (result.rows.length === 0) continue;
|
||||||
if (g.rows.length === 0) continue;
|
|
||||||
groups.push({
|
groups.push({
|
||||||
label: `FROM CLIENT: ${name.toUpperCase()}`,
|
label: `FROM CLIENT: ${name.toUpperCase()}`,
|
||||||
source: 'client',
|
source: 'client',
|
||||||
workflows: g.rows,
|
workflows: result.rows,
|
||||||
total: g.total,
|
total: result.total,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ export const createClientSchema = z.object({
|
|||||||
timezone: optionalIanaTimezoneSchema.optional(),
|
timezone: optionalIanaTimezoneSchema.optional(),
|
||||||
source: z.enum(['website', 'manual', 'referral', 'broker', 'other']).optional(),
|
source: z.enum(['website', 'manual', 'referral', 'broker', 'other']).optional(),
|
||||||
sourceDetails: z.string().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([]),
|
tagIds: z.array(z.string()).optional().default([]),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user