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