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:
2026-05-14 15:37:23 +02:00
parent e933e32dbd
commit a77b3c670a
5 changed files with 124 additions and 26 deletions

View File

@@ -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) => {