From 3cc4902ce2f4aa83fd0f77bb38d9ae8154515dba Mon Sep 17 00:00:00 2001 From: Chris Olson Date: Wed, 9 Sep 2026 12:03:48 -0400 Subject: [PATCH] Integrate BSG Prospecting Loop into Campaign Command Center Add full leads pipeline: Lead/LeadRun types, 3 new DB tables (leads, leads_runs, leads_config), 12 CRUD functions including syncBriefsFromDisk and exportAccountsForLoop, 12 API actions for leads lifecycle management. New pages: /leads (filterable lead cards with expand/action), /leads/admin (run controls, config, run history). AccountLeads component in account detail view. New Leads scorecard on Executive Overview. Sidebar nav entry. Also fixes account detail activity list using raw.activities to show all activities regardless of date filter. Co-Authored-By: Claude Opus 4.6 --- src/app/(dashboard)/accounts/page.tsx | 10 + src/app/(dashboard)/leads/admin/page.tsx | 253 ++++++++++++++++ src/app/(dashboard)/leads/page.tsx | 370 +++++++++++++++++++++++ src/app/(dashboard)/page.tsx | 11 +- src/app/api/phase2/route.ts | 76 +++++ src/components/account/AccountLeads.tsx | 99 ++++++ src/components/account/index.ts | 1 + src/components/layout/Sidebar.tsx | 9 + src/components/ui/PageHeader.tsx | 11 +- src/lib/db.ts | 291 ++++++++++++++++++ src/types/data.ts | 44 ++- 11 files changed, 1170 insertions(+), 5 deletions(-) create mode 100644 src/app/(dashboard)/leads/admin/page.tsx create mode 100644 src/app/(dashboard)/leads/page.tsx create mode 100644 src/components/account/AccountLeads.tsx diff --git a/src/app/(dashboard)/accounts/page.tsx b/src/app/(dashboard)/accounts/page.tsx index e0afccf..e0c7952 100644 --- a/src/app/(dashboard)/accounts/page.tsx +++ b/src/app/(dashboard)/accounts/page.tsx @@ -13,6 +13,7 @@ import { ExportButton } from '@/components/ui/ExportButton'; import { scoreAccountHealth, HEALTH_LEVEL_COLORS } from '@/lib/scoring'; import { AccountNotes } from '@/components/account/AccountNotes'; import { ContactMap } from '@/components/account/ContactMap'; +import { AccountLeads } from '@/components/account/AccountLeads'; import { RenewalTimeline } from '@/components/ui/RenewalTimeline'; import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell, @@ -616,6 +617,15 @@ export default function AccountExplorer() { + {/* Account Leads */} +
+

+ + Leads +

+ +
+ {/* Activity Timeline */}
diff --git a/src/app/(dashboard)/leads/admin/page.tsx b/src/app/(dashboard)/leads/admin/page.tsx new file mode 100644 index 0000000..3c51ea7 --- /dev/null +++ b/src/app/(dashboard)/leads/admin/page.tsx @@ -0,0 +1,253 @@ +'use client'; + +import { useEffect, useState, useCallback } from 'react'; +import Link from 'next/link'; +import { PageHeader } from '@/components/ui/PageHeader'; +import { ChartCard } from '@/components/ui/ChartCard'; +import type { LeadRun } from '@/types/data'; + +function api(action: string, body: Record = {}) { + return fetch('/api/phase2', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action, ...body }) }).then(r => r.json()); +} + +const STATUS_COLORS: Record = { + running: 'bg-blue-100 text-blue-800', + completed: 'bg-green-100 text-green-800', + failed: 'bg-red-100 text-red-800', + cancelled: 'bg-gray-100 text-gray-600', +}; + +export default function LeadsAdminPage() { + const [runs, setRuns] = useState([]); + const [config, setConfig] = useState>({}); + const [loading, setLoading] = useState(true); + const [starting, setStarting] = useState(false); + const [exporting, setExporting] = useState(false); + + const [capabilitySet, setCapabilitySet] = useState('agentminder'); + const [tierFilter, setTierFilter] = useState(''); + const [accountLimit, setAccountLimit] = useState('30'); + + const fetchData = useCallback(async () => { + const [runsRes, cfgRes] = await Promise.all([api('leads.run.list'), api('leads.config.get')]); + setRuns(runsRes.data || []); + setConfig(cfgRes.data || {}); + }, []); + + useEffect(() => { + fetchData().finally(() => setLoading(false)); + }, [fetchData]); + + async function startRun() { + setStarting(true); + await api('leads.run.start', { + capability_set: capabilitySet, + tier_filter: tierFilter || undefined, + account_limit: accountLimit ? parseInt(accountLimit) : undefined, + triggered_by: 'dashboard', + }); + await fetchData(); + setStarting(false); + } + + async function cancelRun(run: LeadRun) { + await api('leads.run.cancel', { runId: run.id, pid: run.pid }); + await fetchData(); + } + + async function exportAccounts() { + setExporting(true); + const res = await api('leads.export_accounts'); + setExporting(false); + alert(`Exported ${res.data?.count || 0} accounts to the prospecting loop.`); + } + + async function saveConfig(key: string, value: string) { + await api('leads.config.set', { key, value }); + setConfig(prev => ({ ...prev, [key]: value })); + } + + const activeRun = runs.find(r => r.status === 'running'); + + if (loading) { + return ( +
+ +
Loading...
+
+ ); + } + + return ( +
+ + + ← Back to Leads + + + +
+ + {activeRun ? ( +
+
+
+ Run in progress + PID: {activeRun.pid} +
+
+
Capability: {activeRun.capability_set}
+
Started: {new Date(activeRun.started_at).toLocaleString()}
+ {activeRun.tier_filter &&
Tier: {activeRun.tier_filter}
} + {activeRun.account_limit &&
Limit: {activeRun.account_limit}
} +
+ +
+ ) : ( +
+
+ + +
+
+ + +
+
+ + setAccountLimit(e.target.value)} + className="w-full text-xs border border-card-border rounded px-2 py-1.5" + min="1" + max="500" + /> +
+ +
+ )} + + + +
+
+ + e.target.value && saveConfig('prospecting_loop_path', e.target.value)} + className="w-full text-xs border border-card-border rounded px-2 py-1.5 font-mono" + /> +
+
+ + +
+
+
Account Sync
+
+ {config.last_accounts_export_at + ? `Last export: ${new Date(config.last_accounts_export_at).toLocaleString()}` + : 'Never exported'} +
+ +
+
+
+
+ + + {runs.length === 0 ? ( +
No runs yet.
+ ) : ( +
+ + + + + + + + + + + + + + + {runs.map(run => { + let duration = ''; + if (run.completed_at && run.started_at) { + const ms = new Date(run.completed_at).getTime() - new Date(run.started_at).getTime(); + const mins = Math.floor(ms / 60000); + duration = mins > 60 ? `${Math.floor(mins / 60)}h ${mins % 60}m` : `${mins}m`; + } + return ( + + + + + + + + + + + ); + })} + +
StartedStatusCapabilityTierAccountsBriefsTriggeredDuration
{new Date(run.started_at).toLocaleString()} + + {run.status} + + {run.capability_set}{run.tier_filter || 'all'}{run.accounts_processed}{run.briefs_generated}{run.triggered_by}{duration || (run.status === 'running' ? '...' : '-')}
+
+ )} +
+
+ ); +} diff --git a/src/app/(dashboard)/leads/page.tsx b/src/app/(dashboard)/leads/page.tsx new file mode 100644 index 0000000..77da0ed --- /dev/null +++ b/src/app/(dashboard)/leads/page.tsx @@ -0,0 +1,370 @@ +'use client'; + +import { useEffect, useState, useCallback, useMemo } from 'react'; +import Link from 'next/link'; +import { PageHeader } from '@/components/ui/PageHeader'; +import { Scorecard } from '@/components/ui/Scorecard'; +import { ChartCard } from '@/components/ui/ChartCard'; +import type { Lead } from '@/types/data'; + +const URGENCY_COLORS: Record = { + high: '#EF4444', + medium: '#F59E0B', + low: '#6B7280', +}; + +const PRIORITY_STYLES: Record = { + priority: 'bg-red-100 text-red-800', + standard: 'bg-blue-100 text-blue-800', + low: 'bg-gray-100 text-gray-600', +}; + +const STATUS_STYLES: Record = { + pending: 'bg-amber-100 text-amber-800', + accepted: 'bg-green-100 text-green-800', + snoozed: 'bg-purple-100 text-purple-800', + rejected: 'bg-gray-100 text-gray-600', + won: 'bg-emerald-100 text-emerald-800', + lost: 'bg-red-100 text-red-800', +}; + +function api(action: string, body: Record = {}) { + return fetch('/api/phase2', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action, ...body }) }).then(r => r.json()); +} + +export default function LeadsPage() { + const [leads, setLeads] = useState([]); + const [total, setTotal] = useState(0); + const [stats, setStats] = useState>({}); + const [loading, setLoading] = useState(true); + const [syncing, setSyncing] = useState(false); + const [expandedId, setExpandedId] = useState(null); + const [statusFilter, setStatusFilter] = useState('pending'); + const [urgencyFilter, setUrgencyFilter] = useState(''); + const [capabilityFilter, setCapabilityFilter] = useState(''); + const [sortBy, setSortBy] = useState('score'); + const [actionNote, setActionNote] = useState(''); + + const fetchLeads = useCallback(async () => { + const filters: Record = {}; + if (statusFilter) filters.status = statusFilter; + if (urgencyFilter) filters.urgency_level = urgencyFilter; + if (capabilityFilter) filters.capability_id = capabilityFilter; + filters.sort = sortBy === 'date' ? 'created_at' : sortBy === 'account' ? 'account' : undefined; + const res = await api('leads.list', filters); + setLeads(res.data || []); + setTotal(res.total || 0); + }, [statusFilter, urgencyFilter, capabilityFilter, sortBy]); + + const fetchStats = useCallback(async () => { + const res = await api('leads.stats'); + setStats(res.data || {}); + }, []); + + useEffect(() => { + Promise.all([fetchLeads(), fetchStats()]).finally(() => setLoading(false)); + }, [fetchLeads, fetchStats]); + + async function handleSync() { + setSyncing(true); + await api('leads.sync'); + await Promise.all([fetchLeads(), fetchStats()]); + setSyncing(false); + } + + async function handleAction(id: string, status: string) { + await api('leads.action', { id, status, note: actionNote || undefined }); + setActionNote(''); + setExpandedId(null); + await Promise.all([fetchLeads(), fetchStats()]); + } + + const capabilities = useMemo(() => { + const caps = (stats.byCapability || []) as { capability_name: string; cnt: number }[]; + return caps; + }, [stats]); + + const pending = (stats.pending as number) || 0; + const totalLeads = (stats.total as number) || 0; + const byStatus = (stats.byStatus || []) as { status: string; cnt: number }[]; + const accepted = byStatus.find(s => s.status === 'accepted')?.cnt || 0; + const winRate = (stats.winRate as number) || 0; + + if (loading) { + return ( +
+ +
Loading leads...
+
+ ); + } + + return ( +
+ +
+ + + Admin + + +
+
+ +
+ + 0 ? 'amber' : undefined} /> + + = 50 ? 'green' : winRate >= 25 ? 'amber' : undefined} /> +
+ +
+ + + {capabilities.length > 0 && ( + + )} +
+ Sort: + {(['score', 'date', 'account'] as const).map(s => ( + + ))} +
+
+ +
{total} lead{total !== 1 ? 's' : ''} found
+ +
+ {leads.map(lead => { + const isExpanded = expandedId === lead.id; + let signals: { source?: string; signal_type?: string; content?: string; published_at?: string; url?: string }[] = []; + let talkingPoints: string[] = []; + let contacts: { name?: string; title?: string; email?: string }[] = []; + let renewalCtx: { product?: string; renewalDate?: string; daysUntil?: number } | null = null; + try { signals = JSON.parse(lead.signals_json || '[]'); } catch { /* empty */ } + try { talkingPoints = JSON.parse(lead.talking_points_json || '[]'); } catch { /* empty */ } + try { contacts = JSON.parse(lead.target_contacts_json || '[]'); } catch { /* empty */ } + try { renewalCtx = lead.renewal_context_json ? JSON.parse(lead.renewal_context_json) : null; } catch { /* empty */ } + + return ( +
+
setExpandedId(isExpanded ? null : lead.id)} + > +
+ +
+
+ e.stopPropagation()} + className="text-xs font-semibold text-brand-azure hover:underline" + > + {lead.Account_Name} + + {lead.capability_name} + + {lead.priority} + + + {lead.status} + +
+
{lead.headline}
+
{lead.executive_summary}
+
+ +
+
{Math.round(lead.composite_score * 100)}
+
score
+
+
+ + {isExpanded && ( +
+
+
Executive Summary
+
{lead.executive_summary}
+
+ + {lead.outreach_hook && ( +
+
Outreach Hook
+
“{lead.outreach_hook}”
+
+ )} + + {talkingPoints.length > 0 && ( +
+
Talking Points
+
    + {talkingPoints.map((tp, i) => ( +
  • + + {tp} +
  • + ))} +
+
+ )} + + {signals.length > 0 && ( +
+
Signals ({signals.length})
+
+ {signals.map((s, i) => ( +
+
+ {s.source || 'Unknown'} + {s.signal_type} + {s.published_at && {new Date(s.published_at).toLocaleDateString()}} +
+
{s.content}
+ {s.url && ( + Source → + )} +
+ ))} +
+
+ )} + + {contacts.length > 0 && ( +
+
Target Contacts
+
+ {contacts.map((c, i) => ( +
+ {c.name} + {c.title && — {c.title}} +
+ ))} +
+
+ )} + + {renewalCtx && ( +
+ 💰 + + Renewal Window: {renewalCtx.product} — {renewalCtx.daysUntil} days ({renewalCtx.renewalDate}) + +
+ )} + +
+ Created: {new Date(lead.created_at).toLocaleDateString()} + Confidence: {Math.round(lead.confidence_score * 100)}% + {lead.feedback_at && Last action: {new Date(lead.feedback_at).toLocaleDateString()}} +
+ + {lead.status === 'pending' && ( +
+ setActionNote(e.target.value)} + onClick={e => e.stopPropagation()} + className="flex-1 text-xs border border-card-border rounded px-2 py-1.5" + /> + + + +
+ )} + + {(lead.status === 'accepted') && ( +
+ Outcome: + + +
+ )} +
+ )} +
+ ); + })} + + {leads.length === 0 && ( +
+ No leads found. Try adjusting your filters or sync from the prospecting loop. +
+ )} +
+
+ ); +} diff --git a/src/app/(dashboard)/page.tsx b/src/app/(dashboard)/page.tsx index 3935a73..035daef 100644 --- a/src/app/(dashboard)/page.tsx +++ b/src/app/(dashboard)/page.tsx @@ -6,7 +6,7 @@ import { ChartCard } from '@/components/ui/ChartCard'; import { PageHeader } from '@/components/ui/PageHeader'; import { formatCurrency, formatPercent, formatRatio, CHART_COLORS, STATUS_COLORS, FORECAST_COLORS, DISTRICT_SHORT } from '@/lib/formatters'; import { NextBestAction } from '@/components/ui/NextBestAction'; -import { useMemo, useState, useEffect, useCallback } from 'react'; +import { useMemo, useState, useEffect } from 'react'; import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend, AreaChart, Area, @@ -33,6 +33,12 @@ export default function ExecutiveOverview() { const { filtered, config, setCrossFilter } = useData(); const { pipeline, accounts, activities, targets } = filtered; + const [leadsStats, setLeadsStats] = useState<{ pending?: number; total?: number }>({}); + useEffect(() => { + fetch('/api/phase2', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'leads.stats' }) }) + .then(r => r.json()).then(r => setLeadsStats(r.data || {})).catch(() => {}); + }, []); + const kpis = useMemo(() => { const totalAccounts = accounts.length; const touchedAccounts = accounts.filter(a => (a.Touch_Count || 0) > 0).length; @@ -141,7 +147,7 @@ export default function ExecutiveOverview() {
-
+
0 ? 'green' : undefined} /> 0 ? 'amber' : 'green'} /> + 0 ? 'green' : undefined} />
diff --git a/src/app/api/phase2/route.ts b/src/app/api/phase2/route.ts index 7050947..c51e05e 100644 --- a/src/app/api/phase2/route.ts +++ b/src/app/api/phase2/route.ts @@ -1,4 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; +import { spawn } from 'child_process'; +import path from 'path'; import { getAccountNotes, addAccountNote, deleteAccountNote, updateAccountNote, getContacts, getAllContacts, upsertContact, deleteContact, @@ -12,6 +14,8 @@ import { getPipelineMovement, getSnapshotDates, updateLossReason, updateAccountPriority, + syncBriefsFromDisk, getLeads, getLeadById, updateLeadStatus, getLeadsStats, + getLeadRuns, insertLeadRun, updateLeadRun, getLeadsConfig, setLeadsConfig, exportAccountsForLoop, } from '@/lib/db'; export async function POST(req: NextRequest) { @@ -101,6 +105,78 @@ export async function POST(req: NextRequest) { updateAccountPriority(body.Account_Name, body.Priority); return NextResponse.json({ success: true }); + // Leads / Prospecting Loop + case 'leads.list': + return NextResponse.json(getLeads(body)); + case 'leads.get': + return NextResponse.json({ data: getLeadById(body.id) }); + case 'leads.action': { + updateLeadStatus(body.id, body.status, body.note); + const loopCfg = getLeadsConfig(); + const loopPath = loopCfg.prospecting_loop_path || path.resolve(process.cwd(), '..', 'BSG Prospecting Loop', 'sales-agent'); + const feedbackMap: Record = { accepted: 'act_on', rejected: 'reject', won: 'won', lost: 'lost', snoozed: 'snooze' }; + const feedbackAction = feedbackMap[body.status]; + if (feedbackAction) { + const args = ['run.py', 'feedback', '--brief', body.id, '--action', feedbackAction]; + if (body.note) args.push('--note', body.note); + spawn('python3', args, { cwd: loopPath, detached: true, stdio: 'ignore' }).unref(); + } + return NextResponse.json({ success: true }); + } + case 'leads.stats': + return NextResponse.json({ data: getLeadsStats() }); + case 'leads.sync': + return NextResponse.json({ data: syncBriefsFromDisk() }); + case 'leads.run.start': { + const cfg = getLeadsConfig(); + const lp = cfg.prospecting_loop_path || path.resolve(process.cwd(), '..', 'BSG Prospecting Loop', 'sales-agent'); + const args = ['run.py', 'loop']; + if (body.capability_set) args.push('--capability', body.capability_set); + if (body.tier_filter) args.push('--tier', body.tier_filter); + if (body.account_limit) args.push('--limit', String(body.account_limit)); + const child = spawn('python3', args, { cwd: lp, detached: true, stdio: ['ignore', 'pipe', 'pipe'] }); + const runId = insertLeadRun({ + capability_set: body.capability_set || 'ALL', + tier_filter: body.tier_filter, + account_limit: body.account_limit, + triggered_by: body.triggered_by || 'manual', + pid: child.pid || null, + }); + let stderr = ''; + child.stderr?.on('data', (d: Buffer) => { stderr += d.toString().slice(-2000); }); + child.on('close', (code: number | null) => { + try { + const result = syncBriefsFromDisk(); + updateLeadRun(runId, { + completed_at: new Date().toISOString(), + status: code === 0 ? 'completed' : 'failed', + briefs_generated: result.imported + result.updated, + error_message: code !== 0 ? stderr.slice(-500) || `Exit code ${code}` : null, + }); + } catch { /* best-effort */ } + }); + child.unref(); + return NextResponse.json({ data: { runId, pid: child.pid } }); + } + case 'leads.run.list': + return NextResponse.json({ data: getLeadRuns() }); + case 'leads.run.cancel': { + if (body.pid) { + try { process.kill(body.pid, 'SIGTERM'); } catch { /* already dead */ } + } + if (body.runId) { + updateLeadRun(body.runId, { completed_at: new Date().toISOString(), status: 'cancelled' }); + } + return NextResponse.json({ success: true }); + } + case 'leads.config.get': + return NextResponse.json({ data: getLeadsConfig() }); + case 'leads.config.set': + setLeadsConfig(body.key, body.value); + return NextResponse.json({ success: true }); + case 'leads.export_accounts': + return NextResponse.json({ data: { count: exportAccountsForLoop() } }); + // Export case 'export': { const data = getDashboardData(); diff --git a/src/components/account/AccountLeads.tsx b/src/components/account/AccountLeads.tsx new file mode 100644 index 0000000..27e3e78 --- /dev/null +++ b/src/components/account/AccountLeads.tsx @@ -0,0 +1,99 @@ +'use client'; + +import { useEffect, useState, useCallback } from 'react'; +import type { Lead } from '@/types/data'; + +const URGENCY_COLORS: Record = { + high: '#EF4444', + medium: '#F59E0B', + low: '#6B7280', +}; + +const STATUS_STYLES: Record = { + pending: 'bg-amber-100 text-amber-800', + accepted: 'bg-green-100 text-green-800', + snoozed: 'bg-purple-100 text-purple-800', + rejected: 'bg-gray-100 text-gray-600', + won: 'bg-emerald-100 text-emerald-800', + lost: 'bg-red-100 text-red-800', +}; + +function api(action: string, body: Record = {}) { + return fetch('/api/phase2', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action, ...body }) }).then(r => r.json()); +} + +export function AccountLeads({ accountName }: { accountName: string }) { + const [leads, setLeads] = useState([]); + const [loading, setLoading] = useState(true); + + const fetchLeads = useCallback(async () => { + const res = await api('leads.list', { Account_Name: accountName, limit: 20 }); + setLeads(res.data || []); + }, [accountName]); + + useEffect(() => { + fetchLeads().finally(() => setLoading(false)); + }, [fetchLeads]); + + async function handleAction(id: string, status: string) { + await api('leads.action', { id, status }); + await fetchLeads(); + } + + if (loading) return
Loading leads...
; + if (leads.length === 0) return
No leads found for this account.
; + + return ( +
+ {leads.map(lead => { + let talkingPoints: string[] = []; + try { talkingPoints = JSON.parse(lead.talking_points_json || '[]'); } catch { /* empty */ } + + return ( +
+
+
+
+
+ {lead.capability_name} + {lead.status} + {Math.round(lead.composite_score * 100)} +
+
{lead.headline}
+
{lead.executive_summary}
+ {talkingPoints.length > 0 && ( +
+ {talkingPoints.length} talking point{talkingPoints.length !== 1 ? 's' : ''} +
+ )} +
+
+ + {lead.status === 'pending' && ( +
+ + + +
+ )} +
+ ); + })} +
+ ); +} diff --git a/src/components/account/index.ts b/src/components/account/index.ts index 73ddb1e..f537b95 100644 --- a/src/components/account/index.ts +++ b/src/components/account/index.ts @@ -1,2 +1,3 @@ export { AccountNotes } from './AccountNotes'; export { ContactMap } from './ContactMap'; +export { AccountLeads } from './AccountLeads'; diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index de1047f..6435702 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -84,9 +84,18 @@ function AccountsIcon({ className }: { className?: string }) { ); } +function LeadsIcon({ className }: { className?: string }) { + return ( + + + + ); +} + const navItems = [ { href: '/', label: 'Executive Overview', icon: DashboardIcon, shortLabel: 'Overview' }, { href: '/accounts', label: 'Accounts', icon: AccountsIcon, shortLabel: 'Accounts' }, + { href: '/leads', label: 'Leads', icon: LeadsIcon, shortLabel: 'Leads' }, { href: '/activity', label: 'Activities', icon: ActivityIcon, shortLabel: 'Activities' }, { href: '/pipeline', label: 'Opportunities', icon: PipelineIcon, shortLabel: 'Opps' }, { href: '/implementation', label: 'Implementation', icon: ImplementIcon, shortLabel: 'Implement' }, diff --git a/src/components/ui/PageHeader.tsx b/src/components/ui/PageHeader.tsx index d21ea11..929d3e6 100644 --- a/src/components/ui/PageHeader.tsx +++ b/src/components/ui/PageHeader.tsx @@ -5,9 +5,10 @@ import { usePageTitle } from '@/lib/page-title-context'; interface PageHeaderProps { title: string; + children?: React.ReactNode; } -export function PageHeader({ title }: PageHeaderProps) { +export function PageHeader({ title, children }: PageHeaderProps) { const { setPageTitle } = usePageTitle(); useEffect(() => { @@ -15,5 +16,11 @@ export function PageHeader({ title }: PageHeaderProps) { return () => setPageTitle(''); }, [title, setPageTitle]); - return null; + if (!children) return null; + + return ( +
+ {children} +
+ ); } diff --git a/src/lib/db.ts b/src/lib/db.ts index 4a385b4..73ba750 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -1,5 +1,6 @@ import Database from 'better-sqlite3'; import path from 'path'; +import fs from 'fs'; import { PipelineRecord, AccountRecord, @@ -17,6 +18,8 @@ import { DistrictTarget, ForecastLock, ForecastOverride, + Lead, + LeadRun, } from '@/types/data'; const DB_PATH = path.join(process.cwd(), 'data', 'campaign.db'); @@ -293,6 +296,61 @@ function initSchema(db: Database.Database) { CREATE INDEX IF NOT EXISTS idx_snapshots_date ON snapshots(snapshot_date); CREATE INDEX IF NOT EXISTS idx_pipeline_snap_date ON pipeline_snapshots(snapshot_date); CREATE INDEX IF NOT EXISTS idx_playbook_progress_account ON playbook_progress(Account_Name); + + CREATE TABLE IF NOT EXISTS leads ( + id TEXT PRIMARY KEY, + account_id TEXT NOT NULL, + Account_Name TEXT NOT NULL, + capability_id TEXT NOT NULL, + capability_name TEXT NOT NULL, + headline TEXT NOT NULL, + executive_summary TEXT NOT NULL, + signals_json TEXT NOT NULL DEFAULT '[]', + talking_points_json TEXT NOT NULL DEFAULT '[]', + outreach_hook TEXT, + target_contacts_json TEXT, + urgency_level TEXT NOT NULL DEFAULT 'medium', + confidence_score REAL NOT NULL DEFAULT 0, + composite_score REAL NOT NULL DEFAULT 0, + priority TEXT NOT NULL DEFAULT 'standard', + assigned_rep_json TEXT, + status TEXT NOT NULL DEFAULT 'pending', + renewal_context_json TEXT, + feedback_note TEXT, + feedback_at TEXT, + created_at TEXT NOT NULL, + expires_at TEXT, + imported_at TEXT NOT NULL, + source_dir TEXT NOT NULL DEFAULT 'state2' + ); + + CREATE TABLE IF NOT EXISTS leads_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + started_at TEXT NOT NULL, + completed_at TEXT, + status TEXT NOT NULL DEFAULT 'running', + capability_set TEXT NOT NULL DEFAULT 'ALL', + tier_filter TEXT, + account_limit INTEGER, + accounts_processed INTEGER DEFAULT 0, + briefs_generated INTEGER DEFAULT 0, + error_message TEXT, + triggered_by TEXT DEFAULT 'manual', + pid INTEGER + ); + + CREATE TABLE IF NOT EXISTS leads_config ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_leads_account ON leads(Account_Name); + CREATE INDEX IF NOT EXISTS idx_leads_status ON leads(status); + CREATE INDEX IF NOT EXISTS idx_leads_priority ON leads(priority); + CREATE INDEX IF NOT EXISTS idx_leads_composite ON leads(composite_score DESC); + CREATE INDEX IF NOT EXISTS idx_leads_capability ON leads(capability_id); + CREATE INDEX IF NOT EXISTS idx_leads_runs_status ON leads_runs(status); `); } @@ -1002,3 +1060,236 @@ export function updateLossReason(opportunityId: string, lossReason: string) { getDb().prepare('UPDATE pipeline SET Loss_Reason = ? WHERE Opportunity_ID = ?').run(lossReason, opportunityId); } +// --- Leads / Prospecting Loop --- + +const LOOP_PATH_DEFAULT = path.resolve(process.cwd(), '..', 'BSG Prospecting Loop', 'sales-agent'); + +function getLoopPath(): string { + try { + const row = getDb().prepare("SELECT value FROM leads_config WHERE key = 'prospecting_loop_path'").get() as { value: string } | undefined; + return row?.value || LOOP_PATH_DEFAULT; + } catch { + return LOOP_PATH_DEFAULT; + } +} + +export function syncBriefsFromDisk(): { imported: number; updated: number; skipped: number } { + const db = getDb(); + const loopPath = getLoopPath(); + const now = new Date().toISOString(); + let imported = 0, updated = 0, skipped = 0; + + const upsert = db.prepare(` + INSERT INTO leads (id, account_id, Account_Name, capability_id, capability_name, headline, + executive_summary, signals_json, talking_points_json, outreach_hook, target_contacts_json, + urgency_level, confidence_score, composite_score, priority, assigned_rep_json, status, + renewal_context_json, created_at, expires_at, imported_at, source_dir) + VALUES (@id, @account_id, @Account_Name, @capability_id, @capability_name, @headline, + @executive_summary, @signals_json, @talking_points_json, @outreach_hook, @target_contacts_json, + @urgency_level, @confidence_score, @composite_score, @priority, @assigned_rep_json, @status, + @renewal_context_json, @created_at, @expires_at, @imported_at, @source_dir) + ON CONFLICT(id) DO UPDATE SET + headline=excluded.headline, executive_summary=excluded.executive_summary, + signals_json=excluded.signals_json, talking_points_json=excluded.talking_points_json, + outreach_hook=excluded.outreach_hook, target_contacts_json=excluded.target_contacts_json, + composite_score=excluded.composite_score, confidence_score=excluded.confidence_score, + priority=excluded.priority, renewal_context_json=excluded.renewal_context_json + WHERE leads.status = 'pending' + `); + + const tx = db.transaction(() => { + for (const stateDir of ['state2', 'state']) { + const indexPath = path.join(loopPath, 'output', stateDir, 'briefs_index.json'); + if (!fs.existsSync(indexPath)) continue; + let briefs: Record[]; + try { briefs = JSON.parse(fs.readFileSync(indexPath, 'utf-8')); } catch { continue; } + for (const b of briefs) { + const result = upsert.run({ + id: b.id as string, + account_id: b.account_id as string, + Account_Name: b.account_name as string, + capability_id: b.capability_id as string, + capability_name: b.capability_name as string, + headline: b.headline as string, + executive_summary: b.executive_summary as string, + signals_json: JSON.stringify(b.signals || []), + talking_points_json: JSON.stringify(b.talking_points || []), + outreach_hook: (b.outreach_hook as string) || null, + target_contacts_json: JSON.stringify(b.target_contacts || []), + urgency_level: (b.urgency_level as string) || 'medium', + confidence_score: (b.confidence_score as number) || 0, + composite_score: (b.composite_score as number) || 0, + priority: (b.priority as string) || 'standard', + assigned_rep_json: b.assigned_rep ? JSON.stringify(b.assigned_rep) : null, + status: (b.status as string) || 'pending', + renewal_context_json: b.renewal_context ? JSON.stringify(b.renewal_context) : null, + created_at: (b.created_at as string) || now, + expires_at: (b.expires_at as string) || null, + imported_at: now, + source_dir: stateDir, + }); + if (result.changes > 0) { + if (db.prepare('SELECT imported_at FROM leads WHERE id = ?').get(b.id as string)) { + updated++; + } else { + imported++; + } + } else { + skipped++; + } + } + } + }); + tx(); + + return { imported, updated, skipped }; +} + +export function getLeads(filters: { + status?: string; + Account_Name?: string; + urgency_level?: string; + priority?: string; + capability_id?: string; + limit?: number; + offset?: number; + sort?: string; +}): { data: Lead[]; total: number } { + const db = getDb(); + const where: string[] = []; + const params: Record = {}; + + if (filters.status) { where.push('status = @status'); params.status = filters.status; } + if (filters.Account_Name) { where.push('Account_Name = @Account_Name'); params.Account_Name = filters.Account_Name; } + if (filters.urgency_level) { where.push('urgency_level = @urgency_level'); params.urgency_level = filters.urgency_level; } + if (filters.priority) { where.push('priority = @priority'); params.priority = filters.priority; } + if (filters.capability_id) { where.push('capability_id = @capability_id'); params.capability_id = filters.capability_id; } + + const whereClause = where.length > 0 ? 'WHERE ' + where.join(' AND ') : ''; + const sortCol = filters.sort === 'created_at' ? 'created_at DESC' : filters.sort === 'account' ? 'Account_Name ASC' : 'composite_score DESC'; + const limit = filters.limit || 50; + const offset = filters.offset || 0; + + const total = (db.prepare(`SELECT COUNT(*) as cnt FROM leads ${whereClause}`).get(params) as { cnt: number }).cnt; + const data = db.prepare(`SELECT * FROM leads ${whereClause} ORDER BY ${sortCol} LIMIT @limit OFFSET @offset`).all({ ...params, limit, offset }) as Lead[]; + + return { data, total }; +} + +export function getLeadById(id: string): Lead | null { + return (getDb().prepare('SELECT * FROM leads WHERE id = ?').get(id) as Lead) || null; +} + +export function updateLeadStatus(id: string, status: string, note?: string) { + const now = new Date().toISOString(); + getDb().prepare('UPDATE leads SET status = ?, feedback_note = ?, feedback_at = ? WHERE id = ?').run(status, note || null, now, id); +} + +export function getLeadsStats(): Record { + const db = getDb(); + const byStatus = db.prepare('SELECT status, COUNT(*) as cnt FROM leads GROUP BY status').all() as { status: string; cnt: number }[]; + const byUrgency = db.prepare("SELECT urgency_level, COUNT(*) as cnt FROM leads WHERE status = 'pending' GROUP BY urgency_level").all() as { urgency_level: string; cnt: number }[]; + const byCapability = db.prepare('SELECT capability_name, COUNT(*) as cnt FROM leads GROUP BY capability_name ORDER BY cnt DESC LIMIT 10').all() as { capability_name: string; cnt: number }[]; + const total = (db.prepare('SELECT COUNT(*) as cnt FROM leads').get() as { cnt: number }).cnt; + const pending = (db.prepare("SELECT COUNT(*) as cnt FROM leads WHERE status = 'pending'").get() as { cnt: number }).cnt; + const won = byStatus.find(s => s.status === 'won')?.cnt || 0; + const lost = byStatus.find(s => s.status === 'lost')?.cnt || 0; + const winRate = (won + lost) > 0 ? Math.round((won / (won + lost)) * 100) : 0; + return { total, pending, byStatus, byUrgency, byCapability, winRate }; +} + +export function getLeadRuns(): LeadRun[] { + return getDb().prepare('SELECT * FROM leads_runs ORDER BY started_at DESC LIMIT 50').all() as LeadRun[]; +} + +export function insertLeadRun(run: { capability_set: string; tier_filter?: string | null; account_limit?: number | null; triggered_by?: string; pid?: number | null }): number { + const now = new Date().toISOString(); + const result = getDb().prepare( + 'INSERT INTO leads_runs (started_at, status, capability_set, tier_filter, account_limit, triggered_by, pid) VALUES (?, ?, ?, ?, ?, ?, ?)' + ).run(now, 'running', run.capability_set, run.tier_filter || null, run.account_limit || null, run.triggered_by || 'manual', run.pid || null); + return Number(result.lastInsertRowid); +} + +export function updateLeadRun(id: number, updates: Partial) { + const sets: string[] = []; + const params: Record = { id }; + for (const [k, v] of Object.entries(updates)) { + if (k === 'id') continue; + sets.push(`${k} = @${k}`); + params[k] = v; + } + if (sets.length === 0) return; + getDb().prepare(`UPDATE leads_runs SET ${sets.join(', ')} WHERE id = @id`).run(params); +} + +export function getLeadsConfig(): Record { + const rows = getDb().prepare('SELECT key, value FROM leads_config').all() as { key: string; value: string }[]; + const config: Record = {}; + for (const r of rows) config[r.key] = r.value; + return config; +} + +export function setLeadsConfig(key: string, value: string) { + const now = new Date().toISOString(); + getDb().prepare('INSERT INTO leads_config (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at').run(key, value, now); +} + +export function exportAccountsForLoop(): number { + const db = getDb(); + const loopPath = getLoopPath(); + const accountsPath = path.join(loopPath, 'data', 'accounts.json'); + + const dbAccounts = db.prepare('SELECT * FROM accounts').all() as AccountRecord[]; + + let existingData: { _note?: string; accounts: Record[] } = { accounts: [] }; + if (fs.existsSync(accountsPath)) { + try { existingData = JSON.parse(fs.readFileSync(accountsPath, 'utf-8')); } catch { /* fresh start */ } + } + + const lookup = new Map>(); + for (const a of existingData.accounts) { + lookup.set(a.accountName as string, a); + } + + const merged: Record[] = []; + for (const dbAcct of dbAccounts) { + const existing = lookup.get(dbAcct.Account_Name); + if (existing) { + existing.accountName = dbAcct.Account_Name; + existing.accountTier = dbAcct.Tier?.toLowerCase() || 'growth'; + if (dbAcct.Company_URL) { + try { existing.domain = new URL(dbAcct.Company_URL.startsWith('http') ? dbAcct.Company_URL : `https://${dbAcct.Company_URL}`).hostname; } catch { /* keep existing */ } + } + if (dbAcct.AD) { + const repId = 'rep_' + dbAcct.AD.toLowerCase().replace(/[^a-z0-9]/g, '_'); + existing.assignedRep = { id: repId, name: dbAcct.AD, email: '' }; + } + merged.push(existing); + lookup.delete(dbAcct.Account_Name); + } else { + const domain = dbAcct.Company_URL ? (() => { try { return new URL(dbAcct.Company_URL!.startsWith('http') ? dbAcct.Company_URL! : `https://${dbAcct.Company_URL}`).hostname; } catch { return ''; } })() : ''; + merged.push({ + accountId: 'acct_' + dbAcct.Account_Name.toLowerCase().replace(/[^a-z0-9]/g, '_'), + accountName: dbAcct.Account_Name, + domain, + industry: '', + accountTier: dbAcct.Tier?.toLowerCase() || 'growth', + assignedRep: dbAcct.AD ? { id: 'rep_' + dbAcct.AD.toLowerCase().replace(/[^a-z0-9]/g, '_'), name: dbAcct.AD, email: '' } : { id: 'rep_unknown', name: 'Unassigned', email: '' }, + currentProducts: [], + ownedCapabilityIds: [], + reference: { salesTerritory: dbAcct.District_Name || '' }, + lastRunAt: null, + targetContacts: [], + }); + } + } + + for (const remaining of lookup.values()) { + merged.push(remaining); + } + + fs.writeFileSync(accountsPath, JSON.stringify({ _note: existingData._note || 'Generated from Campaign Command Center DB', accounts: merged }, null, 2)); + setLeadsConfig('last_accounts_export_at', new Date().toISOString()); + return merged.length; +} + diff --git a/src/types/data.ts b/src/types/data.ts index fb15910..4ed884a 100644 --- a/src/types/data.ts +++ b/src/types/data.ts @@ -231,5 +231,47 @@ export const IMPLEMENTATION_STAGES = ['Not Started', 'In Progress', 'Complete', export const ACTIVITY_TYPES = [ 'Launch Briefing', 'Discovery', 'QBR Attach', 'Exec Meeting', - 'Demo', 'Workshop', 'Email', 'Call' + 'Demo', 'Workshop', 'Email', 'Call', 'Lead Follow-up' ] as const; + +export interface Lead { + id: string; + account_id: string; + Account_Name: string; + capability_id: string; + capability_name: string; + headline: string; + executive_summary: string; + signals_json: string; + talking_points_json: string; + outreach_hook: string | null; + target_contacts_json: string | null; + urgency_level: 'high' | 'medium' | 'low'; + confidence_score: number; + composite_score: number; + priority: 'priority' | 'standard' | 'low'; + assigned_rep_json: string | null; + status: 'pending' | 'accepted' | 'snoozed' | 'rejected' | 'won' | 'lost'; + renewal_context_json: string | null; + feedback_note: string | null; + feedback_at: string | null; + created_at: string; + expires_at: string | null; + imported_at: string; + source_dir: string; +} + +export interface LeadRun { + id: number; + started_at: string; + completed_at: string | null; + status: 'running' | 'completed' | 'failed' | 'cancelled'; + capability_set: string; + tier_filter: string | null; + account_limit: number | null; + accounts_processed: number; + briefs_generated: number; + error_message: string | null; + triggered_by: string; + pid: number | null; +}