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.thumbnailUrl ? (
+

+ ) : (
+
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',
+ }));
+}