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 ────────────────────────────────────────────────────
|
||||
|
||||
@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"])
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
}));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user