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(`/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(`/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(`/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(`/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; 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; risk_flags?: string[]; recommended_next_actions?: string[]; } function normalizeSnapshot(payload: unknown): Client360Snapshot { const snapshot = unwrapObject(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 = {}; 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', })); }