fix: restore Velocity OS entity and pillar views

This commit is contained in:
2026-05-02 12:13:09 +05:30
parent ce02ac958b
commit 08a19db035
8 changed files with 455 additions and 67 deletions

View File

@@ -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"])

View File

@@ -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; }

View File

@@ -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 */

View File

@@ -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); }

View File

@@ -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 (
<div className={styles.root}>
{/* Header */}
<div className={styles.header}>
<div>
<h1 className={styles.title}>Pipeline</h1>
@@ -29,24 +21,30 @@ export default function PipelinePillar() {
className={viewMode === 'board' ? styles.toggleActive : styles.toggleBtn}
onClick={() => setViewMode('board')}
aria-pressed={viewMode === 'board'}
> Board</button>
>
Board
</button>
<button
className={viewMode === 'list' ? styles.toggleActive : styles.toggleBtn}
onClick={() => setViewMode('list')}
aria-pressed={viewMode === 'list'}
> List</button>
>
List
</button>
</div>
</div>
{/* Board */}
{isLoading ? <KanbanSkeleton /> : (
{isLoading ? (
<KanbanSkeleton />
) : viewMode === 'board' ? (
<KanbanBoard stages={stages} />
) : (
<PipelineList stages={stages} />
)}
</div>
);
}
// ── 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 */}
<div className={styles.colHeader}>
<span>{stage.emoji}</span>
<span className={styles.colLabel}>{stage.label}</span>
<span className={styles.colCount}>{stage.leads.length}</span>
</div>
{/* Lead cards */}
<div className={styles.cards}>
{stage.leads.map((lead, li) => (
<LeadCard key={lead.id} lead={lead} delay={si * 0.06 + li * 0.04} />
@@ -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 (
<div className={styles.list} role="table" aria-label="Pipeline lead list">
<div className={styles.listHeader} role="row">
<span>Client</span>
<span>Stage</span>
<span>QD</span>
<span>Last contact</span>
</div>
{leads.map((lead, index) => (
<motion.button
key={lead.id}
className={styles.listRow}
onClick={() => 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`}
>
<span className={styles.listIdentity}>
<span className={styles.avatar}>{lead.name.slice(0, 1)}</span>
<span>
<span className={styles.listName}>{lead.name}</span>
<span className={styles.listMeta}>{lead.location || 'No project captured'}</span>
</span>
</span>
<span className={styles.stagePill}>
<span>{lead.stageEmoji}</span>
{lead.stageLabel}
</span>
<span className={styles.scoreCell}>{Math.round(lead.qdScore)}</span>
<span className={styles.listMeta}>
{lead.lastContactRelative} - {lead.lastContactChannel}
</span>
</motion.button>
))}
{leads.length === 0 && <div className={styles.emptyList}>No pipeline leads found.</div>}
</div>
);
}
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`}
>
<div className={styles.cardTop}>
{/* Avatar initial */}
<div className={styles.avatar}>
<span>{lead.name.slice(0, 1)}</span>
</div>
{/* Identity */}
<div className={styles.identity}>
<span className={styles.cardName}>{lead.name}</span>
{lead.location && (
<span className={styles.cardMeta}>{lead.location}</span>
)}
{lead.location && <span className={styles.cardMeta}>{lead.location}</span>}
</div>
{/* QD Ring */}
<div className={styles.qdWrap}>
<QDRing score={lead.qdScore} size={36} strokeWidth={3} />
</div>
@@ -138,9 +175,8 @@ function LeadCard({ lead, delay }: { lead: Lead; delay: number }) {
<div className={styles.cardBottom}>
<span className={styles.lastContact}>
{lead.lastContactRelative} · {lead.lastContactChannel}
{lead.lastContactRelative} - {lead.lastContactChannel}
</span>
{/* Vault live indicator */}
{isVaultLive && (
<span className="badge badge-high-intent" style={{ fontSize: '0.65rem' }}>
<span className="live-dot" style={{ width: 5, height: 5 }} />
@@ -155,10 +191,10 @@ function LeadCard({ lead, delay }: { lead: Lead; delay: number }) {
function KanbanSkeleton() {
return (
<div className={styles.board}>
{[0, 1, 2, 3].map(i => (
{[0, 1, 2, 3].map((i) => (
<div key={i} className={styles.column}>
<div className={`${styles.skeletonHeader} shimmer`} />
{[0, 1].map(j => (
{[0, 1].map((j) => (
<div key={j} className={`${styles.skeletonCard} shimmer`} />
))}
</div>

View File

@@ -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); }

View File

@@ -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() {
<button
className={section === 'properties' ? styles.tabActive : styles.tab}
onClick={() => setSection('properties')}
>Properties</button>
>
Properties
</button>
<button
className={section === 'campaigns' ? styles.tabActive : styles.tab}
onClick={() => setSection('campaigns')}
>Campaigns</button>
>
Campaigns
</button>
</div>
</div>
{section === 'properties' && <PropertiesSection />}
{section === 'campaigns' && <CampaignsSection />}
{section === 'campaigns' && <CampaignsSection />}
</div>
);
}
// ── Properties Section ────────────────────────────────────────
function PropertiesSection() {
const navigate = useNavigate();
const { properties, isLoading } = useStudioProperties();
@@ -45,7 +45,7 @@ function PropertiesSection() {
if (isLoading) {
return (
<div className={styles.propGrid}>
{[0,1,2,3,4,5].map(i => (
{[0, 1, 2, 3, 4, 5].map((i) => (
<div key={i} className={`${styles.propSkeleton} shimmer`} />
))}
</div>
@@ -66,24 +66,20 @@ function PropertiesSection() {
whileTap={{ scale: 0.99 }}
aria-label={`View ${prop.name}`}
>
{/* Property thumbnail */}
<div className={styles.propThumb}>
{prop.thumbnailUrl
? <img src={prop.thumbnailUrl} alt={prop.name} />
: <div className={styles.thumbPlaceholder}>🏗</div>
}
{prop.thumbnailUrl ? (
<img src={prop.thumbnailUrl} alt={prop.name} />
) : (
<div className={styles.thumbPlaceholder}>Property</div>
)}
{prop.availableUnits !== undefined && (
<span className={styles.availBadge}>
{prop.availableUnits} available
</span>
<span className={styles.availBadge}>{prop.availableUnits} available</span>
)}
</div>
<div className={styles.propMeta}>
<span className={styles.propName}>{prop.name}</span>
<span className={styles.propSub}>{prop.location}</span>
{prop.priceRange && (
<span className={styles.propPrice}>{prop.priceRange}</span>
)}
{prop.priceRange && <span className={styles.propPrice}>{prop.priceRange}</span>}
</div>
</motion.button>
))}
@@ -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<CampaignsResponse>('/catalyst/campaigns');
return response.data ?? [];
},
staleTime: 60_000,
});
const campaigns = data ?? [];
if (isLoading) {
return (
<div className={styles.campaignGrid}>
{[0, 1, 2].map((i) => (
<div key={i} className={`${styles.campaignSkeleton} shimmer`} />
))}
</div>
);
}
if (error) {
return (
<div className={styles.emptyState}>
Campaign data is unavailable. Check the Catalyst API route and Meta provider configuration.
</div>
);
}
if (campaigns.length === 0) {
return <div className={styles.emptyState}>No campaigns found.</div>;
}
return (
<div className={styles.campaignsPlaceholder}>
<p style={{ color: 'var(--color-text-secondary)', fontSize: 'var(--text-sm)' }}>
Campaigns loading Meta Ads integration active.
</p>
<div className={styles.campaignGrid}>
{campaigns.map((campaign, index) => (
<motion.article
key={campaign.id}
className={`${styles.campaignCard} glass-card`}
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.05, duration: 0.28 }}
>
<div className={styles.campaignTop}>
<div>
<span className={styles.campaignName}>{campaign.name}</span>
<span className={styles.campaignMeta}>
{campaign.platform} - {campaign.objective}
</span>
</div>
<span className={styles.campaignStatus}>{campaign.status}</span>
</div>
<div className={styles.campaignStats}>
<Metric label="Budget" value={formatMoney(campaign.budget)} />
<Metric label="Spent" value={formatMoney(campaign.spent)} />
<Metric label="Impressions" value={formatNumber(campaign.impressions)} />
<Metric label="Clicks" value={formatNumber(campaign.clicks)} />
<Metric label="Leads" value={formatNumber(campaign.conversions)} />
</div>
<div className={styles.campaignFooter}>
Bid strategy: {campaign.bid_strategy || 'provider default'}
</div>
</motion.article>
))}
</div>
);
}
function Metric({ label, value }: { label: string; value: string }) {
return (
<span className={styles.campaignStat}>
<small>{label}</small>
<strong>{value}</strong>
</span>
);
}
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));
}

View File

@@ -8,7 +8,10 @@ import { api } from '@/shared/lib/apiClient';
export function useClient360(personId: string) {
const query = useQuery({
queryKey: ['client360', personId],
queryFn: () => api.get<Client360Data>(`/crm/leads/${personId}/360`),
queryFn: async () => {
const payload = await api.get<Wrapped<Client360Snapshot>>(`/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<ConversationEvent[]>(`/comms/threads/${personId}`),
queryFn: async () => {
const payload = await api.get<Wrapped<Client360Snapshot>>(`/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<PropertyInterest[]>(`/crm/leads/${personId}/properties`),
queryFn: async () => {
const payload = await api.get<Wrapped<Client360Snapshot>>(`/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<Task[]>(`/crm/leads/${personId}/tasks`),
queryFn: async () => {
const payload = await api.get<Wrapped<Client360Snapshot>>(`/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<T> {
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<string, { current_value?: number | null; computed_at?: string | null; reasoning?: string | null }>;
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<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',
}));
}