forked from sagnik/Velocity-OS
fix: restore Velocity OS entity and pillar views
This commit is contained in:
@@ -740,6 +740,7 @@ async def get_contact(
|
|||||||
# ── Client 360 Endpoint ────────────────────────────────────────────────────
|
# ── Client 360 Endpoint ────────────────────────────────────────────────────
|
||||||
|
|
||||||
@router.get("/crm/client-360/{person_id}", tags=["CRM Client 360"])
|
@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(
|
async def client_360(
|
||||||
request: Request,
|
request: Request,
|
||||||
person_id: str,
|
person_id: str,
|
||||||
@@ -758,6 +759,36 @@ async def client_360(
|
|||||||
return {"status": "ok", "data": snapshot}
|
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 ─────────────────────────────────────────────────
|
# ── Opportunities Endpoint ─────────────────────────────────────────────────
|
||||||
|
|
||||||
@router.get("/crm/opportunities", tags=["CRM Opportunities"])
|
@router.get("/crm/opportunities", tags=["CRM Opportunities"])
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
/* ControlRoom */
|
/* 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); }
|
.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(9, 13, 24, 0.72); backdrop-filter: blur(22px); flex-shrink: 0; }
|
.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); }
|
.headerLeft { display: flex; align-items: center; gap: var(--space-4); }
|
||||||
.headerIcon { font-size: 28px; color: var(--color-text-tertiary); }
|
.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; }
|
.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; }
|
.subtitle { font-size: var(--text-xs); color: var(--color-text-tertiary); margin: 0; }
|
||||||
.body { display: flex; flex: 1; overflow: hidden; }
|
.body { display: flex; flex: 1; overflow: hidden; }
|
||||||
/* Sidebar */
|
/* 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 { 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); }
|
.sideItem:hover { background: var(--glass-bg); color: var(--color-text-primary); }
|
||||||
.sideActive { color: var(--color-text-primary) !important; background: var(--glass-bg); }
|
.sideActive { color: var(--color-text-primary) !important; background: var(--glass-bg); }
|
||||||
.sideIcon { font-size: 14px; flex-shrink: 0; }
|
.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; }
|
.indicator { position: absolute; left: 0; top: 20%; bottom: 20%; width: 2px; background: var(--color-violet); border-radius: 1px; }
|
||||||
/* Content */
|
/* 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; }
|
.panelWrap { display: flex; flex-direction: column; gap: 0; }
|
||||||
/* Panel */
|
/* Panel */
|
||||||
.panel { display: flex; flex-direction: column; gap: var(--space-6); max-width: 800px; }
|
.panel { display: flex; flex-direction: column; gap: var(--space-6); max-width: 800px; }
|
||||||
|
|||||||
@@ -3,12 +3,34 @@
|
|||||||
All CSS custom properties. Import this first in main.css.
|
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 {
|
body {
|
||||||
|
overflow: hidden;
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
/* ── Color: Base Palette ─────────────────────────────────── */
|
/* ── Color: Base Palette ─────────────────────────────────── */
|
||||||
--color-base-bg: hsl(225, 25%, 8%); /* Deep navy */
|
--color-base-bg: hsl(225, 25%, 8%); /* Deep navy */
|
||||||
|
|||||||
@@ -24,6 +24,18 @@
|
|||||||
.qdWrap { flex-shrink: 0; }
|
.qdWrap { flex-shrink: 0; }
|
||||||
.cardBottom { display: flex; align-items: center; justify-content: space-between; }
|
.cardBottom { display: flex; align-items: center; justify-content: space-between; }
|
||||||
.lastContact { font-size: 10px; color: var(--color-text-tertiary); }
|
.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 */
|
/* Skeletons */
|
||||||
.skeletonHeader { height: 32px; border-radius: var(--radius-md); margin-bottom: var(--space-2); }
|
.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); }
|
.skeletonCard { height: 80px; border-radius: var(--radius-lg); margin-bottom: var(--space-2); }
|
||||||
|
|||||||
@@ -1,24 +1,16 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { QDRing } from './client360/QDRing';
|
import { QDRing } from './client360/QDRing';
|
||||||
import { useKanban } from '../../shared/hooks/useKanban';
|
import { useKanban } from '../../shared/hooks/useKanban';
|
||||||
import styles from './PipelinePillar.module.css';
|
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() {
|
export default function PipelinePillar() {
|
||||||
const [viewMode, setViewMode] = useState<'board' | 'list'>('board');
|
const [viewMode, setViewMode] = useState<'board' | 'list'>('board');
|
||||||
const { stages, isLoading } = useKanban();
|
const { stages, isLoading } = useKanban();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.root}>
|
<div className={styles.root}>
|
||||||
{/* Header */}
|
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
<div>
|
<div>
|
||||||
<h1 className={styles.title}>Pipeline</h1>
|
<h1 className={styles.title}>Pipeline</h1>
|
||||||
@@ -29,24 +21,30 @@ export default function PipelinePillar() {
|
|||||||
className={viewMode === 'board' ? styles.toggleActive : styles.toggleBtn}
|
className={viewMode === 'board' ? styles.toggleActive : styles.toggleBtn}
|
||||||
onClick={() => setViewMode('board')}
|
onClick={() => setViewMode('board')}
|
||||||
aria-pressed={viewMode === 'board'}
|
aria-pressed={viewMode === 'board'}
|
||||||
>⊞ Board</button>
|
>
|
||||||
|
Board
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className={viewMode === 'list' ? styles.toggleActive : styles.toggleBtn}
|
className={viewMode === 'list' ? styles.toggleActive : styles.toggleBtn}
|
||||||
onClick={() => setViewMode('list')}
|
onClick={() => setViewMode('list')}
|
||||||
aria-pressed={viewMode === 'list'}
|
aria-pressed={viewMode === 'list'}
|
||||||
>≡ List</button>
|
>
|
||||||
|
List
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Board */}
|
{isLoading ? (
|
||||||
{isLoading ? <KanbanSkeleton /> : (
|
<KanbanSkeleton />
|
||||||
|
) : viewMode === 'board' ? (
|
||||||
<KanbanBoard stages={stages} />
|
<KanbanBoard stages={stages} />
|
||||||
|
) : (
|
||||||
|
<PipelineList stages={stages} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── KanbanBoard ───────────────────────────────────────────────
|
|
||||||
interface Stage {
|
interface Stage {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -62,7 +60,7 @@ interface Lead {
|
|||||||
qdDelta?: number;
|
qdDelta?: number;
|
||||||
lastContactRelative: string;
|
lastContactRelative: string;
|
||||||
lastContactChannel: string;
|
lastContactChannel: string;
|
||||||
isVaultActive?: boolean; // brochure currently being reviewed
|
isVaultActive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function KanbanBoard({ stages }: { stages: Stage[] }) {
|
function KanbanBoard({ stages }: { stages: Stage[] }) {
|
||||||
@@ -76,14 +74,12 @@ function KanbanBoard({ stages }: { stages: Stage[] }) {
|
|||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ delay: si * 0.06, duration: 0.35, ease: [0.4, 0, 0.2, 1] }}
|
transition={{ delay: si * 0.06, duration: 0.35, ease: [0.4, 0, 0.2, 1] }}
|
||||||
>
|
>
|
||||||
{/* Column header */}
|
|
||||||
<div className={styles.colHeader}>
|
<div className={styles.colHeader}>
|
||||||
<span>{stage.emoji}</span>
|
<span>{stage.emoji}</span>
|
||||||
<span className={styles.colLabel}>{stage.label}</span>
|
<span className={styles.colLabel}>{stage.label}</span>
|
||||||
<span className={styles.colCount}>{stage.leads.length}</span>
|
<span className={styles.colCount}>{stage.leads.length}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Lead cards */}
|
|
||||||
<div className={styles.cards}>
|
<div className={styles.cards}>
|
||||||
{stage.leads.map((lead, li) => (
|
{stage.leads.map((lead, li) => (
|
||||||
<LeadCard key={lead.id} lead={lead} delay={si * 0.06 + li * 0.04} />
|
<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 }) {
|
function LeadCard({ lead, delay }: { lead: Lead; delay: number }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const isHighIntent = lead.qdScore >= 70;
|
|
||||||
const isVaultLive = lead.isVaultActive;
|
const isVaultLive = lead.isVaultActive;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -108,10 +153,7 @@ function LeadCard({ lead, delay }: { lead: Lead; delay: number }) {
|
|||||||
animate={{
|
animate={{
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
scale: 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 }}
|
transition={{ delay, duration: 0.3 }}
|
||||||
whileHover={{ scale: 1.02, y: -2 }}
|
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`}
|
aria-label={`View ${lead.name}'s profile`}
|
||||||
>
|
>
|
||||||
<div className={styles.cardTop}>
|
<div className={styles.cardTop}>
|
||||||
{/* Avatar initial */}
|
|
||||||
<div className={styles.avatar}>
|
<div className={styles.avatar}>
|
||||||
<span>{lead.name.slice(0, 1)}</span>
|
<span>{lead.name.slice(0, 1)}</span>
|
||||||
</div>
|
</div>
|
||||||
{/* Identity */}
|
|
||||||
<div className={styles.identity}>
|
<div className={styles.identity}>
|
||||||
<span className={styles.cardName}>{lead.name}</span>
|
<span className={styles.cardName}>{lead.name}</span>
|
||||||
{lead.location && (
|
{lead.location && <span className={styles.cardMeta}>{lead.location}</span>}
|
||||||
<span className={styles.cardMeta}>{lead.location}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{/* QD Ring */}
|
|
||||||
<div className={styles.qdWrap}>
|
<div className={styles.qdWrap}>
|
||||||
<QDRing score={lead.qdScore} size={36} strokeWidth={3} />
|
<QDRing score={lead.qdScore} size={36} strokeWidth={3} />
|
||||||
</div>
|
</div>
|
||||||
@@ -138,9 +175,8 @@ function LeadCard({ lead, delay }: { lead: Lead; delay: number }) {
|
|||||||
|
|
||||||
<div className={styles.cardBottom}>
|
<div className={styles.cardBottom}>
|
||||||
<span className={styles.lastContact}>
|
<span className={styles.lastContact}>
|
||||||
{lead.lastContactRelative} · {lead.lastContactChannel}
|
{lead.lastContactRelative} - {lead.lastContactChannel}
|
||||||
</span>
|
</span>
|
||||||
{/* Vault live indicator */}
|
|
||||||
{isVaultLive && (
|
{isVaultLive && (
|
||||||
<span className="badge badge-high-intent" style={{ fontSize: '0.65rem' }}>
|
<span className="badge badge-high-intent" style={{ fontSize: '0.65rem' }}>
|
||||||
<span className="live-dot" style={{ width: 5, height: 5 }} />
|
<span className="live-dot" style={{ width: 5, height: 5 }} />
|
||||||
@@ -155,10 +191,10 @@ function LeadCard({ lead, delay }: { lead: Lead; delay: number }) {
|
|||||||
function KanbanSkeleton() {
|
function KanbanSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className={styles.board}>
|
<div className={styles.board}>
|
||||||
{[0, 1, 2, 3].map(i => (
|
{[0, 1, 2, 3].map((i) => (
|
||||||
<div key={i} className={styles.column}>
|
<div key={i} className={styles.column}>
|
||||||
<div className={`${styles.skeletonHeader} shimmer`} />
|
<div className={`${styles.skeletonHeader} shimmer`} />
|
||||||
{[0, 1].map(j => (
|
{[0, 1].map((j) => (
|
||||||
<div key={j} className={`${styles.skeletonCard} shimmer`} />
|
<div key={j} className={`${styles.skeletonCard} shimmer`} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,4 +19,16 @@
|
|||||||
.propName { font-size: var(--text-sm); font-weight: var(--font-semibold); color: var(--color-text-primary); }
|
.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); }
|
.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); }
|
.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); }
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { api } from '../../shared/lib/apiClient';
|
||||||
import { useStudioProperties } from '../../shared/hooks/useStudio';
|
import { useStudioProperties } from '../../shared/hooks/useStudio';
|
||||||
import styles from './StudioPillar.module.css';
|
import styles from './StudioPillar.module.css';
|
||||||
|
|
||||||
/**
|
|
||||||
* StudioPillar — Pillar 3: Asset & Marketing Hub
|
|
||||||
* Merges: Inventory + Catalyst
|
|
||||||
* Two sections: Properties | Campaigns
|
|
||||||
*/
|
|
||||||
export default function StudioPillar() {
|
export default function StudioPillar() {
|
||||||
const [section, setSection] = useState<'properties' | 'campaigns'>('properties');
|
const [section, setSection] = useState<'properties' | 'campaigns'>('properties');
|
||||||
|
|
||||||
@@ -23,21 +20,24 @@ export default function StudioPillar() {
|
|||||||
<button
|
<button
|
||||||
className={section === 'properties' ? styles.tabActive : styles.tab}
|
className={section === 'properties' ? styles.tabActive : styles.tab}
|
||||||
onClick={() => setSection('properties')}
|
onClick={() => setSection('properties')}
|
||||||
>Properties</button>
|
>
|
||||||
|
Properties
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className={section === 'campaigns' ? styles.tabActive : styles.tab}
|
className={section === 'campaigns' ? styles.tabActive : styles.tab}
|
||||||
onClick={() => setSection('campaigns')}
|
onClick={() => setSection('campaigns')}
|
||||||
>Campaigns</button>
|
>
|
||||||
|
Campaigns
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{section === 'properties' && <PropertiesSection />}
|
{section === 'properties' && <PropertiesSection />}
|
||||||
{section === 'campaigns' && <CampaignsSection />}
|
{section === 'campaigns' && <CampaignsSection />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Properties Section ────────────────────────────────────────
|
|
||||||
function PropertiesSection() {
|
function PropertiesSection() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { properties, isLoading } = useStudioProperties();
|
const { properties, isLoading } = useStudioProperties();
|
||||||
@@ -45,7 +45,7 @@ function PropertiesSection() {
|
|||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.propGrid}>
|
<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 key={i} className={`${styles.propSkeleton} shimmer`} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -66,24 +66,20 @@ function PropertiesSection() {
|
|||||||
whileTap={{ scale: 0.99 }}
|
whileTap={{ scale: 0.99 }}
|
||||||
aria-label={`View ${prop.name}`}
|
aria-label={`View ${prop.name}`}
|
||||||
>
|
>
|
||||||
{/* Property thumbnail */}
|
|
||||||
<div className={styles.propThumb}>
|
<div className={styles.propThumb}>
|
||||||
{prop.thumbnailUrl
|
{prop.thumbnailUrl ? (
|
||||||
? <img src={prop.thumbnailUrl} alt={prop.name} />
|
<img src={prop.thumbnailUrl} alt={prop.name} />
|
||||||
: <div className={styles.thumbPlaceholder}>🏗</div>
|
) : (
|
||||||
}
|
<div className={styles.thumbPlaceholder}>Property</div>
|
||||||
|
)}
|
||||||
{prop.availableUnits !== undefined && (
|
{prop.availableUnits !== undefined && (
|
||||||
<span className={styles.availBadge}>
|
<span className={styles.availBadge}>{prop.availableUnits} available</span>
|
||||||
{prop.availableUnits} available
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.propMeta}>
|
<div className={styles.propMeta}>
|
||||||
<span className={styles.propName}>{prop.name}</span>
|
<span className={styles.propName}>{prop.name}</span>
|
||||||
<span className={styles.propSub}>{prop.location}</span>
|
<span className={styles.propSub}>{prop.location}</span>
|
||||||
{prop.priceRange && (
|
{prop.priceRange && <span className={styles.propPrice}>{prop.priceRange}</span>}
|
||||||
<span className={styles.propPrice}>{prop.priceRange}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</motion.button>
|
</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() {
|
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 (
|
return (
|
||||||
<div className={styles.campaignsPlaceholder}>
|
<div className={styles.campaignGrid}>
|
||||||
<p style={{ color: 'var(--color-text-secondary)', fontSize: 'var(--text-sm)' }}>
|
{campaigns.map((campaign, index) => (
|
||||||
Campaigns loading — Meta Ads integration active.
|
<motion.article
|
||||||
</p>
|
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>
|
</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));
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ import { api } from '@/shared/lib/apiClient';
|
|||||||
export function useClient360(personId: string) {
|
export function useClient360(personId: string) {
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: ['client360', personId],
|
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,
|
staleTime: 30_000,
|
||||||
enabled: !!personId,
|
enabled: !!personId,
|
||||||
});
|
});
|
||||||
@@ -22,7 +25,10 @@ export function useConversations(personId: string) {
|
|||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: ['conversations', personId],
|
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,
|
staleTime: 10_000,
|
||||||
enabled: !!personId,
|
enabled: !!personId,
|
||||||
});
|
});
|
||||||
@@ -40,7 +46,10 @@ export function useConversations(personId: string) {
|
|||||||
export function useClientProperties(personId: string) {
|
export function useClientProperties(personId: string) {
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: ['client-properties', personId],
|
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,
|
staleTime: 60_000,
|
||||||
enabled: !!personId,
|
enabled: !!personId,
|
||||||
});
|
});
|
||||||
@@ -54,7 +63,10 @@ export function useClientTasks(personId: string) {
|
|||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: ['client-tasks', personId],
|
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,
|
staleTime: 30_000,
|
||||||
enabled: !!personId,
|
enabled: !!personId,
|
||||||
});
|
});
|
||||||
@@ -131,3 +143,173 @@ interface Task {
|
|||||||
isDueToday?: boolean;
|
isDueToday?: boolean;
|
||||||
isAIGenerated?: 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',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user