feat(crm): canonical crm and imported routes implementation

This commit is contained in:
Sagnik
2026-04-18 21:32:54 +05:30
parent 37c06de749
commit 954618c3ef
52 changed files with 80656 additions and 1 deletions

View File

@@ -8,6 +8,7 @@ import {
Sliders,
Megaphone,
Shield,
Users,
type LucideIcon,
} from 'lucide-react';
import { useStore } from '@/store/useStore';
@@ -22,6 +23,7 @@ const NAV_ICONS: Record<string, LucideIcon> = {
'/catalyst': Megaphone,
'/settings': Sliders,
'/admin': Shield,
'/crm': Users,
};
export function Sidebar() {

View File

@@ -0,0 +1,868 @@
// app/src/components/modules/CRM.tsx
// Top-level Founder CRM Module Shell
// Implements the CRM navigation frame and subpage routing defined in Doc 10
// Sub-pages: Contacts, Client 360, Opportunities, Tasks, Imports, Kanban
import { useState, useEffect, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Users, ClipboardList,
Upload, Layers, Search, RefreshCw, AlertCircle,
ChevronRight, Calendar, TrendingUp, Phone, Mail,
Target, Clock, UserCheck, X
} from 'lucide-react';
import { fetchContacts, fetchKanbanBoard, fetchTasks, fetchOpportunities } from '@/lib/crmApi';
import type {
CrmContactListItem, KanbanColumn, CrmTask, CrmOpportunityCard
} from '@/types/crmTypes';
// ── Sub-view type ─────────────────────────────────────────────────────────────
type CrmView = 'contacts' | 'kanban' | 'opportunities' | 'tasks' | 'imports' | 'client360';
// ── QD Score Bar ──────────────────────────────────────────────────────────────
function QdBar({ value, type }: { value: number; type: 'intent' | 'urgency' }) {
const pct = Math.round(value * 100);
const color = type === 'intent'
? pct > 70 ? '#22c55e' : pct > 40 ? '#f59e0b' : '#ef4444'
: pct > 70 ? '#ef4444' : pct > 40 ? '#f59e0b' : '#6b7280';
return (
<div className="flex items-center gap-2">
<div className="flex-1 h-1.5 rounded-full bg-white/10 overflow-hidden">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${pct}%` }}
transition={{ duration: 0.6, ease: 'easeOut' }}
className="h-full rounded-full"
style={{ background: color }}
/>
</div>
<span className="text-xs font-mono w-8 text-right" style={{ color }}>{pct}</span>
</div>
);
}
// ── Buyer Type Badge ──────────────────────────────────────────────────────────
function BuyerBadge({ type }: { type: string | null }) {
const map: Record<string, { label: string; color: string }> = {
high_intent: { label: 'High Intent', color: '#22c55e' },
slow_burn_investor: { label: 'Investor', color: '#6366f1' },
nri: { label: 'NRI', color: '#0ea5e9' },
family_decision_unit: { label: 'Family Unit', color: '#f59e0b' },
price_sensitive: { label: 'Price Sensitive',color: '#f97316' },
broker_referral: { label: 'Broker', color: '#a855f7' },
repeat_visitor: { label: 'Repeat', color: '#06b6d4' },
};
const cfg = type ? (map[type] ?? { label: type, color: '#6b7280' }) : { label: 'Unknown', color: '#3f3f46' };
return (
<span
className="text-xs px-2 py-0.5 rounded-full font-medium"
style={{ background: `${cfg.color}22`, color: cfg.color, border: `1px solid ${cfg.color}44` }}
>
{cfg.label}
</span>
);
}
// ── Status Badge ──────────────────────────────────────────────────────────────
function StatusBadge({ status }: { status: string | null }) {
const colMap: Record<string, string> = {
new: '#6b7280', contacted: '#3b82f6', qualified: '#06b6d4',
site_visit_scheduled: '#8b5cf6', site_visited: '#a855f7',
negotiation: '#f59e0b', booking_initiated: '#22c55e',
booked: '#16a34a', lost: '#ef4444', dormant: '#374151',
};
const s = status ?? 'new';
const color = colMap[s] ?? '#6b7280';
const label = s.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
return (
<span
className="text-xs px-2 py-0.5 rounded-full font-medium"
style={{ background: `${color}22`, color, border: `1px solid ${color}44` }}
>
{label}
</span>
);
}
// ── Contact List View ─────────────────────────────────────────────────────────
function ContactListView({ onSelectContact }: { onSelectContact: (id: string) => void }) {
const [contacts, setContacts] = useState<CrmContactListItem[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState('');
const [buyerFilter, setBuyerFilter] = useState('');
const [page, setPage] = useState(0);
const LIMIT = 30;
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const result = await fetchContacts({
search: search || undefined,
buyer_type: buyerFilter || undefined,
limit: LIMIT,
offset: page * LIMIT,
});
setContacts(result.contacts);
setTotal(result.total);
} catch (e) {
setError((e as Error).message);
} finally {
setLoading(false);
}
}, [search, buyerFilter, page]);
useEffect(() => { void load(); }, [load]);
return (
<div className="flex flex-col gap-4">
{/* Toolbar */}
<div className="flex items-center gap-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" />
<input
value={search}
onChange={e => { setSearch(e.target.value); setPage(0); }}
placeholder="Search name, email, phone…"
className="w-full pl-9 pr-4 py-2 rounded-xl text-sm text-white placeholder-zinc-500 outline-none focus:ring-1 focus:ring-white/20"
style={{ background: 'hsl(var(--surface))', border: '1px solid hsl(var(--border-subtle))' }}
/>
{search && (
<button onClick={() => { setSearch(''); setPage(0); }} className="absolute right-3 top-1/2 -translate-y-1/2">
<X className="w-3.5 h-3.5 text-zinc-500" />
</button>
)}
</div>
<select
value={buyerFilter}
onChange={e => { setBuyerFilter(e.target.value); setPage(0); }}
className="px-3 py-2 rounded-xl text-sm text-white outline-none"
style={{ background: 'hsl(var(--surface))', border: '1px solid hsl(var(--border-subtle))' }}
>
<option value="">All Types</option>
<option value="high_intent">High Intent</option>
<option value="slow_burn_investor">Investor</option>
<option value="nri">NRI</option>
<option value="family_decision_unit">Family Unit</option>
<option value="price_sensitive">Price Sensitive</option>
<option value="broker_referral">Broker</option>
<option value="repeat_visitor">Repeat Visitor</option>
</select>
<button
onClick={load}
className="p-2 rounded-xl hover:bg-white/5 text-zinc-400 hover:text-white transition-colors"
title="Refresh"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
</button>
<span className="text-xs text-zinc-500">{total.toLocaleString()} contacts</span>
</div>
{error && (
<div className="flex items-center gap-2 px-4 py-3 rounded-xl text-sm text-red-400 bg-red-500/10 border border-red-500/20">
<AlertCircle className="w-4 h-4 flex-none" />
{error}
</div>
)}
{/* Table */}
<div
className="rounded-2xl overflow-hidden"
style={{ background: 'hsl(var(--surface))', border: '1px solid hsl(var(--border-subtle))' }}
>
{/* Header */}
<div
className="grid text-xs text-zinc-500 font-medium px-4 py-3"
style={{
gridTemplateColumns: '2fr 1.5fr 1.2fr 1.2fr 1fr 0.8fr 0.8fr',
borderBottom: '1px solid hsl(var(--border-subtle))'
}}
>
<span>Name</span>
<span>Contact</span>
<span>Buyer Type</span>
<span>Lead Status</span>
<span>Intent</span>
<span>Interactions</span>
<span>Tasks</span>
</div>
{loading ? (
<div className="flex items-center justify-center h-48 text-zinc-500 text-sm">
<RefreshCw className="w-4 h-4 animate-spin mr-2" />
Loading contacts
</div>
) : contacts.length === 0 ? (
<div className="flex flex-col items-center justify-center h-48 gap-3 text-zinc-500">
<Users className="w-8 h-8 opacity-30" />
<p className="text-sm">No contacts found. Import a CSV or add manually.</p>
</div>
) : (
<div className="divide-y divide-white/5">
{contacts.map((c) => (
<motion.div
key={c.person_id}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
onClick={() => onSelectContact(c.person_id)}
className="grid items-center px-4 py-3 cursor-pointer hover:bg-white/[0.03] transition-colors"
style={{ gridTemplateColumns: '2fr 1.5fr 1.2fr 1.2fr 1fr 0.8fr 0.8fr' }}
>
<div className="flex items-center gap-3">
<div
className="w-8 h-8 rounded-xl flex items-center justify-center text-xs font-semibold flex-none"
style={{ background: 'hsl(var(--accent))', color: 'hsl(var(--accent-fg))' }}
>
{c.full_name.slice(0, 2).toUpperCase()}
</div>
<div>
<p className="text-sm font-medium text-white leading-tight">{c.full_name}</p>
{c.last_interaction_at && (
<p className="text-xs text-zinc-500 mt-0.5">
Last: {new Date(c.last_interaction_at).toLocaleDateString('en-IN', { day: 'numeric', month: 'short' })}
</p>
)}
</div>
</div>
<div className="min-w-0">
{c.primary_email && <p className="text-xs text-zinc-400 truncate">{c.primary_email}</p>}
{c.primary_phone && <p className="text-xs text-zinc-500">{c.primary_phone}</p>}
</div>
<div><BuyerBadge type={c.buyer_type} /></div>
<div><StatusBadge status={c.lead_status} /></div>
<div className="pr-4">
<QdBar value={c.intent_score} type="intent" />
</div>
<div className="text-center">
<span className="text-sm text-zinc-300">{c.interaction_count}</span>
</div>
<div className="text-center">
{c.pending_tasks > 0 ? (
<span className="text-xs px-2 py-0.5 rounded-full bg-amber-500/20 text-amber-400 border border-amber-500/30">
{c.pending_tasks}
</span>
) : (
<span className="text-zinc-600 text-xs"></span>
)}
</div>
</motion.div>
))}
</div>
)}
</div>
{/* Pagination */}
{total > LIMIT && (
<div className="flex items-center justify-between text-sm text-zinc-500">
<button
disabled={page === 0}
onClick={() => setPage(p => Math.max(0, p - 1))}
className="px-4 py-2 rounded-lg disabled:opacity-30 hover:text-white transition-colors"
>
Previous
</button>
<span>Page {page + 1} of {Math.ceil(total / LIMIT)}</span>
<button
disabled={(page + 1) * LIMIT >= total}
onClick={() => setPage(p => p + 1)}
className="px-4 py-2 rounded-lg disabled:opacity-30 hover:text-white transition-colors"
>
Next
</button>
</div>
)}
</div>
);
}
// ── Kanban View ───────────────────────────────────────────────────────────────
function KanbanView({ onSelectContact }: { onSelectContact: (id: string) => void }) {
const [board, setBoard] = useState<KanbanColumn[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setLoading(true);
fetchKanbanBoard()
.then(setBoard)
.catch(e => setError((e as Error).message))
.finally(() => setLoading(false));
}, []);
const activeColumns = board.filter(c => c.count > 0 || ['new', 'qualified', 'site_visited', 'negotiation', 'booked'].includes(c.status));
return (
<div className="flex flex-col gap-4">
{error && (
<div className="flex items-center gap-2 px-4 py-3 rounded-xl text-sm text-red-400 bg-red-500/10 border border-red-500/20">
<AlertCircle className="w-4 h-4" />{error}
</div>
)}
{loading ? (
<div className="flex items-center justify-center h-64 text-zinc-500 text-sm">
<RefreshCw className="w-4 h-4 animate-spin mr-2" /> Loading pipeline
</div>
) : (
<div className="flex gap-4 overflow-x-auto pb-4">
{activeColumns.map(col => (
<div
key={col.status}
className="flex-none w-[260px] rounded-2xl flex flex-col"
style={{ background: 'hsl(var(--surface))', border: '1px solid hsl(var(--border-subtle))' }}
>
<div className="px-4 py-3 flex items-center justify-between" style={{ borderBottom: '1px solid hsl(var(--border-subtle))' }}>
<span className="text-sm font-semibold text-white">{col.label}</span>
<span className="text-xs px-2 py-0.5 rounded-full bg-white/10 text-zinc-400">{col.count}</span>
</div>
<div className="flex-1 overflow-y-auto max-h-[500px] p-3 flex flex-col gap-2 custom-scrollbar">
{col.items.length === 0 ? (
<div className="text-center py-6 text-zinc-600 text-xs">No clients</div>
) : col.items.map(card => (
<motion.div
key={card.lead_id}
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
onClick={() => onSelectContact(card.person_id)}
className="p-3 rounded-xl cursor-pointer hover:bg-white/[0.06] transition-all"
style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.07)' }}
>
<div className="flex items-center gap-2 mb-2">
<div
className="w-7 h-7 rounded-lg flex items-center justify-center text-xs font-semibold flex-none"
style={{ background: 'hsl(var(--accent))', color: 'hsl(var(--accent-fg))' }}
>
{card.client_name.slice(0, 2).toUpperCase()}
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-white truncate">{card.client_name}</p>
{card.budget_band && <p className="text-xs text-zinc-500 truncate">{card.budget_band}</p>}
</div>
</div>
<div className="flex items-center justify-between mb-1.5">
<BuyerBadge type={card.buyer_type} />
{card.urgency && card.urgency !== 'low' && (
<span className="text-xs text-amber-400">{card.urgency}</span>
)}
</div>
<QdBar value={card.intent_score} type="intent" />
</motion.div>
))}
</div>
</div>
))}
</div>
)}
</div>
);
}
// ── Opportunities View ────────────────────────────────────────────────────────
function OpportunitiesView({ onSelectContact }: { onSelectContact: (id: string) => void }) {
const [opps, setOpps] = useState<CrmOpportunityCard[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchOpportunities({ limit: 100 })
.then(setOpps)
.finally(() => setLoading(false));
}, []);
const stageColor: Record<string, string> = {
prospect: '#6b7280', qualified: '#3b82f6', proposal: '#8b5cf6',
site_visit: '#06b6d4', negotiation: '#f59e0b', booking: '#22c55e',
agreement: '#16a34a', closed_won: '#166534', closed_lost: '#7f1d1d',
};
return (
<div className="flex flex-col gap-4">
{loading ? (
<div className="flex items-center justify-center h-64 text-zinc-500 text-sm">
<RefreshCw className="w-4 h-4 animate-spin mr-2" /> Loading pipeline
</div>
) : opps.length === 0 ? (
<div className="flex flex-col items-center justify-center h-48 gap-3 text-zinc-500">
<Target className="w-8 h-8 opacity-30" />
<p className="text-sm">No opportunities in the pipeline yet.</p>
</div>
) : (
<div
className="rounded-2xl overflow-hidden"
style={{ background: 'hsl(var(--surface))', border: '1px solid hsl(var(--border-subtle))' }}
>
<div
className="grid text-xs text-zinc-500 font-medium px-4 py-3"
style={{ gridTemplateColumns: '2fr 1.5fr 1.2fr 1fr 1fr 1.5fr', borderBottom: '1px solid hsl(var(--border-subtle))' }}
>
<span>Client</span>
<span>Project</span>
<span>Stage</span>
<span>Value</span>
<span>Probability</span>
<span>Next Action</span>
</div>
<div className="divide-y divide-white/5">
{opps.map(o => {
const color = stageColor[o.stage] ?? '#6b7280';
return (
<div
key={o.opportunity_id}
className="grid items-center px-4 py-3 cursor-pointer hover:bg-white/[0.03] transition-colors"
style={{ gridTemplateColumns: '2fr 1.5fr 1.2fr 1fr 1fr 1.5fr' }}
onClick={() => o.person_id && onSelectContact(o.person_id)}
>
<p className="text-sm text-white">{o.client_name ?? '—'}</p>
<p className="text-sm text-zinc-400 truncate">{o.project_name ?? '—'}</p>
<span
className="text-xs px-2 py-0.5 rounded-full w-fit"
style={{ background: `${color}22`, color, border: `1px solid ${color}44` }}
>
{o.stage.replace(/_/g, ' ')}
</span>
<p className="text-sm text-white">
{o.value ? `${(o.value / 1e7).toFixed(1)}Cr` : '—'}
</p>
<div className="flex items-center gap-1.5">
<div className="flex-1 h-1.5 rounded-full bg-white/10">
<div
className="h-full rounded-full"
style={{ width: `${o.probability ?? 0}%`, background: color }}
/>
</div>
<span className="text-xs text-zinc-400 w-8">{o.probability ?? 0}%</span>
</div>
<p className="text-xs text-zinc-400 truncate">{o.next_action ?? '—'}</p>
</div>
);
})}
</div>
</div>
)}
</div>
);
}
// ── Tasks View ────────────────────────────────────────────────────────────────
function TasksView({ onSelectContact }: { onSelectContact: (id: string) => void }) {
const [tasks, setTasks] = useState<CrmTask[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchTasks({ status: 'pending', limit: 100 })
.then(setTasks)
.finally(() => setLoading(false));
}, []);
const priorityColor: Record<string, string> = {
urgent: '#ef4444', high: '#f59e0b', normal: '#6b7280', low: '#374151',
};
return (
<div className="flex flex-col gap-4">
{loading ? (
<div className="flex items-center justify-center h-64 text-zinc-500 text-sm">
<RefreshCw className="w-4 h-4 animate-spin mr-2" /> Loading tasks
</div>
) : tasks.length === 0 ? (
<div className="flex flex-col items-center justify-center h-48 gap-3 text-zinc-500">
<ClipboardList className="w-8 h-8 opacity-30" />
<p className="text-sm">No pending tasks.</p>
</div>
) : (
<div className="flex flex-col gap-2">
{tasks.map(t => {
const color = priorityColor[t.priority] ?? '#6b7280';
const overdue = t.due_at && new Date(t.due_at) < new Date();
return (
<motion.div
key={t.reminder_id}
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
className="flex items-center gap-4 px-4 py-3 rounded-2xl cursor-pointer hover:bg-white/[0.03] transition-all"
style={{ background: 'hsl(var(--surface))', border: `1px solid ${overdue ? '#ef444444' : 'hsl(var(--border-subtle))'}` }}
onClick={() => t.person_id && onSelectContact(t.person_id)}
>
<div
className="w-2 h-2 rounded-full flex-none"
style={{ background: color }}
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white truncate">{t.title}</p>
<p className="text-xs text-zinc-500">{t.client_name} · {t.reminder_type.replace(/_/g, ' ')}</p>
</div>
<div className="text-right flex-none">
{t.due_at && (
<p className={`text-xs font-mono ${overdue ? 'text-red-400' : 'text-zinc-500'}`}>
{new Date(t.due_at).toLocaleDateString('en-IN', { day: 'numeric', month: 'short' })}
</p>
)}
<span
className="text-xs px-2 py-0.5 rounded-full"
style={{ background: `${color}22`, color }}
>
{t.priority}
</span>
</div>
</motion.div>
);
})}
</div>
)}
</div>
);
}
// ── Import View (lightweight inline) ────────────────────────────────────────
function ImportsView() {
return (
<div className="flex flex-col items-center justify-center h-64 gap-4 text-zinc-500">
<Upload className="w-10 h-10 opacity-30" />
<div className="text-center">
<p className="text-sm font-medium text-white mb-1">CRM CSV Import</p>
<p className="text-xs text-zinc-500 max-w-xs">
Upload any CRM CSV export. Velocity will auto-map columns, propose normalizations,
and queue them for your review before committing to canonical records.
</p>
</div>
<label className="px-5 py-2.5 rounded-xl text-sm font-medium cursor-pointer hover:opacity-90 transition-opacity"
style={{ background: 'hsl(var(--accent))', color: 'hsl(var(--accent-fg))' }}>
<input type="file" accept=".csv" className="hidden" onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
const { uploadCrmImport } = await import('@/lib/crmApi');
try {
const result = await uploadCrmImport(file);
alert(`✅ Imported: ${result.row_count} rows, ${result.proposals_created} proposals queued.\n${result.message}`);
} catch (err) {
alert(`❌ Import failed: ${(err as Error).message}`);
}
}} />
Choose CSV File
</label>
<p className="text-xs text-zinc-600">Supports exports from Salesforce, HubSpot, Excel, or any flat contact list</p>
</div>
);
}
// ── Client 360 Inline Panel ───────────────────────────────────────────────────
function Client360Panel({ personId, onClose }: { personId: string; onClose: () => void }) {
const [data, setData] = useState<import('@/types/crmTypes').Client360Snapshot | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setLoading(true);
setError(null);
import('@/lib/crmApi').then(({ fetchClient360 }) =>
fetchClient360(personId)
.then(setData)
.catch(e => setError((e as Error).message))
.finally(() => setLoading(false))
);
}, [personId]);
return (
<motion.div
initial={{ opacity: 0, x: 40 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 40 }}
transition={{ duration: 0.3 }}
className="fixed inset-y-0 right-0 w-[520px] z-50 flex flex-col shadow-2xl"
style={{ background: '#0a0b10', borderLeft: '1px solid hsl(var(--border-subtle))' }}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4" style={{ borderBottom: '1px solid hsl(var(--border-subtle))' }}>
<div className="flex items-center gap-3">
<UserCheck className="w-5 h-5 text-zinc-400" />
<span className="font-semibold text-white">Client 360</span>
</div>
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-white/5 text-zinc-500 hover:text-white transition-colors">
<X className="w-4 h-4" />
</button>
</div>
<div className="flex-1 overflow-y-auto custom-scrollbar p-6">
{loading && (
<div className="flex items-center justify-center h-48 text-zinc-500 text-sm">
<RefreshCw className="w-4 h-4 animate-spin mr-2" /> Loading dossier
</div>
)}
{error && (
<div className="flex items-center gap-2 px-4 py-3 rounded-xl text-sm text-red-400 bg-red-500/10">
<AlertCircle className="w-4 h-4" />{error}
</div>
)}
{data && (
<div className="flex flex-col gap-6">
{/* Identity */}
<div>
<div className="flex items-center gap-4 mb-3">
<div
className="w-12 h-12 rounded-2xl flex items-center justify-center text-lg font-bold"
style={{ background: 'hsl(var(--accent))', color: 'hsl(var(--accent-fg))' }}
>
{data.identity.full_name.slice(0, 2).toUpperCase()}
</div>
<div>
<h2 className="text-xl font-bold text-white">{data.identity.full_name}</h2>
<div className="flex items-center gap-2 mt-1">
<BuyerBadge type={data.identity.buyer_type} />
{data.current_lead && <StatusBadge status={data.current_lead.status} />}
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
{data.identity.primary_email && (
<div className="flex items-center gap-2 text-xs text-zinc-400">
<Mail className="w-3.5 h-3.5" />
<span className="truncate">{data.identity.primary_email}</span>
</div>
)}
{data.identity.primary_phone && (
<div className="flex items-center gap-2 text-xs text-zinc-400">
<Phone className="w-3.5 h-3.5" />
<span>{data.identity.primary_phone}</span>
</div>
)}
</div>
</div>
{/* QD Scores */}
{Object.keys(data.qd_overview).length > 0 && (
<Section title="Quantum Dynamics" icon={<TrendingUp className="w-4 h-4" />}>
<div className="flex flex-col gap-3">
{Object.entries(data.qd_overview).map(([type, score]) => (
<div key={type}>
<div className="flex justify-between text-xs text-zinc-500 mb-1">
<span>{type.replace(/_score$/, '').replace(/_/g, ' ')}</span>
<span className="text-zinc-400">{score.computed_at ? new Date(score.computed_at).toLocaleDateString('en-IN') : ''}</span>
</div>
<QdBar value={score.current_value} type={type.includes('urgency') ? 'urgency' : 'intent'} />
</div>
))}
</div>
</Section>
)}
{/* Property Interests */}
{data.property_interests.length > 0 && (
<Section title="Property Interests" icon={<Layers className="w-4 h-4" />}>
<div className="flex flex-col gap-2">
{data.property_interests.map(pi => (
<div key={pi.interest_id} className="flex items-center justify-between px-3 py-2 rounded-xl bg-white/[0.03]">
<div>
<p className="text-sm font-medium text-white">{pi.project_name}</p>
{pi.configuration && <p className="text-xs text-zinc-500">{pi.configuration}</p>}
</div>
{(pi.budget_min || pi.budget_max) && (
<p className="text-xs text-zinc-400">
{pi.budget_min ? (pi.budget_min / 1e7).toFixed(1) : '?'}{pi.budget_max ? (pi.budget_max / 1e7).toFixed(1) : '?'} Cr
</p>
)}
</div>
))}
</div>
</Section>
)}
{/* Active Opportunities */}
{data.active_opportunities.length > 0 && (
<Section title="Active Opportunities" icon={<Target className="w-4 h-4" />}>
<div className="flex flex-col gap-2">
{data.active_opportunities.map(o => (
<div key={o.opportunity_id} className="flex items-center justify-between px-3 py-2 rounded-xl bg-white/[0.03]">
<div>
<p className="text-sm text-white">{o.stage.replace(/_/g, ' ')}</p>
{o.next_action && <p className="text-xs text-zinc-500 mt-0.5">{o.next_action}</p>}
</div>
<div className="text-right">
{o.value && <p className="text-sm font-medium text-white">{(o.value / 1e7).toFixed(1)}Cr</p>}
{o.probability != null && <p className="text-xs text-zinc-500">{o.probability}% prob.</p>}
</div>
</div>
))}
</div>
</Section>
)}
{/* Recent Interactions */}
{data.recent_interactions.length > 0 && (
<Section title="Recent Interactions" icon={<Clock className="w-4 h-4" />}>
<div className="flex flex-col gap-2">
{data.recent_interactions.map(i => (
<div key={i.interaction_id} className="flex items-start gap-2 px-3 py-2 rounded-xl bg-white/[0.03]">
<span className="text-xs px-1.5 py-0.5 rounded bg-white/10 text-zinc-400 flex-none">{i.channel}</span>
<div className="min-w-0">
{i.summary && <p className="text-xs text-zinc-300 line-clamp-2">{i.summary}</p>}
{i.happened_at && (
<p className="text-xs text-zinc-600 mt-0.5">
{new Date(i.happened_at).toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: '2-digit' })}
</p>
)}
</div>
</div>
))}
</div>
</Section>
)}
{/* Pending Tasks */}
{data.tasks.length > 0 && (
<Section title="Pending Tasks" icon={<ClipboardList className="w-4 h-4" />}>
<div className="flex flex-col gap-2">
{data.tasks.map(t => (
<div key={t.reminder_id} className="flex items-center justify-between px-3 py-2 rounded-xl bg-white/[0.03]">
<p className="text-sm text-white truncate flex-1">{t.title}</p>
{t.due_at && (
<span className="text-xs text-zinc-500 ml-2 flex-none">
<Calendar className="w-3 h-3 inline mr-1" />
{new Date(t.due_at).toLocaleDateString('en-IN', { day: 'numeric', month: 'short' })}
</span>
)}
</div>
))}
</div>
</Section>
)}
{/* Risk Flags and Next Actions */}
{(data.risk_flags.length > 0 || data.recommended_next_actions.length > 0) && (
<Section title="Oracle Signals" icon={<AlertCircle className="w-4 h-4" />}>
{data.risk_flags.map(f => (
<div key={f} className="text-xs text-amber-400 flex items-center gap-1.5 mb-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-amber-400 flex-none" />
{f.replace(/_/g, ' ')}
</div>
))}
{data.recommended_next_actions.map((a, i) => (
<div key={i} className="text-xs text-zinc-300 flex items-start gap-1.5 mb-1.5">
<ChevronRight className="w-3 h-3 text-zinc-500 flex-none mt-0.5" />
{a}
</div>
))}
</Section>
)}
</div>
)}
</div>
</motion.div>
);
}
// ── Section wrapper ───────────────────────────────────────────────────────────
function Section({ title, icon, children }: { title: string; icon: React.ReactNode; children: React.ReactNode }) {
return (
<div>
<div className="flex items-center gap-2 mb-3">
<span className="text-zinc-500">{icon}</span>
<h3 className="text-xs font-semibold text-zinc-400 uppercase tracking-widest">{title}</h3>
</div>
{children}
</div>
);
}
// ── Nav Tab ───────────────────────────────────────────────────────────────────
function NavTab({
label, icon, active, count, onClick,
}: {
label: string; icon: React.ReactNode;
active: boolean; count?: number; onClick: () => void;
}) {
return (
<button
onClick={onClick}
className={`flex items-center gap-2.5 px-4 py-2.5 rounded-xl text-sm font-medium transition-all ${
active ? 'text-white' : 'text-zinc-500 hover:text-zinc-300'
}`}
style={active ? {
background: 'hsl(var(--accent))',
color: 'hsl(var(--accent-fg))',
boxShadow: '0 0 20px hsla(var(--accent), 0.3)',
} : {}}
>
{icon}
{label}
{count != null && count > 0 && (
<span
className="ml-0.5 text-xs px-1.5 py-0.5 rounded-full"
style={{ background: active ? 'rgba(255,255,255,0.2)' : 'rgba(255,255,255,0.1)' }}
>
{count}
</span>
)}
</button>
);
}
// ── Main CRM Component ────────────────────────────────────────────────────────
export function CRM() {
const [view, setView] = useState<CrmView>('contacts');
const [selectedPersonId, setSelectedPersonId] = useState<string | null>(null);
const handleSelectContact = (id: string) => {
setSelectedPersonId(id);
};
return (
<div className="relative h-full flex flex-col gap-6">
{/* Module Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold text-white tracking-tight">Client Intelligence</h2>
<p className="text-sm text-zinc-500 mt-0.5">Canonical CRM contacts, pipeline, interactions, and 360° dossiers</p>
</div>
<div className="flex items-center gap-2 text-xs text-zinc-600 px-3 py-1.5 rounded-lg"
style={{ background: 'hsl(var(--surface))', border: '1px solid hsl(var(--border-subtle))' }}>
<div className="w-1.5 h-1.5 rounded-full bg-emerald-400" />
Canonical Mode
</div>
</div>
{/* Navigation Tabs */}
<div className="flex items-center gap-1 flex-wrap">
<NavTab label="Contacts" icon={<Users className="w-4 h-4" />} active={view === 'contacts'} onClick={() => setView('contacts')} />
<NavTab label="Pipeline" icon={<Layers className="w-4 h-4" />} active={view === 'kanban'} onClick={() => setView('kanban')} />
<NavTab label="Deals" icon={<Target className="w-4 h-4" />} active={view === 'opportunities'} onClick={() => setView('opportunities')} />
<NavTab label="Tasks" icon={<ClipboardList className="w-4 h-4" />} active={view === 'tasks'} onClick={() => setView('tasks')} />
<NavTab label="Import" icon={<Upload className="w-4 h-4" />} active={view === 'imports'} onClick={() => setView('imports')} />
</div>
{/* View Content */}
<AnimatePresence mode="wait">
<motion.div
key={view}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
transition={{ duration: 0.2 }}
className="flex-1"
>
{view === 'contacts' && <ContactListView onSelectContact={handleSelectContact} />}
{view === 'kanban' && <KanbanView onSelectContact={handleSelectContact} />}
{view === 'opportunities' && <OpportunitiesView onSelectContact={handleSelectContact} />}
{view === 'tasks' && <TasksView onSelectContact={handleSelectContact} />}
{view === 'imports' && <ImportsView />}
</motion.div>
</AnimatePresence>
{/* Client 360 Slide-over Panel */}
<AnimatePresence>
{selectedPersonId && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setSelectedPersonId(null)}
className="fixed inset-0 bg-black/40 z-40 backdrop-blur-sm"
/>
<Client360Panel personId={selectedPersonId} onClose={() => setSelectedPersonId(null)} />
</>
)}
</AnimatePresence>
</div>
);
}