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

@@ -10,6 +10,7 @@ import { Sentinel } from '@/components/modules/Sentinel';
import { Inventory } from '@/components/modules/Inventory';
import { Settings } from '@/components/modules/Settings';
import { Catalyst } from '@/components/modules/Catalyst';
import { CRM } from '@/components/modules/CRM';
import { NotificationCenter } from '@/components/layout/NotificationCenter';
import { useCrmBootstrap } from '@/hooks/useCrmBootstrap';
import type { ModuleId } from '@/types';
@@ -51,6 +52,7 @@ export const MODULE_ROUTES: Array<{
{ id: 'inventory', path: '/inventory', title: 'Inventory', component: Inventory },
{ id: 'catalyst', path: '/catalyst', title: 'The Catalyst', component: Catalyst },
{ id: 'settings', path: '/settings', title: 'Settings', component: Settings },
{ id: 'crm', path: '/crm', title: 'CRM', component: CRM },
{ id: 'admin', path: '/admin', title: 'Admin', component: AdminPage, adminOnly: true },
];

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

219
app/src/lib/crmApi.ts Normal file
View File

@@ -0,0 +1,219 @@
// app/src/lib/crmApi.ts
// CRM API client — canonical CRM routes
// Implements the frontend adapter layer from Doc 10 (TypeScript Module Spec)
import type {
CrmContactListItem,
CrmPerson,
Client360Snapshot,
CrmOpportunityCard,
CrmTask,
KanbanColumn,
ImportBatchSummary,
ImportProposal,
ImportReviewDecision,
QdScoreEntry,
} from '@/types/crmTypes';
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? '';
function getAuthHeaders(): Record<string, string> {
const token = localStorage.getItem('velocity_token');
return token ? { Authorization: `Bearer ${token}` } : {};
}
async function apiFetch<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...getAuthHeaders(),
...(options?.headers ?? {}),
},
});
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(err.detail ?? `API error ${res.status}`);
}
return res.json() as Promise<T>;
}
// ── Contact List ──────────────────────────────────────────────────────────────
export async function fetchContacts(params: {
search?: string;
buyer_type?: string;
status?: string;
limit?: number;
offset?: number;
}): Promise<{ contacts: CrmContactListItem[]; total: number; limit: number; offset: number }> {
const qs = new URLSearchParams();
if (params.search) qs.set('search', params.search);
if (params.buyer_type) qs.set('buyer_type', params.buyer_type);
if (params.status) qs.set('status', params.status);
if (params.limit != null) qs.set('limit', String(params.limit));
if (params.offset != null) qs.set('offset', String(params.offset));
const res = await apiFetch<{ status: string; data: { contacts: CrmContactListItem[]; total: number; limit: number; offset: number } }>(
`/api/crm/contacts?${qs}`
);
return res.data;
}
export async function fetchContact(personId: string): Promise<CrmPerson> {
const res = await apiFetch<{ status: string; data: CrmPerson }>(`/api/crm/contacts/${personId}`);
return res.data;
}
// ── Client 360 ────────────────────────────────────────────────────────────────
export async function fetchClient360(personId: string): Promise<Client360Snapshot> {
const res = await apiFetch<{ status: string; data: Client360Snapshot }>(`/api/crm/client-360/${personId}`);
return res.data;
}
// ── Opportunities ─────────────────────────────────────────────────────────────
export async function fetchOpportunities(params?: {
stage?: string;
limit?: number;
offset?: number;
}): Promise<CrmOpportunityCard[]> {
const qs = new URLSearchParams();
if (params?.stage) qs.set('stage', params.stage);
if (params?.limit != null) qs.set('limit', String(params.limit));
if (params?.offset != null) qs.set('offset', String(params.offset));
const res = await apiFetch<{ status: string; data: CrmOpportunityCard[] }>(`/api/crm/opportunities?${qs}`);
return res.data;
}
// ── Tasks ─────────────────────────────────────────────────────────────────────
export async function fetchTasks(params?: {
status?: string;
assigned_to?: string;
limit?: number;
}): Promise<CrmTask[]> {
const qs = new URLSearchParams();
if (params?.status) qs.set('status', params.status);
if (params?.assigned_to) qs.set('assigned_to', params.assigned_to);
if (params?.limit != null) qs.set('limit', String(params.limit));
const res = await apiFetch<{ status: string; data: CrmTask[] }>(`/api/crm/tasks?${qs}`);
return res.data;
}
export async function createTask(body: {
person_id: string;
lead_id?: string;
reminder_type?: string;
title: string;
notes?: string;
due_at?: string;
priority?: string;
}): Promise<{ reminder_id: string; title: string }> {
const res = await apiFetch<{ status: string; data: { reminder_id: string; title: string } }>('/api/crm/tasks', {
method: 'POST',
body: JSON.stringify(body),
});
return res.data;
}
// ── Kanban ────────────────────────────────────────────────────────────────────
export async function fetchKanbanBoard(): Promise<KanbanColumn[]> {
const res = await apiFetch<{ status: string; data: KanbanColumn[] }>('/api/crm/kanban');
return res.data;
}
// ── QD Scores ─────────────────────────────────────────────────────────────────
export async function fetchQdScore(personId: string): Promise<{
person_id: string;
scores: Record<string, QdScoreEntry>;
timeseries: Array<{ score_type: string; value: number; timestamp: string | null; signal_source: string | null; delta: number | null }>;
}> {
const res = await apiFetch<{
status: string;
data: {
person_id: string;
scores: Record<string, QdScoreEntry>;
timeseries: Array<{ score_type: string; value: number; timestamp: string | null; signal_source: string | null; delta: number | null }>;
};
}>(`/api/crm/qd/${personId}`);
return res.data;
}
// ── Import Batches ─────────────────────────────────────────────────────────────
export async function uploadCrmImport(file: File, sourceSystem = 'csv_upload'): Promise<{
batch_id: string;
row_count: number;
mapped_columns: number;
unmapped_columns: number;
mapping_confidence: number;
proposals_created: number;
parse_errors: string[];
lifecycle: string;
message: string;
}> {
const form = new FormData();
form.append('file', file);
const res = await fetch(`${API_BASE}/api/crm/imports?source_system=${sourceSystem}`, {
method: 'POST',
headers: { ...getAuthHeaders() },
body: form,
});
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(err.detail ?? `Upload error ${res.status}`);
}
const json = await res.json();
return json.data;
}
export async function fetchImportBatches(lifecycle?: string): Promise<ImportBatchSummary[]> {
const qs = lifecycle ? `?lifecycle=${lifecycle}` : '';
const res = await apiFetch<{ status: string; data: ImportBatchSummary[] }>(`/api/crm/imports${qs}`);
return res.data;
}
export async function fetchImportBatch(batchId: string): Promise<{
batch_id: string;
filename: string;
row_count: number;
mapping_manifest: Record<string, unknown>;
lifecycle: string;
proposals: ImportProposal[];
proposal_count: number;
}> {
const res = await apiFetch<{ status: string; data: ReturnType<typeof fetchImportBatch> extends Promise<infer R> ? R : never }>(`/api/crm/imports/${batchId}`);
return res.data as Awaited<ReturnType<typeof fetchImportBatch>>;
}
export async function reviewProposal(
batchId: string,
proposalId: string,
decision: ImportReviewDecision,
notes = ''
): Promise<{ decision_id: string; decision: string }> {
const res = await apiFetch<{ status: string; data: { decision_id: string; decision: string } }>(
`/api/crm/imports/${batchId}/review-proposal`,
{
method: 'PUT',
body: JSON.stringify({ proposal_id: proposalId, decision, notes }),
}
);
return res.data;
}
export async function commitImportBatch(batchId: string): Promise<{
committed: number;
skipped: number;
errors: string[];
lifecycle: string;
}> {
const res = await apiFetch<{
status: string;
data: { committed: number; skipped: number; errors: string[]; lifecycle: string };
}>(`/api/crm/imports/${batchId}/commit`, { method: 'POST' });
return res.data;
}

214
app/src/types/crmTypes.ts Normal file
View File

@@ -0,0 +1,214 @@
// app/src/types/crmTypes.ts
// Canonical CRM TypeScript types — aligned to Doc 10 (TypeScript Module Spec)
// and Doc 09 (Database Schema and Root API Spec)
// ── Identity ──────────────────────────────────────────────────────────────────
export interface CrmContactListItem {
person_id: string;
full_name: string;
primary_email: string | null;
primary_phone: string | null;
buyer_type: string | null;
lead_id: string | null;
lead_status: string | null;
budget_band: string | null;
urgency: string | null;
intent_score: number;
urgency_score: number;
interaction_count: number;
last_interaction_at: string | null;
pending_tasks: number;
created_at: string | null;
}
export interface CrmPerson {
person_id: string;
full_name: string;
primary_email: string | null;
primary_phone: string | null;
buyer_type: string | null;
persona_labels: string[];
source_confidence: number;
created_at: string | null;
}
// ── Lead ─────────────────────────────────────────────────────────────────────
export type CrmLeadStatus =
| 'new' | 'contacted' | 'qualified' | 'site_visit_scheduled'
| 'site_visited' | 'negotiation' | 'booking_initiated'
| 'booked' | 'lost' | 'dormant';
export interface CrmLead {
lead_id: string;
status: CrmLeadStatus;
budget_band: string | null;
urgency: string | null;
financing_posture: string | null;
timeline_to_decision: string | null;
objections: string[];
motivations: string[];
created_at: string | null;
}
// ── Opportunity ───────────────────────────────────────────────────────────────
export type CrmOpportunityStage =
| 'prospect' | 'qualified' | 'proposal' | 'site_visit'
| 'negotiation' | 'booking' | 'agreement'
| 'closed_won' | 'closed_lost';
export interface CrmOpportunityCard {
opportunity_id: string;
stage: CrmOpportunityStage;
value: number | null;
probability: number | null;
expected_close_date: string | null;
next_action: string | null;
project_id: string | null;
unit_id: string | null;
// When fetched from list endpoint, person-level fields are included
person_id?: string;
client_name?: string;
client_phone?: string;
project_name?: string;
}
// ── Interaction ───────────────────────────────────────────────────────────────
export type IntelChannel =
| 'whatsapp' | 'phone' | 'email' | 'site_visit'
| 'office_meeting' | 'video_call' | 'cctv'
| 'perception_session' | 'system';
export interface InteractionTimelineItem {
interaction_id: string;
channel: IntelChannel;
interaction_type: string;
happened_at: string | null;
summary: string | null;
}
// ── Task / Reminder ───────────────────────────────────────────────────────────
export interface CrmTask {
reminder_id: string;
reminder_type: string;
title: string;
notes: string | null;
due_at: string | null;
status: 'pending' | 'done' | 'snoozed' | 'cancelled';
priority: 'low' | 'normal' | 'high' | 'urgent';
// Populated in list view
person_id?: string;
client_name?: string;
client_phone?: string;
}
// ── Property Interest ─────────────────────────────────────────────────────────
export interface PropertyInterest {
interest_id: string;
project_name: string;
unit_preference: string | null;
configuration: string | null;
budget_min: number | null;
budget_max: number | null;
priority: number;
}
// ── QD Score ──────────────────────────────────────────────────────────────────
export interface QdScoreEntry {
score_type: string;
current_value: number;
computed_at: string | null;
reasoning: string | null;
}
export interface QdOverview {
[score_type: string]: QdScoreEntry;
}
// ── Account ───────────────────────────────────────────────────────────────────
export interface AccountLink {
account_id: string;
account_name: string;
account_type: string;
industry: string | null;
}
// ── Client 360 Snapshot ────────────────────────────────────────────────────────
export interface Client360Snapshot {
client_ref: string;
snapshot_type: 'client_360';
identity: CrmPerson;
account_links: AccountLink[];
current_lead: CrmLead | null;
active_opportunities: CrmOpportunityCard[];
recent_interactions: InteractionTimelineItem[];
property_interests: PropertyInterest[];
tasks: CrmTask[];
qd_overview: QdOverview;
risk_flags: string[];
recommended_next_actions: string[];
note: string;
}
// ── Import Batch ────────────────────────────────────────────────────────────
export type ImportLifecycle =
| 'uploaded' | 'parsed' | 'mapped' | 'proposed'
| 'approved' | 'committed' | 'failed';
export interface ImportBatchSummary {
batch_id: string;
source_system: string;
filename: string;
row_count: number;
mapped_count: number;
unresolved_count: number;
lifecycle: ImportLifecycle;
created_at: string | null;
}
export interface ImportProposal {
proposal_id: string;
payload: {
row_number: number;
canonical_payload: Record<string, string>;
raw_row: Record<string, string>;
unresolved_fields: string[];
missing_required: string[];
confidence: number;
review_required: boolean;
};
confidence: number;
status: string;
review_required: boolean;
}
export type ImportReviewDecision = 'approved' | 'rejected' | 'needs_more_info';
// ── Kanban Board ──────────────────────────────────────────────────────────────
export interface KanbanColumn {
status: CrmLeadStatus | string;
label: string;
count: number;
items: KanbanCard[];
}
export interface KanbanCard {
lead_id: string;
person_id: string;
client_name: string;
client_phone: string | null;
buyer_type: string | null;
budget_band: string | null;
urgency: string | null;
intent_score: number;
}

View File

@@ -1,5 +1,5 @@
// Navigation Module Types
export type ModuleId = 'dashboard' | 'oracle' | 'sentinel' | 'inventory' | 'settings' | 'catalyst' | 'admin';
export type ModuleId = 'dashboard' | 'oracle' | 'sentinel' | 'inventory' | 'settings' | 'catalyst' | 'admin' | 'crm';
export type SentinelSubTab = 'overview' | 'live-session';