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 <noreply@anthropic.com>
This commit is contained in:
2026-09-09 12:03:48 -04:00
parent c4f02a84c8
commit 3cc4902ce2
11 changed files with 1170 additions and 5 deletions

View File

@@ -13,6 +13,7 @@ import { ExportButton } from '@/components/ui/ExportButton';
import { scoreAccountHealth, HEALTH_LEVEL_COLORS } from '@/lib/scoring'; import { scoreAccountHealth, HEALTH_LEVEL_COLORS } from '@/lib/scoring';
import { AccountNotes } from '@/components/account/AccountNotes'; import { AccountNotes } from '@/components/account/AccountNotes';
import { ContactMap } from '@/components/account/ContactMap'; import { ContactMap } from '@/components/account/ContactMap';
import { AccountLeads } from '@/components/account/AccountLeads';
import { RenewalTimeline } from '@/components/ui/RenewalTimeline'; import { RenewalTimeline } from '@/components/ui/RenewalTimeline';
import { import {
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell, BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell,
@@ -616,6 +617,15 @@ export default function AccountExplorer() {
<ContactMap accountName={selectedAccount.Account_Name} /> <ContactMap accountName={selectedAccount.Account_Name} />
</div> </div>
{/* Account Leads */}
<div className="bg-card-bg rounded-xl border border-card-border p-5">
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
<svg className="w-4 h-4 text-brand-azure" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" /></svg>
Leads
</h3>
<AccountLeads accountName={selectedAccount.Account_Name} />
</div>
{/* Activity Timeline */} {/* Activity Timeline */}
<div className="bg-card-bg rounded-xl border border-card-border p-5"> <div className="bg-card-bg rounded-xl border border-card-border p-5">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">

View File

@@ -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<string, unknown> = {}) {
return fetch('/api/phase2', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action, ...body }) }).then(r => r.json());
}
const STATUS_COLORS: Record<string, string> = {
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<LeadRun[]>([]);
const [config, setConfig] = useState<Record<string, string>>({});
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 (
<div>
<PageHeader title="Leads Admin" />
<div className="flex items-center justify-center h-64 text-muted">Loading...</div>
</div>
);
}
return (
<div>
<PageHeader title="Leads Admin">
<Link href="/leads" className="px-3 py-1.5 text-xs font-medium border border-card-border rounded-lg hover:bg-gray-50 transition">
Back to Leads
</Link>
</PageHeader>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4">
<ChartCard title="Run Controls" subtitle="Start a prospecting loop run">
{activeRun ? (
<div className="space-y-3">
<div className="flex items-center gap-2 p-3 bg-blue-50 border border-blue-200 rounded-lg">
<div className="w-2 h-2 rounded-full bg-blue-500 animate-pulse" />
<span className="text-xs font-medium text-blue-800">Run in progress</span>
<span className="text-[10px] text-blue-600 ml-auto">PID: {activeRun.pid}</span>
</div>
<div className="text-xs text-muted space-y-1">
<div>Capability: <span className="font-medium text-foreground">{activeRun.capability_set}</span></div>
<div>Started: <span className="font-medium text-foreground">{new Date(activeRun.started_at).toLocaleString()}</span></div>
{activeRun.tier_filter && <div>Tier: <span className="font-medium text-foreground">{activeRun.tier_filter}</span></div>}
{activeRun.account_limit && <div>Limit: <span className="font-medium text-foreground">{activeRun.account_limit}</span></div>}
</div>
<button
onClick={() => cancelRun(activeRun)}
className="w-full px-3 py-2 text-xs font-medium bg-red-500 text-white rounded-lg hover:bg-red-600 transition"
>
Cancel Run
</button>
</div>
) : (
<div className="space-y-3">
<div>
<label className="text-xs text-muted block mb-1">Capability Set</label>
<select
value={capabilitySet}
onChange={e => setCapabilitySet(e.target.value)}
className="w-full text-xs border border-card-border rounded px-2 py-1.5"
>
<option value="ALL">ALL (Full Portfolio)</option>
<option value="agentminder">AgentMinder</option>
<option value="dx02">DX02</option>
</select>
</div>
<div>
<label className="text-xs text-muted block mb-1">Tier Filter</label>
<select
value={tierFilter}
onChange={e => setTierFilter(e.target.value)}
className="w-full text-xs border border-card-border rounded px-2 py-1.5"
>
<option value="">All Tiers</option>
<option value="strategic">Strategic</option>
<option value="enterprise">Enterprise</option>
<option value="growth">Growth</option>
</select>
</div>
<div>
<label className="text-xs text-muted block mb-1">Account Limit</label>
<input
type="number"
value={accountLimit}
onChange={e => setAccountLimit(e.target.value)}
className="w-full text-xs border border-card-border rounded px-2 py-1.5"
min="1"
max="500"
/>
</div>
<button
onClick={startRun}
disabled={starting}
className="w-full px-3 py-2 text-xs font-medium bg-brand-azure text-white rounded-lg hover:bg-brand-azure/90 transition disabled:opacity-50"
>
{starting ? 'Starting...' : 'Start Prospecting Run'}
</button>
</div>
)}
</ChartCard>
<ChartCard title="Configuration" subtitle="Prospecting loop settings">
<div className="space-y-3">
<div>
<label className="text-xs text-muted block mb-1">Loop Path</label>
<input
type="text"
value={config.prospecting_loop_path || ''}
placeholder="(default: ../BSG Prospecting Loop/sales-agent)"
onBlur={e => 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"
/>
</div>
<div>
<label className="text-xs text-muted block mb-1">Default Capability Set</label>
<select
value={config.default_capability_set || 'agentminder'}
onChange={e => saveConfig('default_capability_set', e.target.value)}
className="w-full text-xs border border-card-border rounded px-2 py-1.5"
>
<option value="ALL">ALL</option>
<option value="agentminder">AgentMinder</option>
<option value="dx02">DX02</option>
</select>
</div>
<div className="pt-2 border-t border-card-border">
<div className="text-xs font-semibold text-foreground mb-2">Account Sync</div>
<div className="text-xs text-muted mb-2">
{config.last_accounts_export_at
? `Last export: ${new Date(config.last_accounts_export_at).toLocaleString()}`
: 'Never exported'}
</div>
<button
onClick={exportAccounts}
disabled={exporting}
className="w-full px-3 py-2 text-xs font-medium border border-card-border rounded-lg hover:bg-gray-50 transition disabled:opacity-50"
>
{exporting ? 'Exporting...' : 'Export Accounts to Loop'}
</button>
</div>
</div>
</ChartCard>
</div>
<ChartCard title="Run History" subtitle="Past prospecting loop runs">
{runs.length === 0 ? (
<div className="text-center py-8 text-muted text-xs">No runs yet.</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-card-border">
<th className="text-left py-2 px-2 font-semibold text-muted">Started</th>
<th className="text-left py-2 px-2 font-semibold text-muted">Status</th>
<th className="text-left py-2 px-2 font-semibold text-muted">Capability</th>
<th className="text-left py-2 px-2 font-semibold text-muted">Tier</th>
<th className="text-right py-2 px-2 font-semibold text-muted">Accounts</th>
<th className="text-right py-2 px-2 font-semibold text-muted">Briefs</th>
<th className="text-left py-2 px-2 font-semibold text-muted">Triggered</th>
<th className="text-left py-2 px-2 font-semibold text-muted">Duration</th>
</tr>
</thead>
<tbody>
{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 (
<tr key={run.id} className="border-b border-card-border/50 hover:bg-gray-50/50">
<td className="py-2 px-2">{new Date(run.started_at).toLocaleString()}</td>
<td className="py-2 px-2">
<span className={`inline-flex px-1.5 py-0.5 rounded-full text-[10px] font-medium ${STATUS_COLORS[run.status] || ''}`}>
{run.status}
</span>
</td>
<td className="py-2 px-2">{run.capability_set}</td>
<td className="py-2 px-2">{run.tier_filter || 'all'}</td>
<td className="py-2 px-2 text-right">{run.accounts_processed}</td>
<td className="py-2 px-2 text-right">{run.briefs_generated}</td>
<td className="py-2 px-2">{run.triggered_by}</td>
<td className="py-2 px-2">{duration || (run.status === 'running' ? '...' : '-')}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</ChartCard>
</div>
);
}

View File

@@ -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<string, string> = {
high: '#EF4444',
medium: '#F59E0B',
low: '#6B7280',
};
const PRIORITY_STYLES: Record<string, string> = {
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<string, string> = {
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<string, unknown> = {}) {
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<Lead[]>([]);
const [total, setTotal] = useState(0);
const [stats, setStats] = useState<Record<string, unknown>>({});
const [loading, setLoading] = useState(true);
const [syncing, setSyncing] = useState(false);
const [expandedId, setExpandedId] = useState<string | null>(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<string, unknown> = {};
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 (
<div>
<PageHeader title="Leads" />
<div className="flex items-center justify-center h-64 text-muted">Loading leads...</div>
</div>
);
}
return (
<div>
<PageHeader title="Leads">
<div className="flex items-center gap-2">
<Link
href="/leads/admin"
className="px-3 py-1.5 text-xs font-medium border border-card-border rounded-lg hover:bg-gray-50 transition flex items-center gap-1"
>
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" /></svg>
Admin
</Link>
<button
onClick={handleSync}
disabled={syncing}
className="px-3 py-1.5 text-xs font-medium bg-brand-azure text-white rounded-lg hover:bg-brand-azure/90 transition disabled:opacity-50 flex items-center gap-1"
>
<svg className={`w-3.5 h-3.5 ${syncing ? 'animate-spin' : ''}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="23 4 23 10 17 10" /><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" /></svg>
{syncing ? 'Syncing...' : 'Sync'}
</button>
</div>
</PageHeader>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6">
<Scorecard label="Total Leads" value={totalLeads} />
<Scorecard label="Pending" value={pending} color={pending > 0 ? 'amber' : undefined} />
<Scorecard label="Accepted" value={accepted} color="green" />
<Scorecard label="Win Rate" value={`${winRate}%`} color={winRate >= 50 ? 'green' : winRate >= 25 ? 'amber' : undefined} />
</div>
<div className="flex flex-wrap gap-2 mb-4">
<select
value={statusFilter}
onChange={e => setStatusFilter(e.target.value)}
className="text-xs border border-card-border rounded-lg px-2 py-1.5 bg-white"
>
<option value="">All Statuses</option>
<option value="pending">Pending</option>
<option value="accepted">Accepted</option>
<option value="snoozed">Snoozed</option>
<option value="rejected">Rejected</option>
<option value="won">Won</option>
<option value="lost">Lost</option>
</select>
<select
value={urgencyFilter}
onChange={e => setUrgencyFilter(e.target.value)}
className="text-xs border border-card-border rounded-lg px-2 py-1.5 bg-white"
>
<option value="">All Urgency</option>
<option value="high">High</option>
<option value="medium">Medium</option>
<option value="low">Low</option>
</select>
{capabilities.length > 0 && (
<select
value={capabilityFilter}
onChange={e => setCapabilityFilter(e.target.value)}
className="text-xs border border-card-border rounded-lg px-2 py-1.5 bg-white"
>
<option value="">All Capabilities</option>
{capabilities.map(c => (
<option key={c.capability_name} value={c.capability_name}>{c.capability_name} ({c.cnt})</option>
))}
</select>
)}
<div className="flex items-center gap-1 ml-auto">
<span className="text-xs text-muted">Sort:</span>
{(['score', 'date', 'account'] as const).map(s => (
<button
key={s}
onClick={() => setSortBy(s)}
className={`text-xs px-2 py-1 rounded ${sortBy === s ? 'bg-brand-navy text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
>
{s === 'score' ? 'Score' : s === 'date' ? 'Newest' : 'Account'}
</button>
))}
</div>
</div>
<div className="text-xs text-muted mb-2">{total} lead{total !== 1 ? 's' : ''} found</div>
<div className="space-y-2">
{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 (
<div key={lead.id} className="bg-white rounded-lg border border-card-border overflow-hidden">
<div
className="flex items-start gap-3 p-3 cursor-pointer hover:bg-gray-50/50 transition"
onClick={() => setExpandedId(isExpanded ? null : lead.id)}
>
<div className="w-1 self-stretch rounded-full flex-shrink-0" style={{ backgroundColor: URGENCY_COLORS[lead.urgency_level] || '#6B7280' }} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<Link
href={`/accounts?account=${encodeURIComponent(lead.Account_Name)}`}
onClick={e => e.stopPropagation()}
className="text-xs font-semibold text-brand-azure hover:underline"
>
{lead.Account_Name}
</Link>
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-brand-navy/10 text-brand-navy font-medium">{lead.capability_name}</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ${PRIORITY_STYLES[lead.priority] || ''}`}>
{lead.priority}
</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ml-auto ${STATUS_STYLES[lead.status] || ''}`}>
{lead.status}
</span>
</div>
<div className="text-sm font-medium text-foreground mb-0.5">{lead.headline}</div>
<div className="text-xs text-muted line-clamp-2">{lead.executive_summary}</div>
</div>
<div className="flex-shrink-0 text-right">
<div className="text-lg font-bold text-brand-navy">{Math.round(lead.composite_score * 100)}</div>
<div className="text-[10px] text-muted">score</div>
</div>
</div>
{isExpanded && (
<div className="border-t border-card-border p-4 bg-gray-50/30 space-y-4">
<div>
<div className="text-xs font-semibold text-foreground mb-1">Executive Summary</div>
<div className="text-xs text-muted leading-relaxed">{lead.executive_summary}</div>
</div>
{lead.outreach_hook && (
<div>
<div className="text-xs font-semibold text-foreground mb-1">Outreach Hook</div>
<div className="text-xs text-brand-azure italic">&ldquo;{lead.outreach_hook}&rdquo;</div>
</div>
)}
{talkingPoints.length > 0 && (
<div>
<div className="text-xs font-semibold text-foreground mb-1">Talking Points</div>
<ul className="space-y-1">
{talkingPoints.map((tp, i) => (
<li key={i} className="text-xs text-muted flex gap-2">
<span className="text-brand-azure flex-shrink-0"></span>
{tp}
</li>
))}
</ul>
</div>
)}
{signals.length > 0 && (
<div>
<div className="text-xs font-semibold text-foreground mb-1">Signals ({signals.length})</div>
<div className="space-y-1.5">
{signals.map((s, i) => (
<div key={i} className="text-xs bg-white rounded border border-card-border p-2">
<div className="flex items-center gap-2 mb-0.5">
<span className="font-medium text-foreground">{s.source || 'Unknown'}</span>
<span className="text-[10px] px-1 py-0.5 rounded bg-gray-100 text-gray-500">{s.signal_type}</span>
{s.published_at && <span className="text-[10px] text-muted ml-auto">{new Date(s.published_at).toLocaleDateString()}</span>}
</div>
<div className="text-muted line-clamp-2">{s.content}</div>
{s.url && (
<a href={s.url} target="_blank" rel="noopener noreferrer" className="text-[10px] text-brand-azure hover:underline mt-0.5 inline-block">Source </a>
)}
</div>
))}
</div>
</div>
)}
{contacts.length > 0 && (
<div>
<div className="text-xs font-semibold text-foreground mb-1">Target Contacts</div>
<div className="flex flex-wrap gap-2">
{contacts.map((c, i) => (
<div key={i} className="text-xs bg-white rounded border border-card-border px-2 py-1">
<span className="font-medium">{c.name}</span>
{c.title && <span className="text-muted"> {c.title}</span>}
</div>
))}
</div>
</div>
)}
{renewalCtx && (
<div className="flex items-center gap-2 text-xs bg-amber-50 border border-amber-200 rounded-lg px-3 py-2">
<span>💰</span>
<span className="font-medium text-amber-800">
Renewal Window: {renewalCtx.product} {renewalCtx.daysUntil} days ({renewalCtx.renewalDate})
</span>
</div>
)}
<div className="text-[10px] text-muted flex gap-4">
<span>Created: {new Date(lead.created_at).toLocaleDateString()}</span>
<span>Confidence: {Math.round(lead.confidence_score * 100)}%</span>
{lead.feedback_at && <span>Last action: {new Date(lead.feedback_at).toLocaleDateString()}</span>}
</div>
{lead.status === 'pending' && (
<div className="flex items-center gap-2 pt-2 border-t border-card-border">
<input
type="text"
placeholder="Note (optional)..."
value={actionNote}
onChange={e => setActionNote(e.target.value)}
onClick={e => e.stopPropagation()}
className="flex-1 text-xs border border-card-border rounded px-2 py-1.5"
/>
<button
onClick={e => { e.stopPropagation(); handleAction(lead.id, 'accepted'); }}
className="px-3 py-1.5 text-xs font-medium bg-green-600 text-white rounded hover:bg-green-700 transition"
>
Accept
</button>
<button
onClick={e => { e.stopPropagation(); handleAction(lead.id, 'snoozed'); }}
className="px-3 py-1.5 text-xs font-medium bg-purple-600 text-white rounded hover:bg-purple-700 transition"
>
Snooze
</button>
<button
onClick={e => { e.stopPropagation(); handleAction(lead.id, 'rejected'); }}
className="px-3 py-1.5 text-xs font-medium bg-gray-400 text-white rounded hover:bg-gray-500 transition"
>
Reject
</button>
</div>
)}
{(lead.status === 'accepted') && (
<div className="flex items-center gap-2 pt-2 border-t border-card-border">
<span className="text-xs text-muted">Outcome:</span>
<button
onClick={e => { e.stopPropagation(); handleAction(lead.id, 'won'); }}
className="px-3 py-1.5 text-xs font-medium bg-emerald-600 text-white rounded hover:bg-emerald-700 transition"
>
Won
</button>
<button
onClick={e => { e.stopPropagation(); handleAction(lead.id, 'lost'); }}
className="px-3 py-1.5 text-xs font-medium bg-red-500 text-white rounded hover:bg-red-600 transition"
>
Lost
</button>
</div>
)}
</div>
)}
</div>
);
})}
{leads.length === 0 && (
<div className="text-center py-12 text-muted text-sm">
No leads found. Try adjusting your filters or sync from the prospecting loop.
</div>
)}
</div>
</div>
);
}

View File

@@ -6,7 +6,7 @@ import { ChartCard } from '@/components/ui/ChartCard';
import { PageHeader } from '@/components/ui/PageHeader'; import { PageHeader } from '@/components/ui/PageHeader';
import { formatCurrency, formatPercent, formatRatio, CHART_COLORS, STATUS_COLORS, FORECAST_COLORS, DISTRICT_SHORT } from '@/lib/formatters'; import { formatCurrency, formatPercent, formatRatio, CHART_COLORS, STATUS_COLORS, FORECAST_COLORS, DISTRICT_SHORT } from '@/lib/formatters';
import { NextBestAction } from '@/components/ui/NextBestAction'; import { NextBestAction } from '@/components/ui/NextBestAction';
import { useMemo, useState, useEffect, useCallback } from 'react'; import { useMemo, useState, useEffect } from 'react';
import { import {
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend, BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend,
AreaChart, Area, AreaChart, Area,
@@ -33,6 +33,12 @@ export default function ExecutiveOverview() {
const { filtered, config, setCrossFilter } = useData(); const { filtered, config, setCrossFilter } = useData();
const { pipeline, accounts, activities, targets } = filtered; 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 kpis = useMemo(() => {
const totalAccounts = accounts.length; const totalAccounts = accounts.length;
const touchedAccounts = accounts.filter(a => (a.Touch_Count || 0) > 0).length; const touchedAccounts = accounts.filter(a => (a.Touch_Count || 0) > 0).length;
@@ -141,7 +147,7 @@ export default function ExecutiveOverview() {
<div> <div>
<PageHeader title="Executive Overview" /> <PageHeader title="Executive Overview" />
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-9 gap-3 mb-6"> <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-10 gap-3 mb-6">
<Scorecard label="Total Accounts" value={kpis.totalAccounts} /> <Scorecard label="Total Accounts" value={kpis.totalAccounts} />
<Scorecard <Scorecard
label="Accounts Touched" label="Accounts Touched"
@@ -160,6 +166,7 @@ export default function ExecutiveOverview() {
<Scorecard label="Activities (30d)" value={kpis.recentActivities} /> <Scorecard label="Activities (30d)" value={kpis.recentActivities} />
<Scorecard label="Meetings Scheduled" value={kpis.meetingsScheduled} color={kpis.meetingsScheduled > 0 ? 'green' : undefined} /> <Scorecard label="Meetings Scheduled" value={kpis.meetingsScheduled} color={kpis.meetingsScheduled > 0 ? 'green' : undefined} />
<Scorecard label="Follow-Ups Pending" value={kpis.followUpsPending} color={kpis.followUpsPending > 0 ? 'amber' : 'green'} /> <Scorecard label="Follow-Ups Pending" value={kpis.followUpsPending} color={kpis.followUpsPending > 0 ? 'amber' : 'green'} />
<Scorecard label="New Leads" value={leadsStats.pending || 0} color={(leadsStats.pending || 0) > 0 ? 'green' : undefined} />
</div> </div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4">

View File

@@ -1,4 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { spawn } from 'child_process';
import path from 'path';
import { import {
getAccountNotes, addAccountNote, deleteAccountNote, updateAccountNote, getAccountNotes, addAccountNote, deleteAccountNote, updateAccountNote,
getContacts, getAllContacts, upsertContact, deleteContact, getContacts, getAllContacts, upsertContact, deleteContact,
@@ -12,6 +14,8 @@ import {
getPipelineMovement, getSnapshotDates, getPipelineMovement, getSnapshotDates,
updateLossReason, updateLossReason,
updateAccountPriority, updateAccountPriority,
syncBriefsFromDisk, getLeads, getLeadById, updateLeadStatus, getLeadsStats,
getLeadRuns, insertLeadRun, updateLeadRun, getLeadsConfig, setLeadsConfig, exportAccountsForLoop,
} from '@/lib/db'; } from '@/lib/db';
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
@@ -101,6 +105,78 @@ export async function POST(req: NextRequest) {
updateAccountPriority(body.Account_Name, body.Priority); updateAccountPriority(body.Account_Name, body.Priority);
return NextResponse.json({ success: true }); 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<string, string> = { 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 // Export
case 'export': { case 'export': {
const data = getDashboardData(); const data = getDashboardData();

View File

@@ -0,0 +1,99 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import type { Lead } from '@/types/data';
const URGENCY_COLORS: Record<string, string> = {
high: '#EF4444',
medium: '#F59E0B',
low: '#6B7280',
};
const STATUS_STYLES: Record<string, string> = {
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<string, unknown> = {}) {
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<Lead[]>([]);
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 <div className="text-xs text-muted py-4">Loading leads...</div>;
if (leads.length === 0) return <div className="text-xs text-muted py-4">No leads found for this account.</div>;
return (
<div className="space-y-2">
{leads.map(lead => {
let talkingPoints: string[] = [];
try { talkingPoints = JSON.parse(lead.talking_points_json || '[]'); } catch { /* empty */ }
return (
<div key={lead.id} className="bg-white rounded-lg border border-card-border p-3">
<div className="flex items-start gap-2">
<div className="w-1 self-stretch rounded-full flex-shrink-0" style={{ backgroundColor: URGENCY_COLORS[lead.urgency_level] || '#6B7280' }} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-brand-navy/10 text-brand-navy font-medium">{lead.capability_name}</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ${STATUS_STYLES[lead.status] || ''}`}>{lead.status}</span>
<span className="text-xs font-bold text-brand-navy ml-auto">{Math.round(lead.composite_score * 100)}</span>
</div>
<div className="text-xs font-medium text-foreground mb-0.5">{lead.headline}</div>
<div className="text-[11px] text-muted line-clamp-2 mb-1">{lead.executive_summary}</div>
{talkingPoints.length > 0 && (
<div className="text-[10px] text-muted">
<span className="font-medium">{talkingPoints.length}</span> talking point{talkingPoints.length !== 1 ? 's' : ''}
</div>
)}
</div>
</div>
{lead.status === 'pending' && (
<div className="flex items-center gap-1.5 mt-2 pt-2 border-t border-card-border/50">
<button
onClick={() => handleAction(lead.id, 'accepted')}
className="px-2 py-1 text-[10px] font-medium bg-green-600 text-white rounded hover:bg-green-700 transition"
>
Accept
</button>
<button
onClick={() => handleAction(lead.id, 'snoozed')}
className="px-2 py-1 text-[10px] font-medium bg-purple-600 text-white rounded hover:bg-purple-700 transition"
>
Snooze
</button>
<button
onClick={() => handleAction(lead.id, 'rejected')}
className="px-2 py-1 text-[10px] font-medium bg-gray-400 text-white rounded hover:bg-gray-500 transition"
>
Reject
</button>
</div>
)}
</div>
);
})}
</div>
);
}

View File

@@ -1,2 +1,3 @@
export { AccountNotes } from './AccountNotes'; export { AccountNotes } from './AccountNotes';
export { ContactMap } from './ContactMap'; export { ContactMap } from './ContactMap';
export { AccountLeads } from './AccountLeads';

View File

@@ -84,9 +84,18 @@ function AccountsIcon({ className }: { className?: string }) {
); );
} }
function LeadsIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />
</svg>
);
}
const navItems = [ const navItems = [
{ href: '/', label: 'Executive Overview', icon: DashboardIcon, shortLabel: 'Overview' }, { href: '/', label: 'Executive Overview', icon: DashboardIcon, shortLabel: 'Overview' },
{ href: '/accounts', label: 'Accounts', icon: AccountsIcon, shortLabel: 'Accounts' }, { 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: '/activity', label: 'Activities', icon: ActivityIcon, shortLabel: 'Activities' },
{ href: '/pipeline', label: 'Opportunities', icon: PipelineIcon, shortLabel: 'Opps' }, { href: '/pipeline', label: 'Opportunities', icon: PipelineIcon, shortLabel: 'Opps' },
{ href: '/implementation', label: 'Implementation', icon: ImplementIcon, shortLabel: 'Implement' }, { href: '/implementation', label: 'Implementation', icon: ImplementIcon, shortLabel: 'Implement' },

View File

@@ -5,9 +5,10 @@ import { usePageTitle } from '@/lib/page-title-context';
interface PageHeaderProps { interface PageHeaderProps {
title: string; title: string;
children?: React.ReactNode;
} }
export function PageHeader({ title }: PageHeaderProps) { export function PageHeader({ title, children }: PageHeaderProps) {
const { setPageTitle } = usePageTitle(); const { setPageTitle } = usePageTitle();
useEffect(() => { useEffect(() => {
@@ -15,5 +16,11 @@ export function PageHeader({ title }: PageHeaderProps) {
return () => setPageTitle(''); return () => setPageTitle('');
}, [title, setPageTitle]); }, [title, setPageTitle]);
return null; if (!children) return null;
return (
<div className="flex items-center justify-end mb-4">
{children}
</div>
);
} }

View File

@@ -1,5 +1,6 @@
import Database from 'better-sqlite3'; import Database from 'better-sqlite3';
import path from 'path'; import path from 'path';
import fs from 'fs';
import { import {
PipelineRecord, PipelineRecord,
AccountRecord, AccountRecord,
@@ -17,6 +18,8 @@ import {
DistrictTarget, DistrictTarget,
ForecastLock, ForecastLock,
ForecastOverride, ForecastOverride,
Lead,
LeadRun,
} from '@/types/data'; } from '@/types/data';
const DB_PATH = path.join(process.cwd(), 'data', 'campaign.db'); 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_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_pipeline_snap_date ON pipeline_snapshots(snapshot_date);
CREATE INDEX IF NOT EXISTS idx_playbook_progress_account ON playbook_progress(Account_Name); 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); 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<string, unknown>[];
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<string, unknown> = {};
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<string, unknown> {
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<LeadRun>) {
const sets: string[] = [];
const params: Record<string, unknown> = { 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<string, string> {
const rows = getDb().prepare('SELECT key, value FROM leads_config').all() as { key: string; value: string }[];
const config: Record<string, string> = {};
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<string, unknown>[] } = { accounts: [] };
if (fs.existsSync(accountsPath)) {
try { existingData = JSON.parse(fs.readFileSync(accountsPath, 'utf-8')); } catch { /* fresh start */ }
}
const lookup = new Map<string, Record<string, unknown>>();
for (const a of existingData.accounts) {
lookup.set(a.accountName as string, a);
}
const merged: Record<string, unknown>[] = [];
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;
}

View File

@@ -231,5 +231,47 @@ export const IMPLEMENTATION_STAGES = ['Not Started', 'In Progress', 'Complete',
export const ACTIVITY_TYPES = [ export const ACTIVITY_TYPES = [
'Launch Briefing', 'Discovery', 'QBR Attach', 'Exec Meeting', 'Launch Briefing', 'Discovery', 'QBR Attach', 'Exec Meeting',
'Demo', 'Workshop', 'Email', 'Call' 'Demo', 'Workshop', 'Email', 'Call', 'Lead Follow-up'
] as const; ] 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;
}