From 08a19db035cdc46ca05a19355a196a784b5f3e89 Mon Sep 17 00:00:00 2001 From: Sagnik Ghosh Date: Sat, 2 May 2026 12:13:09 +0530 Subject: [PATCH] fix: restore Velocity OS entity and pillar views --- core/api/api/routes_crm_imports.py | 31 +++ webos/src/control-room/ControlRoom.module.css | 8 +- webos/src/design-system/tokens.css | 22 ++ .../pipeline/PipelinePillar.module.css | 12 ++ webos/src/pillars/pipeline/PipelinePillar.tsx | 100 ++++++--- .../pillars/studio/StudioPillar.module.css | 14 +- webos/src/pillars/studio/StudioPillar.tsx | 145 ++++++++++--- webos/src/shared/hooks/useClient360.ts | 190 +++++++++++++++++- 8 files changed, 455 insertions(+), 67 deletions(-) diff --git a/core/api/api/routes_crm_imports.py b/core/api/api/routes_crm_imports.py index e31bd5a..0c63b41 100644 --- a/core/api/api/routes_crm_imports.py +++ b/core/api/api/routes_crm_imports.py @@ -740,6 +740,7 @@ async def get_contact( # ── Client 360 Endpoint ──────────────────────────────────────────────────── @router.get("/crm/client-360/{person_id}", tags=["CRM Client 360"]) +@router.get("/crm/leads/{person_id}/360", tags=["CRM Client 360"]) async def client_360( request: Request, person_id: str, @@ -758,6 +759,36 @@ async def client_360( return {"status": "ok", "data": snapshot} +@router.get("/crm/leads/{person_id}/properties", tags=["CRM Client 360"]) +async def legacy_lead_properties( + request: Request, + person_id: str, + user: UserPrincipal = Depends(get_current_user), +) -> Any: + """Compatibility read for older WebOS builds that requested lead properties directly.""" + pool = await _get_pool(request) + async with pool.acquire() as conn: + snapshot = await get_client_360(conn, _tenant_scope(user), person_id) + if not snapshot: + raise HTTPException(status_code=404, detail=f"Client '{person_id}' not found.") + return snapshot.get("property_interests", []) + + +@router.get("/crm/leads/{person_id}/tasks", tags=["CRM Client 360"]) +async def legacy_lead_tasks( + request: Request, + person_id: str, + user: UserPrincipal = Depends(get_current_user), +) -> Any: + """Compatibility read for older WebOS builds that requested lead tasks directly.""" + pool = await _get_pool(request) + async with pool.acquire() as conn: + snapshot = await get_client_360(conn, _tenant_scope(user), person_id) + if not snapshot: + raise HTTPException(status_code=404, detail=f"Client '{person_id}' not found.") + return snapshot.get("tasks", []) + + # ── Opportunities Endpoint ───────────────────────────────────────────────── @router.get("/crm/opportunities", tags=["CRM Opportunities"]) diff --git a/webos/src/control-room/ControlRoom.module.css b/webos/src/control-room/ControlRoom.module.css index 08aede6..4858b20 100644 --- a/webos/src/control-room/ControlRoom.module.css +++ b/webos/src/control-room/ControlRoom.module.css @@ -1,20 +1,20 @@ /* ControlRoom */ -.root { display: flex; flex-direction: column; min-height: 100vh; height: 100vh; overflow: hidden; background: radial-gradient(circle at 16% 10%, rgba(124,58,237,0.22), transparent 34%), radial-gradient(circle at 76% 12%, rgba(59,130,246,0.14), transparent 30%), var(--color-bg-primary); color: var(--color-text-primary); } -.header { display: flex; align-items: center; justify-content: space-between; padding: var(--space-5) var(--space-8); border-bottom: var(--glass-border); background: rgba(9, 13, 24, 0.72); backdrop-filter: blur(22px); flex-shrink: 0; } +.root { display: flex; flex-direction: column; min-height: 100vh; height: 100vh; overflow: hidden; background: radial-gradient(circle at 16% 10%, rgba(124,58,237,0.18), transparent 34%), radial-gradient(circle at 76% 12%, rgba(59,130,246,0.10), transparent 30%), var(--color-base-bg); color: var(--color-text-primary); } +.header { display: flex; align-items: center; justify-content: space-between; padding: var(--space-5) var(--space-8); border-bottom: var(--glass-border); background: rgba(13, 17, 28, 0.86); backdrop-filter: blur(22px); flex-shrink: 0; } .headerLeft { display: flex; align-items: center; gap: var(--space-4); } .headerIcon { font-size: 28px; color: var(--color-text-tertiary); } .title { font-size: var(--text-xl); font-weight: var(--font-bold); color: var(--color-text-primary); margin: 0; } .subtitle { font-size: var(--text-xs); color: var(--color-text-tertiary); margin: 0; } .body { display: flex; flex: 1; overflow: hidden; } /* Sidebar */ -.sidebar { width: 220px; border-right: var(--glass-border); display: flex; flex-direction: column; padding: var(--space-4); gap: var(--space-1); flex-shrink: 0; background: rgba(7, 11, 20, 0.62); } +.sidebar { width: 220px; border-right: var(--glass-border); display: flex; flex-direction: column; padding: var(--space-4); gap: var(--space-1); flex-shrink: 0; background: rgba(10, 14, 24, 0.78); } .sideItem { position: relative; display: flex; align-items: center; gap: var(--space-3); padding: var(--space-3); border-radius: var(--radius-md); background: none; border: none; cursor: pointer; font-family: var(--font-sans); font-size: var(--text-sm); color: var(--color-text-secondary); text-align: left; transition: all var(--duration-fast) var(--ease-standard); width: 100%; } .sideItem:hover { background: var(--glass-bg); color: var(--color-text-primary); } .sideActive { color: var(--color-text-primary) !important; background: var(--glass-bg); } .sideIcon { font-size: 14px; flex-shrink: 0; } .indicator { position: absolute; left: 0; top: 20%; bottom: 20%; width: 2px; background: var(--color-violet); border-radius: 1px; } /* Content */ -.content { flex: 1; overflow-y: auto; padding: var(--space-8); background: linear-gradient(135deg, rgba(15,23,42,0.48), rgba(2,6,23,0.88)); } +.content { flex: 1; overflow-y: auto; padding: var(--space-8); background: radial-gradient(circle at 12% 12%, rgba(124,58,237,0.10), transparent 28%), linear-gradient(135deg, rgba(15,23,42,0.22), rgba(2,6,23,0.52)), var(--color-base-bg); } .panelWrap { display: flex; flex-direction: column; gap: 0; } /* Panel */ .panel { display: flex; flex-direction: column; gap: var(--space-6); max-width: 800px; } diff --git a/webos/src/design-system/tokens.css b/webos/src/design-system/tokens.css index ec756b6..018fcb0 100644 --- a/webos/src/design-system/tokens.css +++ b/webos/src/design-system/tokens.css @@ -3,12 +3,34 @@ All CSS custom properties. Import this first in main.css. ============================================================ */ +* { + box-sizing: border-box; +} + +html, +body, +#root { + width: 100%; + min-height: 100%; + margin: 0; + background: var(--color-base-bg); + color: var(--color-text-primary); +} + body { + overflow: hidden; font-family: var(--font-sans); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } +button, +input, +textarea, +select { + font: inherit; +} + :root { /* ── Color: Base Palette ─────────────────────────────────── */ --color-base-bg: hsl(225, 25%, 8%); /* Deep navy */ diff --git a/webos/src/pillars/pipeline/PipelinePillar.module.css b/webos/src/pillars/pipeline/PipelinePillar.module.css index f0fa386..3c68071 100644 --- a/webos/src/pillars/pipeline/PipelinePillar.module.css +++ b/webos/src/pillars/pipeline/PipelinePillar.module.css @@ -24,6 +24,18 @@ .qdWrap { flex-shrink: 0; } .cardBottom { display: flex; align-items: center; justify-content: space-between; } .lastContact { font-size: 10px; color: var(--color-text-tertiary); } +/* List */ +.list { display: flex; flex-direction: column; gap: var(--space-2); padding: 0 var(--space-8) var(--space-8); overflow-y: auto; flex: 1; } +.listHeader, .listRow { display: grid; grid-template-columns: minmax(280px, 1.3fr) minmax(180px, 0.8fr) 80px minmax(180px, 0.8fr); gap: var(--space-4); align-items: center; } +.listHeader { padding: var(--space-2) var(--space-4); font-size: 10px; font-weight: var(--font-semibold); letter-spacing: var(--tracking-wider); text-transform: uppercase; color: var(--color-text-tertiary); } +.listRow { width: 100%; min-height: 72px; padding: var(--space-3) var(--space-4); border: var(--glass-border); border-radius: var(--radius-lg); background: var(--glass-bg); color: var(--color-text-primary); text-align: left; cursor: pointer; transition: border-color var(--duration-fast) var(--ease-standard), background var(--duration-fast) var(--ease-standard), transform var(--duration-fast) var(--ease-standard); } +.listRow:hover { background: var(--glass-bg-hover); border-color: rgba(124, 58, 237, 0.38); transform: translateY(-1px); } +.listIdentity { display: flex; align-items: center; gap: var(--space-3); min-width: 0; } +.listName { display: block; font-size: var(--text-sm); font-weight: var(--font-semibold); color: var(--color-text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.listMeta { display: block; font-size: var(--text-xs); color: var(--color-text-tertiary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.stagePill { display: inline-flex; align-items: center; gap: var(--space-2); width: fit-content; padding: var(--space-1) var(--space-3); border-radius: var(--radius-full); background: rgba(124, 58, 237, 0.14); border: 1px solid rgba(124, 58, 237, 0.28); color: var(--color-violet-light); font-size: var(--text-xs); font-weight: var(--font-semibold); } +.scoreCell { width: 44px; height: 32px; border-radius: var(--radius-full); display: inline-flex; align-items: center; justify-content: center; background: rgba(16, 185, 129, 0.12); border: 1px solid rgba(16, 185, 129, 0.28); color: var(--color-green); font-size: var(--text-xs); font-weight: var(--font-bold); } +.emptyList { padding: var(--space-8); border: var(--glass-border); border-radius: var(--radius-lg); background: var(--glass-bg); color: var(--color-text-secondary); text-align: center; } /* Skeletons */ .skeletonHeader { height: 32px; border-radius: var(--radius-md); margin-bottom: var(--space-2); } .skeletonCard { height: 80px; border-radius: var(--radius-lg); margin-bottom: var(--space-2); } diff --git a/webos/src/pillars/pipeline/PipelinePillar.tsx b/webos/src/pillars/pipeline/PipelinePillar.tsx index 4b00b3b..b5acf26 100644 --- a/webos/src/pillars/pipeline/PipelinePillar.tsx +++ b/webos/src/pillars/pipeline/PipelinePillar.tsx @@ -1,24 +1,16 @@ import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { motion, AnimatePresence } from 'framer-motion'; +import { motion } from 'framer-motion'; import { QDRing } from './client360/QDRing'; import { useKanban } from '../../shared/hooks/useKanban'; import styles from './PipelinePillar.module.css'; -/** - * PipelinePillar — Pillar 2: Deal Intelligence - * Merges: CRM + Comms + Sentinel - * Default view: KanbanBoard of lead cards by pipeline stage. - * Tapping a card → /pipeline/:personId → Client360 (no full-page nav, - * depth choreography handled by AuthenticatedShell). - */ export default function PipelinePillar() { const [viewMode, setViewMode] = useState<'board' | 'list'>('board'); const { stages, isLoading } = useKanban(); return (
- {/* Header */}

Pipeline

@@ -29,24 +21,30 @@ export default function PipelinePillar() { className={viewMode === 'board' ? styles.toggleActive : styles.toggleBtn} onClick={() => setViewMode('board')} aria-pressed={viewMode === 'board'} - >⊞ Board + > + Board + + > + List +
- {/* Board */} - {isLoading ? : ( + {isLoading ? ( + + ) : viewMode === 'board' ? ( + ) : ( + )}
); } -// ── KanbanBoard ─────────────────────────────────────────────── interface Stage { id: string; label: string; @@ -62,7 +60,7 @@ interface Lead { qdDelta?: number; lastContactRelative: string; lastContactChannel: string; - isVaultActive?: boolean; // brochure currently being reviewed + isVaultActive?: boolean; } function KanbanBoard({ stages }: { stages: Stage[] }) { @@ -76,14 +74,12 @@ function KanbanBoard({ stages }: { stages: Stage[] }) { animate={{ opacity: 1, y: 0 }} transition={{ delay: si * 0.06, duration: 0.35, ease: [0.4, 0, 0.2, 1] }} > - {/* Column header */}
{stage.emoji} {stage.label} {stage.leads.length}
- {/* Lead cards */}
{stage.leads.map((lead, li) => ( @@ -95,9 +91,58 @@ function KanbanBoard({ stages }: { stages: Stage[] }) { ); } +function PipelineList({ stages }: { stages: Stage[] }) { + const navigate = useNavigate(); + const leads = stages.flatMap((stage) => + stage.leads.map((lead) => ({ + ...lead, + stageLabel: stage.label, + stageEmoji: stage.emoji, + })), + ); + + return ( +
+
+ Client + Stage + QD + Last contact +
+ {leads.map((lead, index) => ( + navigate(`/pipeline/${lead.id}`)} + initial={{ opacity: 0, y: 10 }} + animate={{ opacity: 1, y: 0 }} + transition={{ delay: index * 0.015, duration: 0.22 }} + aria-label={`View ${lead.name}'s profile`} + > + + {lead.name.slice(0, 1)} + + {lead.name} + {lead.location || 'No project captured'} + + + + {lead.stageEmoji} + {lead.stageLabel} + + {Math.round(lead.qdScore)} + + {lead.lastContactRelative} - {lead.lastContactChannel} + + + ))} + {leads.length === 0 &&
No pipeline leads found.
} +
+ ); +} + function LeadCard({ lead, delay }: { lead: Lead; delay: number }) { const navigate = useNavigate(); - const isHighIntent = lead.qdScore >= 70; const isVaultLive = lead.isVaultActive; return ( @@ -108,10 +153,7 @@ function LeadCard({ lead, delay }: { lead: Lead; delay: number }) { animate={{ opacity: 1, scale: 1, - // Amber glow on vault engagement - boxShadow: isVaultLive - ? '0 0 16px rgba(245,158,11,0.40)' - : 'var(--glass-shadow)', + boxShadow: isVaultLive ? '0 0 16px rgba(245,158,11,0.40)' : 'var(--glass-shadow)', }} transition={{ delay, duration: 0.3 }} whileHover={{ scale: 1.02, y: -2 }} @@ -119,18 +161,13 @@ function LeadCard({ lead, delay }: { lead: Lead; delay: number }) { aria-label={`View ${lead.name}'s profile`} >
- {/* Avatar initial */}
{lead.name.slice(0, 1)}
- {/* Identity */}
{lead.name} - {lead.location && ( - {lead.location} - )} + {lead.location && {lead.location}}
- {/* QD Ring */}
@@ -138,9 +175,8 @@ function LeadCard({ lead, delay }: { lead: Lead; delay: number }) {
- {lead.lastContactRelative} · {lead.lastContactChannel} + {lead.lastContactRelative} - {lead.lastContactChannel} - {/* Vault live indicator */} {isVaultLive && ( @@ -155,10 +191,10 @@ function LeadCard({ lead, delay }: { lead: Lead; delay: number }) { function KanbanSkeleton() { return (
- {[0, 1, 2, 3].map(i => ( + {[0, 1, 2, 3].map((i) => (
- {[0, 1].map(j => ( + {[0, 1].map((j) => (
))}
diff --git a/webos/src/pillars/studio/StudioPillar.module.css b/webos/src/pillars/studio/StudioPillar.module.css index 953bd54..adb3fb3 100644 --- a/webos/src/pillars/studio/StudioPillar.module.css +++ b/webos/src/pillars/studio/StudioPillar.module.css @@ -19,4 +19,16 @@ .propName { font-size: var(--text-sm); font-weight: var(--font-semibold); color: var(--color-text-primary); } .propSub { font-size: 10px; color: var(--color-text-tertiary); } .propPrice { font-size: var(--text-xs); color: var(--color-violet-light); font-weight: var(--font-medium); } -.campaignsPlaceholder { padding: var(--space-8); } +.campaignGrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: var(--space-4); padding: 0 var(--space-8) var(--space-8); overflow-y: auto; flex: 1; } +.campaignSkeleton { min-height: 220px; border-radius: var(--radius-xl); } +.campaignCard { display: flex; flex-direction: column; gap: var(--space-5); min-height: 220px; } +.campaignTop { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-4); } +.campaignName { display: block; font-size: var(--text-base); font-weight: var(--font-bold); color: var(--color-text-primary); } +.campaignMeta { display: block; margin-top: var(--space-1); font-size: var(--text-xs); color: var(--color-text-tertiary); text-transform: uppercase; letter-spacing: var(--tracking-wide); } +.campaignStatus { padding: var(--space-1) var(--space-3); border-radius: var(--radius-full); background: rgba(16, 185, 129, 0.12); border: 1px solid rgba(16, 185, 129, 0.28); color: var(--color-green); font-size: 10px; font-weight: var(--font-semibold); text-transform: uppercase; } +.campaignStats { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-3); } +.campaignStat { display: flex; flex-direction: column; gap: 2px; padding: var(--space-3); border-radius: var(--radius-md); background: rgba(255, 255, 255, 0.035); border: var(--glass-border); } +.campaignStat small { font-size: 10px; color: var(--color-text-tertiary); text-transform: uppercase; letter-spacing: var(--tracking-wider); } +.campaignStat strong { font-size: var(--text-lg); color: var(--color-text-primary); } +.campaignFooter { margin-top: auto; padding-top: var(--space-3); border-top: var(--glass-border); color: var(--color-text-secondary); font-size: var(--text-xs); } +.emptyState { margin: 0 var(--space-8); padding: var(--space-8); border: var(--glass-border); border-radius: var(--radius-xl); background: var(--glass-bg); color: var(--color-text-secondary); } diff --git a/webos/src/pillars/studio/StudioPillar.tsx b/webos/src/pillars/studio/StudioPillar.tsx index c686f72..680f8a7 100644 --- a/webos/src/pillars/studio/StudioPillar.tsx +++ b/webos/src/pillars/studio/StudioPillar.tsx @@ -1,14 +1,11 @@ import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { motion } from 'framer-motion'; +import { useQuery } from '@tanstack/react-query'; +import { api } from '../../shared/lib/apiClient'; import { useStudioProperties } from '../../shared/hooks/useStudio'; import styles from './StudioPillar.module.css'; -/** - * StudioPillar — Pillar 3: Asset & Marketing Hub - * Merges: Inventory + Catalyst - * Two sections: Properties | Campaigns - */ export default function StudioPillar() { const [section, setSection] = useState<'properties' | 'campaigns'>('properties'); @@ -23,21 +20,24 @@ export default function StudioPillar() { + > + Properties + + > + Campaigns +
{section === 'properties' && } - {section === 'campaigns' && } + {section === 'campaigns' && }
); } -// ── Properties Section ──────────────────────────────────────── function PropertiesSection() { const navigate = useNavigate(); const { properties, isLoading } = useStudioProperties(); @@ -45,7 +45,7 @@ function PropertiesSection() { if (isLoading) { return (
- {[0,1,2,3,4,5].map(i => ( + {[0, 1, 2, 3, 4, 5].map((i) => (
))}
@@ -66,24 +66,20 @@ function PropertiesSection() { whileTap={{ scale: 0.99 }} aria-label={`View ${prop.name}`} > - {/* Property thumbnail */}
- {prop.thumbnailUrl - ? {prop.name} - :
🏗
- } + {prop.thumbnailUrl ? ( + {prop.name} + ) : ( +
Property
+ )} {prop.availableUnits !== undefined && ( - - {prop.availableUnits} available - + {prop.availableUnits} available )}
{prop.name} {prop.location} - {prop.priceRange && ( - {prop.priceRange} - )} + {prop.priceRange && {prop.priceRange}}
))} @@ -91,13 +87,110 @@ function PropertiesSection() { ); } -// ── Campaigns Section ───────────────────────────────────────── +interface CampaignSummary { + id: string; + name: string; + platform: string; + status: string; + budget: number; + spent: number; + impressions: number; + clicks: number; + conversions: number; + objective: string; + bid_strategy: string; +} + +interface CampaignsResponse { + status: string; + data: CampaignSummary[]; +} + function CampaignsSection() { + const { data, isLoading, error } = useQuery({ + queryKey: ['studio-campaigns'], + queryFn: async () => { + const response = await api.get('/catalyst/campaigns'); + return response.data ?? []; + }, + staleTime: 60_000, + }); + const campaigns = data ?? []; + + if (isLoading) { + return ( +
+ {[0, 1, 2].map((i) => ( +
+ ))} +
+ ); + } + + if (error) { + return ( +
+ Campaign data is unavailable. Check the Catalyst API route and Meta provider configuration. +
+ ); + } + + if (campaigns.length === 0) { + return
No campaigns found.
; + } + return ( -
-

- Campaigns loading — Meta Ads integration active. -

+
+ {campaigns.map((campaign, index) => ( + +
+
+ {campaign.name} + + {campaign.platform} - {campaign.objective} + +
+ {campaign.status} +
+
+ + + + + +
+
+ Bid strategy: {campaign.bid_strategy || 'provider default'} +
+
+ ))}
); } + +function Metric({ label, value }: { label: string; value: string }) { + return ( + + {label} + {value} + + ); +} + +function formatMoney(value: number): string { + return new Intl.NumberFormat('en-IN', { + style: 'currency', + currency: 'INR', + maximumFractionDigits: 0, + }).format(Number(value || 0)); +} + +function formatNumber(value: number): string { + return new Intl.NumberFormat('en-IN').format(Number(value || 0)); +} diff --git a/webos/src/shared/hooks/useClient360.ts b/webos/src/shared/hooks/useClient360.ts index d5d289a..03d7679 100644 --- a/webos/src/shared/hooks/useClient360.ts +++ b/webos/src/shared/hooks/useClient360.ts @@ -8,7 +8,10 @@ import { api } from '@/shared/lib/apiClient'; export function useClient360(personId: string) { const query = useQuery({ queryKey: ['client360', personId], - queryFn: () => api.get(`/crm/leads/${personId}/360`), + queryFn: async () => { + const payload = await api.get>(`/crm/client-360/${personId}`); + return mapClient360(payload.data); + }, staleTime: 30_000, enabled: !!personId, }); @@ -22,7 +25,10 @@ export function useConversations(personId: string) { const qc = useQueryClient(); const query = useQuery({ queryKey: ['conversations', personId], - queryFn: () => api.get(`/comms/threads/${personId}`), + queryFn: async () => { + const payload = await api.get>(`/crm/client-360/${personId}`); + return mapConversationEvents(payload.data); + }, staleTime: 10_000, enabled: !!personId, }); @@ -40,7 +46,10 @@ export function useConversations(personId: string) { export function useClientProperties(personId: string) { const query = useQuery({ queryKey: ['client-properties', personId], - queryFn: () => api.get(`/crm/leads/${personId}/properties`), + queryFn: async () => { + const payload = await api.get>(`/crm/client-360/${personId}`); + return mapPropertyInterests(payload.data); + }, staleTime: 60_000, enabled: !!personId, }); @@ -54,7 +63,10 @@ export function useClientTasks(personId: string) { const qc = useQueryClient(); const query = useQuery({ queryKey: ['client-tasks', personId], - queryFn: () => api.get(`/crm/leads/${personId}/tasks`), + queryFn: async () => { + const payload = await api.get>(`/crm/client-360/${personId}`); + return mapTasks(payload.data); + }, staleTime: 30_000, enabled: !!personId, }); @@ -131,3 +143,173 @@ interface Task { isDueToday?: boolean; isAIGenerated?: boolean; } + +interface Wrapped { + status: string; + data: T; +} + +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 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', + })); +}