Files
Velocity-OS/webos/src/shared/hooks/useClient360.ts
Sagnik Ghosh 600716a69d
Some checks failed
Velocity-OS Deployment Pipeline / lint (push) Has been cancelled
Velocity-OS Deployment Pipeline / build-and-push (agents) (push) Has been cancelled
Velocity-OS Deployment Pipeline / build-and-push (core) (push) Has been cancelled
Velocity-OS Deployment Pipeline / build-and-push (media-engine) (push) Has been cancelled
Velocity-OS Deployment Pipeline / build-and-push (webos) (push) Has been cancelled
Velocity-OS Deployment Pipeline / sign-images (agents) (push) Has been cancelled
Velocity-OS Deployment Pipeline / sign-images (core) (push) Has been cancelled
Velocity-OS Deployment Pipeline / sign-images (media-engine) (push) Has been cancelled
Velocity-OS Deployment Pipeline / sign-images (webos) (push) Has been cancelled
Velocity-OS Deployment Pipeline / notify-ingress (push) Has been cancelled
fix: complete Velocity-OS feature migration wiring
2026-05-02 22:42:26 +05:30

332 lines
11 KiB
TypeScript

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/shared/lib/apiClient';
import { stableArray, unwrapObject } from '@/shared/lib/apiShape';
/**
* useClient360 — fetch unified client entity
* Feeds: CRM lead data + QD score + pipeline stage + contact info
*/
export function useClient360(personId: string) {
const query = useQuery({
queryKey: ['client360', personId],
queryFn: async () => {
const payload = await api.get<unknown>(`/crm/client-360/${personId}`);
return mapClient360(normalizeSnapshot(payload));
},
staleTime: 0,
refetchOnMount: 'always',
enabled: !!personId,
});
return { client: query.data, isLoading: query.isLoading, error: query.error };
}
/**
* useConversations — unified comms feed for a lead
*/
export function useConversations(personId: string) {
const qc = useQueryClient();
const query = useQuery({
queryKey: ['conversations', personId],
queryFn: async () => {
const payload = await api.get<unknown>(`/crm/client-360/${personId}`);
return mapConversationEvents(normalizeSnapshot(payload));
},
staleTime: 0,
refetchOnMount: 'always',
enabled: !!personId,
});
const sendWhatsApp = (text: string) =>
api.post(`/comms/send`, { person_id: personId, channel: 'whatsapp', text })
.then(() => qc.invalidateQueries({ queryKey: ['conversations', personId] }));
return { events: query.data ?? [], isLoading: query.isLoading, sendWhatsApp };
}
/**
* useClientProperties — linked property interests
*/
export function useClientProperties(personId: string) {
const query = useQuery({
queryKey: ['client-properties', personId],
queryFn: async () => {
const payload = await api.get<unknown>(`/crm/client-360/${personId}`);
return mapPropertyInterests(normalizeSnapshot(payload));
},
staleTime: 0,
refetchOnMount: 'always',
enabled: !!personId,
});
return { properties: query.data ?? [], isLoading: query.isLoading };
}
/**
* useClientTasks — tasks for a specific lead
*/
export function useClientTasks(personId: string) {
const qc = useQueryClient();
const query = useQuery({
queryKey: ['client-tasks', personId],
queryFn: async () => {
const payload = await api.get<unknown>(`/crm/client-360/${personId}`);
return mapTasks(normalizeSnapshot(payload));
},
staleTime: 0,
refetchOnMount: 'always',
enabled: !!personId,
});
const markDone = (taskId: string) =>
api.patch(`/crm/tasks/${taskId}`, { status: 'done' })
.then(() => qc.invalidateQueries({ queryKey: ['client-tasks', personId] }));
const snooze = (taskId: string) =>
api.patch(`/crm/tasks/${taskId}`, { status: 'snoozed' })
.then(() => qc.invalidateQueries({ queryKey: ['client-tasks', personId] }));
// Group tasks
const all = query.data ?? [];
const tasks = all.map(t => ({
...t,
group: t.status === 'done' ? 'completed'
: t.isDueToday ? 'today'
: 'upcoming',
})) as any[];
return { tasks, isLoading: query.isLoading, markDone, snooze };
}
// ── Types ────────────────────────────────────────────────────
export interface Client360Data {
id: string;
name: string;
location?: string;
primaryPhone?: string;
avatarUrl?: string;
qdScore: number;
qdDelta: number;
stageName: string;
stageEmoji: string;
lastContactRelative: string;
lastContactChannel: string;
aiInsight?: string;
extractedFacts?: Record<string, string>;
objections?: string[];
qdHistory?: { date: string; score: number; label?: string }[];
}
interface ConversationEvent {
id: string;
type: 'whatsapp' | 'call' | 'email';
timestamp: string;
timestampRelative: string;
messages?: { sender: 'client' | 'you'; text: string; status?: '✓' | '✓✓' }[];
duration?: string;
direction?: 'inbound' | 'outbound';
keyMoments?: string[];
hasTranscript?: boolean;
subject?: string;
}
interface PropertyInterest {
id: string;
projectName: string;
unitName: string;
config: string;
area: string;
price: string;
thumbnailUrl?: string;
isPrimary: boolean;
engagementLevel: 'High' | 'Medium' | 'Low';
}
interface Task {
id: string;
label: string;
dueAt?: string;
status: 'pending' | 'done' | 'snoozed';
isDueToday?: boolean;
isAIGenerated?: boolean;
}
interface Client360Snapshot {
client_ref?: string;
identity?: {
person_id?: string;
full_name?: string;
primary_phone?: string | null;
primary_email?: string | null;
buyer_type?: string | null;
persona_labels?: string[];
};
current_lead?: {
status?: string | null;
budget_band?: string | null;
urgency?: string | null;
objections?: string[];
motivations?: string[];
};
active_opportunities?: Array<{
opportunity_id?: string;
stage?: string | null;
value?: number | null;
probability?: number | null;
expected_close_date?: string | null;
next_action?: string | null;
}>;
recent_interactions?: Array<{
interaction_id?: string;
channel?: string | null;
interaction_type?: string | null;
happened_at?: string | null;
summary?: string | null;
}>;
property_interests?: Array<{
interest_id?: string;
project_name?: string | null;
unit_preference?: string | null;
configuration?: string | null;
budget_min?: number | null;
budget_max?: number | null;
priority?: number | null;
}>;
tasks?: Array<{
reminder_id?: string;
title?: string | null;
notes?: string | null;
due_at?: string | null;
status?: string | null;
created_by_type?: string | null;
}>;
qd_overview?: Record<string, { current_value?: number | null; computed_at?: string | null; reasoning?: string | null }>;
risk_flags?: string[];
recommended_next_actions?: string[];
}
function normalizeSnapshot(payload: unknown): Client360Snapshot {
const snapshot = unwrapObject<Client360Snapshot>(payload);
if (!snapshot) return {};
return {
...snapshot,
identity: snapshot.identity ?? {},
current_lead: snapshot.current_lead ?? {},
active_opportunities: stableArray(snapshot.active_opportunities),
recent_interactions: stableArray(snapshot.recent_interactions),
property_interests: stableArray(snapshot.property_interests),
tasks: stableArray(snapshot.tasks),
risk_flags: stableArray(snapshot.risk_flags),
recommended_next_actions: stableArray(snapshot.recommended_next_actions),
};
}
function scoreToPercent(value: number | null | undefined): number {
const safe = Number(value ?? 0);
const pct = safe <= 1 ? safe * 100 : safe;
return Math.max(0, Math.min(100, Math.round(pct)));
}
function bestQdScore(snapshot: Client360Snapshot): number {
const qd = snapshot.qd_overview ?? {};
const preferred = qd.intent_score ?? qd.engagement_score ?? qd.financial_qualification;
if (preferred?.current_value != null) return scoreToPercent(preferred.current_value);
const first = Object.values(qd).find((item) => item?.current_value != null);
return scoreToPercent(first?.current_value);
}
function stageLabel(status?: string | null): string {
return (status || 'new').replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}
function stageEmoji(status?: string | null): string {
if (!status) return 'N';
return status.slice(0, 1).toUpperCase();
}
function relativeDate(value?: string | null): string {
if (!value) return 'No contact yet';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return 'Recently';
const diffMs = Date.now() - date.getTime();
const days = Math.floor(diffMs / 86_400_000);
if (days > 0) return `${days}d ago`;
const hours = Math.floor(diffMs / 3_600_000);
if (hours > 0) return `${hours}h ago`;
return 'Just now';
}
function mapClient360(snapshot: Client360Snapshot): Client360Data {
const identity = snapshot.identity ?? {};
const lead = snapshot.current_lead ?? {};
const interactions = snapshot.recent_interactions ?? [];
const last = interactions[0];
const qdScore = bestQdScore(snapshot);
const facts: Record<string, string> = {};
if (lead.budget_band) facts.budget = lead.budget_band;
if (lead.urgency) facts.timeline = lead.urgency;
if (identity.buyer_type) facts.buyerType = identity.buyer_type;
if (identity.persona_labels?.length) facts.persona = identity.persona_labels.join(', ');
return {
id: identity.person_id || snapshot.client_ref || '',
name: identity.full_name || 'Unknown Client',
location: snapshot.property_interests?.[0]?.project_name || lead.budget_band || undefined,
primaryPhone: identity.primary_phone || undefined,
qdScore,
qdDelta: 0,
stageName: stageLabel(lead.status),
stageEmoji: stageEmoji(lead.status),
lastContactRelative: relativeDate(last?.happened_at),
lastContactChannel: last?.channel || 'crm',
aiInsight: snapshot.recommended_next_actions?.[0] || snapshot.active_opportunities?.[0]?.next_action || undefined,
extractedFacts: facts,
objections: [...(lead.objections ?? []), ...(snapshot.risk_flags ?? [])],
qdHistory: Object.entries(snapshot.qd_overview ?? {}).map(([label, item]) => ({
date: item.computed_at || new Date().toISOString(),
score: scoreToPercent(item.current_value),
label: label.replace(/_/g, ' '),
})),
};
}
function mapConversationEvents(snapshot: Client360Snapshot): ConversationEvent[] {
return (snapshot.recent_interactions ?? []).map((event, index) => {
const type = event.channel === 'email' ? 'email' : event.channel === 'call' || event.channel === 'phone_call' ? 'call' : 'whatsapp';
return {
id: event.interaction_id || `${snapshot.client_ref || 'client'}-${index}`,
type,
timestamp: event.happened_at || new Date().toISOString(),
timestampRelative: relativeDate(event.happened_at),
subject: event.interaction_type || undefined,
direction: 'outbound',
duration: undefined,
hasTranscript: false,
keyMoments: event.summary ? [event.summary] : [],
messages: type === 'whatsapp' ? [{ sender: 'client', text: event.summary || 'Interaction recorded in CRM.' }] : undefined,
};
});
}
function mapPropertyInterests(snapshot: Client360Snapshot): PropertyInterest[] {
return (snapshot.property_interests ?? []).map((item, index) => ({
id: item.interest_id || `${snapshot.client_ref || 'property'}-${index}`,
projectName: item.project_name || 'Unknown Project',
unitName: item.unit_preference || 'Preferred Unit',
config: item.configuration || 'Configuration not captured',
area: 'Area TBD',
price: item.budget_min != null && item.budget_max != null ? `${item.budget_min}Cr - ₹${item.budget_max}Cr` : 'Budget TBD',
isPrimary: index === 0 || item.priority === 1,
engagementLevel: index === 0 ? 'High' : 'Medium',
}));
}
function mapTasks(snapshot: Client360Snapshot): Task[] {
return (snapshot.tasks ?? []).map((task, index) => ({
id: task.reminder_id || `${snapshot.client_ref || 'task'}-${index}`,
label: task.title || task.notes || 'Follow up with client',
dueAt: task.due_at || undefined,
status: task.status === 'done' ? 'done' : task.status === 'snoozed' ? 'snoozed' : 'pending',
isDueToday: task.due_at ? new Date(task.due_at).toDateString() === new Date().toDateString() : false,
isAIGenerated: task.created_by_type === 'ai',
}));
}