forked from sagnik/Project_Velocity
feat(crm): canonical crm and imported routes implementation
This commit is contained in:
@@ -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 },
|
||||
];
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
868
app/src/components/modules/CRM.tsx
Normal file
868
app/src/components/modules/CRM.tsx
Normal 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
219
app/src/lib/crmApi.ts
Normal 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
214
app/src/types/crmTypes.ts
Normal 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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user