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