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
332 lines
11 KiB
TypeScript
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',
|
|
}));
|
|
}
|