Implement Phase 2 features for Campaign Command Center

Adds 14 new features across the dashboard:
- Deal Risk Scoring (0-100) with risk factors on pipeline deals
- Account Health Score with multi-factor analysis on account cards
- Next Best Action Engine on Executive Overview
- Account Notes with pin/edit/delete in activity overlay and account detail
- Stakeholder Contact Map with role, sentiment, email, LinkedIn
- Competitive Intelligence Tracker with multi-select competitors and filtering
- CSV/Excel Export on accounts, activities, and pipeline pages
- Data Quality Dashboard in admin with completeness scoring
- Campaign Playbook Templates with 5 default plays and account progress tracking
- Goals & District Targets with quarterly tracking and progress bars
- Weekly Status Report (printable) and Executive Summary (printable)
- Activity Effectiveness Scoring showing pipeline conversion by activity type
- Mobile Activity FAB for quick activity logging on mobile
- Multi-User Auth (NextAuth + Google OAuth, @broadcom.com domain, role-based)

Also adds Phase 2 API route, scoring utilities, sidebar navigation updates,
and database schema extensions for notes, contacts, snapshots, playbooks,
and district targets.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-08-31 18:07:46 -04:00
parent 3c8c8ee594
commit d2d5632086
28 changed files with 3541 additions and 25 deletions

11
.env.example Normal file
View File

@@ -0,0 +1,11 @@
# Google Apps Script URL (provides live sheet data — no Cloud project needed)
# See GOOGLE_SHEETS_SETUP.md for how to deploy the Apps Script
APPS_SCRIPT_URL=https://script.google.com/macros/s/YOUR_DEPLOYMENT_ID/exec
# ISR revalidation interval in seconds (default: 300 = 5 minutes)
REVALIDATE_INTERVAL=300
# Google OAuth (optional — leave unset to disable auth in dev)
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
AUTH_SECRET= # generate with: openssl rand -base64 32

View File

@@ -8,6 +8,10 @@ import { formatCurrency, CHART_COLORS, STATUS_COLORS, DISTRICT_SHORT, STAGE_COLO
import { useMemo, useState } from 'react';
import { parseISO, format, differenceInDays, eachDayOfInterval, addMonths, addQuarters } from 'date-fns';
import { AccountRecord } from '@/types/data';
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 {
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell,
} from 'recharts';
@@ -29,6 +33,15 @@ const PRIORITY_COLORS: Record<string, string> = {
'Low': '#6B7280',
};
const COMPETITOR_COLORS: Record<string, string> = {
'Microsoft': '#00A4EF',
'Okta': '#00297A',
'Google': '#34A853',
'Sailpoint': '#0033A0',
'AWS': '#FF9900',
'Other': '#6B7280',
};
const RENEWAL_FILTER_OPTIONS = [
{ label: 'All Renewals', value: 'all' },
{ label: 'Next 90 Days', value: '90d' },
@@ -96,6 +109,7 @@ export default function AccountExplorer() {
const [imsbaFilter, setImsbaFilter] = useState<string | null>(null);
const [renewalFilter, setRenewalFilter] = useState<string>('all');
const [priorityFilter, setPriorityFilter] = useState<string | null>(null);
const [competitorFilter, setCompetitorFilter] = useState<string | null>(null);
const [selectedAccount, setSelectedAccount] = useState<AccountRecord | null>(null);
const adValues = useMemo(() => Array.from(new Set(accounts.map(a => a.AD).filter(Boolean))).sort() as string[], [accounts]);
@@ -114,6 +128,10 @@ export default function AccountExplorer() {
if (adFilter) result = result.filter(a => a.AD === adFilter);
if (imsbaFilter) result = result.filter(a => a.IMS_BA === imsbaFilter);
if (priorityFilter) result = result.filter(a => a.Priority === priorityFilter);
if (competitorFilter) result = result.filter(a => {
const comps = (a.Competitors || '').split(',').map(c => c.trim()).filter(Boolean);
return comps.includes(competitorFilter);
});
if (renewalFilter !== 'all') {
if (renewalFilter === 'none') {
@@ -132,7 +150,7 @@ export default function AccountExplorer() {
}
}
return result;
}, [accounts, search, tierFilter, statusFilter, districtFilter, adFilter, imsbaFilter, renewalFilter, priorityFilter]);
}, [accounts, search, tierFilter, statusFilter, districtFilter, adFilter, imsbaFilter, renewalFilter, priorityFilter, competitorFilter]);
const kpis = useMemo(() => {
const acctNames = new Set(filteredAccounts.map(a => a.Account_Name));
@@ -210,14 +228,16 @@ export default function AccountExplorer() {
setAdFilter(null);
setImsbaFilter(null);
setPriorityFilter(null);
setCompetitorFilter(null);
setRenewalFilter('all');
setSearch('');
};
const hasFilters = tierFilter || statusFilter || districtFilter || adFilter || imsbaFilter || priorityFilter || renewalFilter !== 'all' || search;
const hasFilters = tierFilter || statusFilter || districtFilter || adFilter || imsbaFilter || priorityFilter || competitorFilter || renewalFilter !== 'all' || search;
if (selectedAccount) {
const suggestion = getSuggestedPriority(selectedAccount);
const detailHealth = scoreAccountHealth(selectedAccount, activities, pipeline);
return (
<div>
<button onClick={() => setSelectedAccount(null)} className="flex items-center gap-1.5 text-xs text-brand-azure hover:text-brand-navy mb-4 transition">
@@ -234,6 +254,9 @@ export default function AccountExplorer() {
<span className="px-2 py-0.5 rounded-full text-[10px] text-white font-semibold" style={{ backgroundColor: TIER_COLORS[selectedAccount.Tier] || '#94A3B8' }}>{selectedAccount.Tier || 'N/A'}</span>
<span className="px-2 py-0.5 rounded-full text-[10px] text-white font-semibold" style={{ backgroundColor: STATUS_COLORS[selectedAccount.AgentMinder_Status] || '#94A3B8' }}>{selectedAccount.AgentMinder_Status}</span>
<span className="px-2 py-0.5 rounded-full text-[10px] text-white font-semibold" style={{ backgroundColor: PRIORITY_COLORS[selectedAccount.Priority] || '#94A3B8' }}>{selectedAccount.Priority} Priority</span>
<span className="px-2 py-0.5 rounded-full text-[10px] font-bold" style={{ color: HEALTH_LEVEL_COLORS[detailHealth.health_level], backgroundColor: `${HEALTH_LEVEL_COLORS[detailHealth.health_level]}15` }}>
Health: {detailHealth.health_level} ({detailHealth.health_score})
</span>
<span className="text-xs text-muted">{DISTRICT_SHORT[selectedAccount.District_Name]}</span>
</div>
</div>
@@ -298,6 +321,17 @@ export default function AccountExplorer() {
</div>
</div>
{selectedAccount.Competitors && (
<div className="mt-3 pt-3 border-t border-card-border">
<div className="text-[10px] text-muted uppercase mb-1.5">Competitors</div>
<div className="flex flex-wrap gap-1.5">
{selectedAccount.Competitors.split(',').map(c => c.trim()).filter(Boolean).map(comp => (
<span key={comp} className="px-2 py-0.5 rounded-full text-[10px] text-white font-medium" style={{ backgroundColor: COMPETITOR_COLORS[comp] || '#6B7280' }}>{comp}</span>
))}
</div>
</div>
)}
{(selectedAccount.AD || selectedAccount.IMS_BA || selectedAccount.Area_Sales_Leader || selectedAccount.DM) && (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-3 pt-3 border-t border-card-border">
{selectedAccount.Area_Sales_Leader && (
@@ -354,6 +388,12 @@ export default function AccountExplorer() {
)}
</div>
{/* Account Notes & Contacts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<AccountNotes accountName={selectedAccount.Account_Name} />
<ContactMap accountName={selectedAccount.Account_Name} />
</div>
{/* Activity Timeline */}
<div className="bg-card-bg rounded-xl border border-card-border p-5">
<h3 className="text-sm font-semibold mb-3">Activity Timeline ({accountActivities.length} activities)</h3>
@@ -444,7 +484,7 @@ export default function AccountExplorer() {
<BarChart data={priorityChartData} layout="vertical" margin={{ left: 0, right: 10, top: 0, bottom: 0 }}>
<XAxis type="number" hide />
<YAxis type="category" dataKey="name" width={55} tick={{ fontSize: 11 }} axisLine={false} tickLine={false} />
<Tooltip formatter={(value: number) => [value, 'Accounts']} />
<Tooltip formatter={(value) => [Number(value), 'Accounts']} />
<Bar dataKey="value" radius={[0, 4, 4, 0]} barSize={20}>
{priorityChartData.map(entry => (
<Cell key={entry.name} fill={PRIORITY_COLORS[entry.name] || '#94A3B8'} />
@@ -515,6 +555,7 @@ export default function AccountExplorer() {
onChange={e => setSearch(e.target.value)}
className="text-xs border border-card-border rounded-lg px-3 py-2 w-56 focus:outline-none focus:ring-2 focus:ring-brand-azure/30"
/>
<ExportButton table="accounts" />
{['Tier 1', 'Tier 2', 'Tier 3', 'Tier 4'].map(t => (
<button
key={t}
@@ -586,6 +627,16 @@ export default function AccountExplorer() {
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
<select
value={competitorFilter || ''}
onChange={e => setCompetitorFilter(e.target.value || null)}
className="text-xs border border-card-border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-brand-azure/30 bg-white"
>
<option value="">All Competitors</option>
{['Microsoft', 'Okta', 'Google', 'Sailpoint', 'AWS', 'Other'].map(c => (
<option key={c} value={c}>{c}</option>
))}
</select>
{hasFilters && (
<button onClick={clearAllFilters} className="px-3 py-1.5 rounded-lg text-xs font-medium text-muted hover:text-foreground hover:bg-gray-100 transition">
Clear all
@@ -599,6 +650,7 @@ export default function AccountExplorer() {
const stats = getAccountStats(account);
const suggestion = getSuggestedPriority(account);
const mismatch = suggestion.level !== account.Priority;
const health = scoreAccountHealth(account, activities, pipeline);
return (
<div
key={account.Account_Name}
@@ -632,12 +684,24 @@ export default function AccountExplorer() {
</div>
)}
</div>
{account.Competitors && (
<div className="flex flex-wrap gap-1 mb-2">
{account.Competitors.split(',').map(c => c.trim()).filter(Boolean).map(comp => (
<span key={comp} className="px-1.5 py-0.5 rounded text-[8px] text-white font-medium" style={{ backgroundColor: COMPETITOR_COLORS[comp] || '#6B7280' }}>{comp}</span>
))}
</div>
)}
<div className="flex items-center gap-2 mb-2">
{mismatch && (
<div className="flex items-center gap-1 mb-2 text-[10px]" style={{ color: PRIORITY_COLORS[suggestion.level] }}>
<div className="flex items-center gap-1 text-[10px]" style={{ color: PRIORITY_COLORS[suggestion.level] }}>
<svg className="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 9v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
Suggested: {suggestion.level}
</div>
)}
<span className="ml-auto px-1.5 py-0.5 rounded text-[9px] font-bold" style={{ color: HEALTH_LEVEL_COLORS[health.health_level], backgroundColor: `${HEALTH_LEVEL_COLORS[health.health_level]}15` }}>
{health.health_level} ({health.health_score})
</span>
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div>
<div className="text-[10px] text-muted">ARR</div>

View File

@@ -11,6 +11,9 @@ import {
} from 'recharts';
import { parseISO, format, startOfWeek, differenceInDays, eachDayOfInterval } from 'date-fns';
import { ActivityRecord } from '@/types/data';
import { ExportButton } from '@/components/ui/ExportButton';
import { AccountNotes } from '@/components/account/AccountNotes';
import { ContactMap } from '@/components/account/ContactMap';
const TYPE_COLORS: Record<string, string> = {
'Launch Briefing': CHART_COLORS.navy,
@@ -310,9 +313,60 @@ export default function ActivityDeepDive() {
</ChartCard>
</div>
{/* Activity Effectiveness */}
<ChartCard title="Activity Effectiveness" subtitle="Which activities drive pipeline outcomes?">
<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 text-muted font-medium">Activity Type</th>
<th className="text-right py-2 px-2 text-muted font-medium">Count</th>
<th className="text-right py-2 px-2 text-muted font-medium">Unique Accts</th>
<th className="text-right py-2 px-2 text-muted font-medium">Accts w/ Pipeline</th>
<th className="text-right py-2 px-2 text-muted font-medium">Conversion</th>
<th className="text-right py-2 px-2 text-muted font-medium">Pipeline $</th>
</tr>
</thead>
<tbody>
{(() => {
const types = Array.from(new Set(activities.map(a => a.Activity_Type)));
const pipelineAccts = new Set(pipeline.filter(p => p.Stage !== '07-Closed Lost').map(p => p.Account_Name));
return types.map(type => {
const typeActivities = activities.filter(a => a.Activity_Type === type);
const uniqueAccts = new Set(typeActivities.map(a => a.Account_Name));
const acctsWithPipeline = Array.from(uniqueAccts).filter(a => pipelineAccts.has(a));
const convRate = uniqueAccts.size > 0 ? (acctsWithPipeline.length / uniqueAccts.size) * 100 : 0;
const pipelineVal = pipeline.filter(p => acctsWithPipeline.includes(p.Account_Name) && p.Stage !== '06-Closed Won' && p.Stage !== '07-Closed Lost').reduce((s, p) => s + p.Amount_USD, 0);
return { type, count: typeActivities.length, uniqueAccts: uniqueAccts.size, acctsWithPipeline: acctsWithPipeline.length, convRate, pipelineVal };
}).sort((a, b) => b.convRate - a.convRate);
})().map(row => (
<tr key={row.type} className="border-b border-card-border/50">
<td className="py-2 px-2">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: TYPE_COLORS[row.type] || '#94A3B8' }} />
<span className="font-medium">{row.type}</span>
</div>
</td>
<td className="py-2 px-2 text-right">{row.count}</td>
<td className="py-2 px-2 text-right">{row.uniqueAccts}</td>
<td className="py-2 px-2 text-right">{row.acctsWithPipeline}</td>
<td className="py-2 px-2 text-right">
<span className={`font-medium ${row.convRate >= 50 ? 'text-green-600' : row.convRate >= 25 ? 'text-amber-600' : 'text-muted'}`}>
{row.convRate.toFixed(0)}%
</span>
</td>
<td className="py-2 px-2 text-right font-medium">{row.pipelineVal > 0 ? `$${Math.round(row.pipelineVal / 1000)}K` : '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
</ChartCard>
{/* Activity Detail Table */}
<ChartCard title="Activity Detail" subtitle={`${filteredActivities.length} activities`}
action={
<div className="flex items-center gap-2">
<input
type="text"
placeholder="Search activities..."
@@ -320,6 +374,8 @@ export default function ActivityDeepDive() {
onChange={e => { setSearchQuery(e.target.value); setPage(0); }}
className="text-xs border border-card-border rounded-lg px-3 py-1.5 w-48 focus:outline-none focus:ring-2 focus:ring-brand-azure/30"
/>
<ExportButton table="activities" />
</div>
}
>
<div className="overflow-x-auto">
@@ -524,6 +580,12 @@ export default function ActivityDeepDive() {
</div>
)}
{/* Account Notes */}
<AccountNotes accountName={selectedActivity.Account_Name} />
{/* Stakeholder Map */}
<ContactMap accountName={selectedActivity.Account_Name} />
{/* Open Opportunities */}
{overlayData.openPipeline.length > 0 && (
<div>

View File

@@ -0,0 +1,293 @@
'use client';
import { useState, useEffect, useMemo, useCallback } from 'react';
import { useData } from '@/lib/data-context';
import { PageHeader } from '@/components/ui/PageHeader';
import { ChartCard } from '@/components/ui/ChartCard';
import { DISTRICTS } from '@/types/data';
import { subDays, parseISO, startOfWeek, endOfWeek, format } from 'date-fns';
interface DistrictTarget {
id?: number;
District_Name: string;
quarter: string;
activities_per_week: number;
accounts_touched: number;
pipeline_generated: number;
}
function getCurrentQuarter(): string {
const now = new Date();
const q = Math.ceil((now.getMonth() + 1) / 3);
return `FY${now.getFullYear().toString().slice(-2)}Q${q}`;
}
function getQuarterOptions(): string[] {
const now = new Date();
const quarters: string[] = [];
for (let offset = -1; offset <= 3; offset++) {
const d = new Date(now.getFullYear(), now.getMonth() + offset * 3, 1);
const q = Math.ceil((d.getMonth() + 1) / 3);
const label = `FY${d.getFullYear().toString().slice(-2)}Q${q}`;
if (!quarters.includes(label)) quarters.push(label);
}
return quarters;
}
const DISTRICT_SHORT: Record<string, string> = {
'SE-SUNSHINE': 'Sunshine',
'SE-PEACHTREE': 'Peachtree',
'SE-MISS-VALLEY': 'Miss Valley',
'SE-MID-ATL': 'Mid-Atlantic',
};
export default function GoalsPage() {
const { filtered } = useData();
const { activities, accounts, pipeline } = filtered;
const [quarter, setQuarter] = useState(getCurrentQuarter());
const [targets, setTargets] = useState<DistrictTarget[]>([]);
const [editing, setEditing] = useState<string | null>(null);
const [editForm, setEditForm] = useState<DistrictTarget | null>(null);
const [saving, setSaving] = useState(false);
const fetchTargets = useCallback(async () => {
const res = await fetch('/api/phase2', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'targets.list', quarter }),
});
const data = await res.json();
setTargets(data.data || []);
}, [quarter]);
useEffect(() => { fetchTargets(); }, [fetchTargets]);
const actuals = useMemo(() => {
const now = new Date();
const weekStart = startOfWeek(now, { weekStartsOn: 1 });
const fourWeeksAgo = subDays(weekStart, 28);
const byDistrict: Record<string, { activitiesThisWeek: number; activitiesLast4Weeks: number; accountsTouched: number; pipelineGenerated: number }> = {};
for (const d of DISTRICTS) {
const dActivities = activities.filter(a => a.District_Name === d);
const thisWeek = dActivities.filter(a => a.Activity_Date >= format(weekStart, 'yyyy-MM-dd'));
const last4 = dActivities.filter(a => a.Activity_Date >= format(fourWeeksAgo, 'yyyy-MM-dd'));
const touched = new Set(dActivities.map(a => a.Account_Name)).size;
const totalAccts = accounts.filter(a => a.District_Name === d).length;
const pipelineVal = pipeline
.filter(p => p.District_Name === d && p.Stage !== '06-Closed Won' && p.Stage !== '07-Closed Lost')
.reduce((s, p) => s + p.Amount_USD, 0);
byDistrict[d] = {
activitiesThisWeek: thisWeek.length,
activitiesLast4Weeks: last4.length / 4,
accountsTouched: touched,
pipelineGenerated: pipelineVal,
};
}
return byDistrict;
}, [activities, accounts, pipeline]);
const handleEdit = (district: string) => {
const existing = targets.find(t => t.District_Name === district);
setEditForm(existing || { District_Name: district, quarter, activities_per_week: 10, accounts_touched: 5, pipeline_generated: 500000 });
setEditing(district);
};
const handleSave = async () => {
if (!editForm) return;
setSaving(true);
await fetch('/api/phase2', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'targets.upsert', ...editForm }),
});
setSaving(false);
setEditing(null);
fetchTargets();
};
const getProgress = (actual: number, target: number) => {
if (target <= 0) return 0;
return Math.min(Math.round((actual / target) * 100), 100);
};
const getProgressColor = (pct: number) => {
if (pct >= 80) return '#16A34A';
if (pct >= 50) return '#F59E0B';
return '#DC2626';
};
return (
<div className="space-y-6">
<PageHeader title="Goals & Targets" subtitle="Set and track district performance targets" />
<div className="flex items-center gap-3">
<label className="text-sm font-medium text-muted">Quarter</label>
<select
value={quarter}
onChange={e => setQuarter(e.target.value)}
className="px-3 py-1.5 rounded-lg border border-card-border bg-card-bg text-sm text-foreground"
>
{getQuarterOptions().map(q => <option key={q} value={q}>{q}</option>)}
</select>
</div>
{/* District Cards */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{DISTRICTS.map(district => {
const target = targets.find(t => t.District_Name === district);
const actual = actuals[district] || { activitiesThisWeek: 0, activitiesLast4Weeks: 0, accountsTouched: 0, pipelineGenerated: 0 };
const isEditing = editing === district;
return (
<ChartCard key={district} title={DISTRICT_SHORT[district] || district}>
{isEditing && editForm ? (
<div className="space-y-3 py-2">
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-[10px] font-semibold text-muted uppercase mb-1">Activities/Week</label>
<input
type="number"
value={editForm.activities_per_week}
onChange={e => setEditForm({ ...editForm, activities_per_week: Number(e.target.value) })}
className="w-full px-2 py-1.5 border border-card-border rounded-lg text-sm bg-card-bg text-foreground"
/>
</div>
<div>
<label className="block text-[10px] font-semibold text-muted uppercase mb-1">Accounts Touched</label>
<input
type="number"
value={editForm.accounts_touched}
onChange={e => setEditForm({ ...editForm, accounts_touched: Number(e.target.value) })}
className="w-full px-2 py-1.5 border border-card-border rounded-lg text-sm bg-card-bg text-foreground"
/>
</div>
<div>
<label className="block text-[10px] font-semibold text-muted uppercase mb-1">Pipeline ($)</label>
<input
type="number"
value={editForm.pipeline_generated}
onChange={e => setEditForm({ ...editForm, pipeline_generated: Number(e.target.value) })}
className="w-full px-2 py-1.5 border border-card-border rounded-lg text-sm bg-card-bg text-foreground"
/>
</div>
</div>
<div className="flex gap-2 justify-end">
<button onClick={() => setEditing(null)} className="px-3 py-1.5 text-xs text-muted hover:text-foreground">Cancel</button>
<button onClick={handleSave} disabled={saving} className="px-4 py-1.5 bg-[#0098C7] text-white text-xs font-semibold rounded-lg hover:bg-[#007ba3] disabled:opacity-50">
{saving ? 'Saving...' : 'Save'}
</button>
</div>
</div>
) : (
<div className="space-y-4 py-1">
{/* Metric rows */}
{[
{ label: 'Activities/Week', actual: Math.round(actual.activitiesLast4Weeks * 10) / 10, target: target?.activities_per_week || 0, format: (v: number) => v.toString() },
{ label: 'Accounts Touched', actual: actual.accountsTouched, target: target?.accounts_touched || 0, format: (v: number) => v.toString() },
{ label: 'Pipeline Generated', actual: actual.pipelineGenerated, target: target?.pipeline_generated || 0, format: (v: number) => `$${Math.round(v / 1000)}K` },
].map(metric => {
const pct = target ? getProgress(metric.actual, metric.target) : 0;
const color = getProgressColor(pct);
return (
<div key={metric.label}>
<div className="flex items-center justify-between mb-1">
<span className="text-xs font-medium text-foreground">{metric.label}</span>
<span className="text-xs text-muted">
{metric.format(metric.actual)} / {target ? metric.format(metric.target) : '—'}
</span>
</div>
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-500"
style={{ width: target ? `${pct}%` : '0%', backgroundColor: color }}
/>
</div>
{target && (
<div className="text-right mt-0.5">
<span className="text-[10px] font-bold" style={{ color }}>{pct}%</span>
</div>
)}
</div>
);
})}
<div className="pt-2 border-t border-card-border">
<button
onClick={() => handleEdit(district)}
className="text-xs text-[#0098C7] hover:text-[#007ba3] font-medium"
>
{target ? 'Edit Targets' : 'Set Targets'}
</button>
</div>
</div>
)}
</ChartCard>
);
})}
</div>
{/* Territory Summary */}
<ChartCard title="Territory Summary">
<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 font-semibold text-muted">District</th>
<th className="text-center py-2 font-semibold text-muted">Activities/Wk</th>
<th className="text-center py-2 font-semibold text-muted">Accts Touched</th>
<th className="text-center py-2 font-semibold text-muted">Pipeline</th>
<th className="text-center py-2 font-semibold text-muted">Overall</th>
</tr>
</thead>
<tbody>
{DISTRICTS.map(d => {
const target = targets.find(t => t.District_Name === d);
const actual = actuals[d] || { activitiesThisWeek: 0, activitiesLast4Weeks: 0, accountsTouched: 0, pipelineGenerated: 0 };
const metrics = target ? [
getProgress(actual.activitiesLast4Weeks, target.activities_per_week),
getProgress(actual.accountsTouched, target.accounts_touched),
getProgress(actual.pipelineGenerated, target.pipeline_generated),
] : [];
const overall = metrics.length > 0 ? Math.round(metrics.reduce((a, b) => a + b, 0) / metrics.length) : 0;
return (
<tr key={d} className="border-b border-card-border/50">
<td className="py-2.5 font-medium text-foreground">{DISTRICT_SHORT[d] || d}</td>
{target ? (
<>
<td className="text-center py-2.5">
<span className="px-2 py-0.5 rounded-full text-[10px] font-bold" style={{ color: getProgressColor(metrics[0]), backgroundColor: `${getProgressColor(metrics[0])}15` }}>
{metrics[0]}%
</span>
</td>
<td className="text-center py-2.5">
<span className="px-2 py-0.5 rounded-full text-[10px] font-bold" style={{ color: getProgressColor(metrics[1]), backgroundColor: `${getProgressColor(metrics[1])}15` }}>
{metrics[1]}%
</span>
</td>
<td className="text-center py-2.5">
<span className="px-2 py-0.5 rounded-full text-[10px] font-bold" style={{ color: getProgressColor(metrics[2]), backgroundColor: `${getProgressColor(metrics[2])}15` }}>
{metrics[2]}%
</span>
</td>
<td className="text-center py-2.5">
<span className="px-2 py-0.5 rounded-full text-[10px] font-bold" style={{ color: getProgressColor(overall), backgroundColor: `${getProgressColor(overall)}15` }}>
{overall}%
</span>
</td>
</>
) : (
<td colSpan={4} className="text-center py-2.5 text-muted italic">No targets set</td>
)}
</tr>
);
})}
</tbody>
</table>
</div>
</ChartCard>
</div>
);
}

View File

@@ -5,7 +5,8 @@ import { Scorecard } from '@/components/ui/Scorecard';
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 { useMemo } from 'react';
import { NextBestAction } from '@/components/ui/NextBestAction';
import { useMemo, useState, useEffect, useCallback } from 'react';
import {
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend,
AreaChart, Area,
@@ -163,7 +164,7 @@ export default function ExecutiveOverview() {
</ChartCard>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4">
<ChartCard title="Campaign Funnel" subtitle="Conversion through the pipeline">
<div className="space-y-2">
{funnelData.map((stage, i) => {
@@ -204,6 +205,11 @@ export default function ExecutiveOverview() {
</ResponsiveContainer>
</ChartCard>
</div>
{/* Next Best Action Engine */}
<ChartCard title="Next Best Actions" subtitle="AI-prioritized actions for your territory">
<NextBestAction accounts={accounts} activities={activities} pipeline={pipeline} limit={6} />
</ChartCard>
</div>
);
}

View File

@@ -11,9 +11,12 @@ import {
} from 'recharts';
import { parseISO, differenceInDays, format } from 'date-fns';
import { PipelineRecord } from '@/types/data';
import { ExportButton } from '@/components/ui/ExportButton';
import { scoreDealRisk, RISK_LEVEL_COLORS } from '@/lib/scoring';
function DealDetailPanel({ deal, onClose }: { deal: PipelineRecord; onClose: () => void }) {
function DealDetailPanel({ deal, onClose, activities, allDeals }: { deal: PipelineRecord; onClose: () => void; activities: { Account_Name: string; Activity_Date: string }[]; allDeals: PipelineRecord[] }) {
const risk = scoreDealRisk(deal, activities as Parameters<typeof scoreDealRisk>[1], allDeals);
return (
<div className="fixed inset-y-0 right-0 w-full max-w-md bg-white shadow-2xl z-50 overflow-y-auto">
<div className="sticky top-0 bg-white border-b border-card-border px-5 py-4 flex items-center justify-between">
@@ -23,6 +26,23 @@ function DealDetailPanel({ deal, onClose }: { deal: PipelineRecord; onClose: ()
</button>
</div>
<div className="p-5 space-y-5">
{/* Deal Risk Score */}
{risk.risk_score > 0 && (
<div className="rounded-xl p-3 border" style={{ borderColor: RISK_LEVEL_COLORS[risk.risk_level], backgroundColor: `${RISK_LEVEL_COLORS[risk.risk_level]}10` }}>
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-semibold" style={{ color: RISK_LEVEL_COLORS[risk.risk_level] }}>Deal Risk: {risk.risk_level}</span>
<span className="text-lg font-bold" style={{ color: RISK_LEVEL_COLORS[risk.risk_level] }}>{risk.risk_score}</span>
</div>
<div className="h-1.5 bg-gray-200 rounded-full overflow-hidden mb-2">
<div className="h-full rounded-full" style={{ width: `${risk.risk_score}%`, backgroundColor: RISK_LEVEL_COLORS[risk.risk_level] }} />
</div>
{risk.risk_factors.map((f, i) => (
<div key={i} className="text-[11px] text-muted flex items-center gap-1.5">
<span style={{ color: RISK_LEVEL_COLORS[risk.risk_level] }}>!</span> {f}
</div>
))}
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div>
<div className="text-[10px] text-muted uppercase tracking-wider">Amount</div>
@@ -80,7 +100,7 @@ function DealDetailPanel({ deal, onClose }: { deal: PipelineRecord; onClose: ()
export default function PipelineDeepDive() {
const { filtered, setCrossFilter } = useData();
const { pipeline } = filtered;
const { pipeline, activities } = filtered;
const [selectedDeal, setSelectedDeal] = useState<PipelineRecord | null>(null);
const [stageFilter, setStageFilter] = useState<string | null>(null);
const [forecastQuickFilter, setForecastQuickFilter] = useState(false);
@@ -162,6 +182,10 @@ export default function PipelineDeepDive() {
<div>
<PageHeader title="Pipeline Deep Dive" subtitle="Where is the money and will it close?" />
<div className="flex justify-end mb-2">
<ExportButton table="pipeline" />
</div>
{/* Stage filter pills */}
<div className="flex flex-wrap gap-2 mb-4">
<button
@@ -276,11 +300,13 @@ export default function PipelineDeepDive() {
<th className="text-left py-2 px-2 text-muted font-medium hidden lg:table-cell">Next Step</th>
<th className="text-right py-2 px-2 text-muted font-medium hidden md:table-cell">Days</th>
<th className="text-left py-2 px-2 text-muted font-medium hidden lg:table-cell">Close Date</th>
<th className="text-center py-2 px-2 text-muted font-medium hidden md:table-cell">Risk</th>
</tr>
</thead>
<tbody>
{topDeals.map(deal => {
const days = differenceInDays(new Date(), parseISO(deal.Created_Date));
const risk = scoreDealRisk(deal, activities as Parameters<typeof scoreDealRisk>[1], pipeline);
return (
<tr key={deal.Opportunity_ID} className="border-b border-card-border/50 hover:bg-gray-50 cursor-pointer transition" onClick={() => setSelectedDeal(deal)}>
<td className="py-2 px-2 font-medium">{deal.Account_Name}</td>
@@ -295,6 +321,11 @@ export default function PipelineDeepDive() {
<td className="py-2 px-2 text-muted hidden lg:table-cell max-w-[160px] truncate">{deal.Next_Step || '—'}</td>
<td className={`py-2 px-2 text-right hidden md:table-cell ${days > 90 ? 'text-danger font-medium' : 'text-muted'}`}>{days}</td>
<td className="py-2 px-2 text-muted hidden lg:table-cell">{format(parseISO(deal.Expected_Close_Date), 'MMM d')}</td>
<td className="py-2 px-2 text-center hidden md:table-cell">
<span className="inline-block px-1.5 py-0.5 rounded text-[9px] font-bold text-white" style={{ backgroundColor: RISK_LEVEL_COLORS[risk.risk_level] }}>
{risk.risk_score}
</span>
</td>
</tr>
);
})}
@@ -307,7 +338,7 @@ export default function PipelineDeepDive() {
{selectedDeal && (
<>
<div className="fixed inset-0 bg-black/20 z-40" onClick={() => setSelectedDeal(null)} />
<DealDetailPanel deal={selectedDeal} onClose={() => setSelectedDeal(null)} />
<DealDetailPanel deal={selectedDeal} onClose={() => setSelectedDeal(null)} activities={activities} allDeals={pipeline} />
</>
)}
</div>

View File

@@ -0,0 +1,252 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { PageHeader } from '@/components/ui/PageHeader';
import { ChartCard } from '@/components/ui/ChartCard';
interface PlaybookStep {
step: number;
title: string;
description: string;
duration: string;
}
interface Playbook {
id: number;
play_name: string;
description: string | null;
steps_json: string;
}
interface PlaybookProgress {
id: number;
Account_Name: string;
playbook_id: number;
current_step: number;
status: string;
started_at: string;
updated_at: string;
}
const STEP_STATUS_COLORS: Record<string, string> = {
'in_progress': '#0098C7',
'completed': '#16A34A',
'paused': '#F59E0B',
'not_started': '#94A3B8',
};
export default function PlaybooksPage() {
const [playbooks, setPlaybooks] = useState<Playbook[]>([]);
const [selectedPlaybook, setSelectedPlaybook] = useState<Playbook | null>(null);
const [progress, setProgress] = useState<PlaybookProgress[]>([]);
const [assignAccount, setAssignAccount] = useState('');
const [accounts, setAccounts] = useState<string[]>([]);
const [editing, setEditing] = useState(false);
const [editForm, setEditForm] = useState({ play_name: '', description: '', steps_json: '' });
const fetchPlaybooks = useCallback(async () => {
const res = await fetch('/api/phase2', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'playbooks.list' }),
});
const data = await res.json();
setPlaybooks(data.data || []);
}, []);
useEffect(() => { fetchPlaybooks(); }, [fetchPlaybooks]);
useEffect(() => {
fetch('/api/admin', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'list', table: 'accounts' }) })
.then(r => r.json())
.then(d => setAccounts((d.data || []).map((a: { Account_Name: string }) => a.Account_Name).sort()));
}, []);
const selectPlaybook = async (pb: Playbook) => {
setSelectedPlaybook(pb);
setEditing(false);
const res = await fetch('/api/phase2', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'playbooks.progress.list', Account_Name: '__all__' }),
});
const data = await res.json();
setProgress((data.data || []).filter((p: PlaybookProgress) => p.playbook_id === pb.id));
};
const assignPlaybook = async () => {
if (!assignAccount || !selectedPlaybook) return;
await fetch('/api/phase2', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'playbooks.progress.upsert', Account_Name: assignAccount, playbook_id: selectedPlaybook.id, current_step: 1, status: 'in_progress' }),
});
setAssignAccount('');
selectPlaybook(selectedPlaybook);
};
const advanceStep = async (p: PlaybookProgress) => {
const steps: PlaybookStep[] = selectedPlaybook ? JSON.parse(selectedPlaybook.steps_json) : [];
const nextStep = p.current_step + 1;
const isComplete = nextStep > steps.length;
await fetch('/api/phase2', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'playbooks.progress.upsert',
Account_Name: p.Account_Name,
playbook_id: p.playbook_id,
current_step: isComplete ? p.current_step : nextStep,
status: isComplete ? 'completed' : 'in_progress',
}),
});
if (selectedPlaybook) selectPlaybook(selectedPlaybook);
};
const startEdit = (pb: Playbook) => {
setEditForm({ play_name: pb.play_name, description: pb.description || '', steps_json: pb.steps_json });
setEditing(true);
};
const savePlaybook = async () => {
await fetch('/api/phase2', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'playbooks.upsert', id: selectedPlaybook?.id, ...editForm }),
});
setEditing(false);
fetchPlaybooks();
if (selectedPlaybook) {
const updated = { ...selectedPlaybook, ...editForm };
setSelectedPlaybook(updated);
}
};
if (selectedPlaybook) {
const steps: PlaybookStep[] = (() => { try { return JSON.parse(selectedPlaybook.steps_json); } catch { return []; } })();
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<button onClick={() => setSelectedPlaybook(null)} className="flex items-center gap-1.5 text-xs text-brand-azure hover:text-brand-navy transition">
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="15 18 9 12 15 6" /></svg>
Back
</button>
<PageHeader title={selectedPlaybook.play_name} subtitle={selectedPlaybook.description || 'Campaign playbook'} />
</div>
{editing ? (
<ChartCard title="Edit Playbook">
<div className="space-y-3 py-2">
<div>
<label className="block text-xs font-semibold text-muted mb-1">Play Name</label>
<input value={editForm.play_name} onChange={e => setEditForm({ ...editForm, play_name: e.target.value })} className="w-full px-3 py-2 border border-card-border rounded-lg text-sm bg-card-bg text-foreground" />
</div>
<div>
<label className="block text-xs font-semibold text-muted mb-1">Description</label>
<input value={editForm.description} onChange={e => setEditForm({ ...editForm, description: e.target.value })} className="w-full px-3 py-2 border border-card-border rounded-lg text-sm bg-card-bg text-foreground" />
</div>
<div>
<label className="block text-xs font-semibold text-muted mb-1">Steps (JSON)</label>
<textarea value={editForm.steps_json} onChange={e => setEditForm({ ...editForm, steps_json: e.target.value })} rows={10} className="w-full px-3 py-2 border border-card-border rounded-lg text-xs font-mono bg-card-bg text-foreground" />
</div>
<div className="flex gap-2 justify-end">
<button onClick={() => setEditing(false)} className="px-3 py-1.5 text-xs text-muted hover:text-foreground">Cancel</button>
<button onClick={savePlaybook} className="px-4 py-1.5 bg-[#0098C7] text-white text-xs font-semibold rounded-lg hover:bg-[#007ba3]">Save</button>
</div>
</div>
</ChartCard>
) : (
<>
{/* Steps Overview */}
<ChartCard title="Playbook Steps" action={<button onClick={() => startEdit(selectedPlaybook)} className="text-xs text-[#0098C7] hover:text-[#007ba3] font-medium">Edit</button>}>
<div className="space-y-3 py-1">
{steps.map((step, i) => (
<div key={i} className="flex items-start gap-3">
<div className="w-7 h-7 rounded-full bg-[#1B1D36] text-white flex items-center justify-center text-xs font-bold flex-shrink-0">{step.step}</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-foreground">{step.title}</div>
<div className="text-xs text-muted mt-0.5">{step.description}</div>
<div className="text-[10px] text-muted mt-1">{step.duration}</div>
</div>
</div>
))}
</div>
</ChartCard>
{/* Account Progress */}
<ChartCard title="Account Progress" subtitle={`${progress.length} accounts running this play`}>
<div className="space-y-2 py-1">
{progress.map(p => {
const pct = steps.length > 0 ? Math.round(((p.status === 'completed' ? steps.length : p.current_step - 1) / steps.length) * 100) : 0;
return (
<div key={p.id} className="flex items-center gap-3 py-1.5 border-b border-card-border/50 last:border-0">
<div className="flex-1 min-w-0">
<div className="text-xs font-semibold text-foreground">{p.Account_Name}</div>
<div className="flex items-center gap-2 mt-1">
<div className="flex-1 h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div className="h-full rounded-full transition-all" style={{ width: `${pct}%`, backgroundColor: STEP_STATUS_COLORS[p.status] || '#94A3B8' }} />
</div>
<span className="text-[10px] font-medium" style={{ color: STEP_STATUS_COLORS[p.status] }}>
{p.status === 'completed' ? 'Done' : `Step ${p.current_step}/${steps.length}`}
</span>
</div>
</div>
{p.status !== 'completed' && (
<button onClick={() => advanceStep(p)} className="px-2 py-1 text-[10px] font-semibold text-[#0098C7] border border-[#0098C7] rounded hover:bg-[#0098C7]/10">
Advance
</button>
)}
</div>
);
})}
{/* Assign to account */}
<div className="flex items-center gap-2 pt-2 border-t border-card-border">
<select value={assignAccount} onChange={e => setAssignAccount(e.target.value)} className="flex-1 px-2 py-1.5 border border-card-border rounded-lg text-xs bg-card-bg text-foreground">
<option value="">Assign to account...</option>
{accounts.filter(a => !progress.some(p => p.Account_Name === a)).map(a => <option key={a} value={a}>{a}</option>)}
</select>
<button onClick={assignPlaybook} disabled={!assignAccount} className="px-3 py-1.5 bg-[#0098C7] text-white text-xs font-semibold rounded-lg hover:bg-[#007ba3] disabled:opacity-40">
Assign
</button>
</div>
</div>
</ChartCard>
</>
)}
</div>
);
}
return (
<div className="space-y-6">
<PageHeader title="Campaign Playbooks" subtitle="Standardized sales plays and account progress tracking" />
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{playbooks.map(pb => {
const steps: PlaybookStep[] = (() => { try { return JSON.parse(pb.steps_json); } catch { return []; } })();
return (
<div
key={pb.id}
onClick={() => selectPlaybook(pb)}
className="bg-card-bg rounded-xl border border-card-border p-5 cursor-pointer hover:shadow-md hover:border-brand-azure/30 transition-all"
>
<div className="flex items-center gap-2 mb-2">
<div className="w-8 h-8 rounded-lg bg-[#1B1D36] text-white flex items-center justify-center">
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" /><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" /></svg>
</div>
<h3 className="text-sm font-bold text-foreground">{pb.play_name}</h3>
</div>
{pb.description && <p className="text-xs text-muted mb-3">{pb.description}</p>}
<div className="flex items-center gap-3 text-[10px] text-muted">
<span>{steps.length} steps</span>
<span className="px-1.5 py-0.5 rounded bg-brand-azure/10 text-brand-azure font-semibold">View Play</span>
</div>
</div>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,382 @@
'use client';
import { useData } from '@/lib/data-context';
import { formatCurrency, formatPercent, DISTRICT_SHORT, STAGE_COLORS, CHART_COLORS } from '@/lib/formatters';
import { useMemo } from 'react';
import { format, subDays, parseISO, differenceInDays } from 'date-fns';
const STAGE_ORDER = [
'01-Qualified', '02-Discovery', '03-Evaluation',
'04-Business Case', '05-Negotiation',
];
const STAGE_LABELS: Record<string, string> = {
'01-Qualified': 'Qualified',
'02-Discovery': 'Discovery',
'03-Evaluation': 'Evaluation',
'04-Business Case': 'Biz Case',
'05-Negotiation': 'Negotiation',
};
export default function ExecutiveSummary() {
const { filtered, config } = useData();
const { pipeline, accounts, activities } = filtered;
const now = new Date();
const scorecard = useMemo(() => {
const totalPipeline = pipeline
.filter(p => p.Stage !== '06-Closed Won' && p.Stage !== '07-Closed Lost')
.reduce((sum, p) => sum + p.Amount_USD, 0);
const totalAccounts = accounts.length;
const touchedAccounts = accounts.filter(a => (a.Touch_Count || 0) > 0).length;
const touchRate = totalAccounts > 0 ? (touchedAccounts / totalAccounts) * 100 : 0;
const closedWon = pipeline
.filter(p => p.Stage === '06-Closed Won')
.reduce((sum, p) => sum + (p.Closed_Amount_USD || 0), 0);
const thirtyDaysAgo = format(subDays(now, 30), 'yyyy-MM-dd');
const recentActivities = activities.filter(a => a.Activity_Date >= thirtyDaysAgo).length;
const weeklyPace = recentActivities / 4.3;
const paceVsTarget = config.weeklyActivityTarget > 0
? (weeklyPace / config.weeklyActivityTarget) * 100
: 0;
return { totalPipeline, touchRate, closedWon, paceVsTarget };
}, [pipeline, accounts, activities, config, now]);
const pipelineByStage = useMemo(() => {
const open = pipeline.filter(p => p.Stage !== '06-Closed Won' && p.Stage !== '07-Closed Lost');
const total = open.reduce((sum, p) => sum + p.Amount_USD, 0);
const stageMap = new Map<string, number>();
open.forEach(p => {
stageMap.set(p.Stage, (stageMap.get(p.Stage) || 0) + p.Amount_USD);
});
return {
total,
stages: STAGE_ORDER
.filter(s => stageMap.has(s))
.map(s => ({
stage: s,
label: STAGE_LABELS[s] || s,
amount: stageMap.get(s) || 0,
pct: total > 0 ? ((stageMap.get(s) || 0) / total) * 100 : 0,
color: STAGE_COLORS[s] || CHART_COLORS.navy,
})),
};
}, [pipeline]);
const campaignProgress = useMemo(() => {
const byPriority = { High: 0, Medium: 0, Low: 0 };
accounts.forEach(a => {
if (a.Priority in byPriority) {
byPriority[a.Priority as keyof typeof byPriority]++;
}
});
const totalAccounts = accounts.length;
const touchedAccounts = accounts.filter(a => (a.Touch_Count || 0) > 0).length;
return { byPriority, totalAccounts, touchedAccounts };
}, [accounts]);
const districtPerformance = useMemo(() => {
const districts = new Map<string, { pipeline: number; activities: number; accountsTouched: Set<string>; totalAccounts: number }>();
accounts.forEach(a => {
const d = DISTRICT_SHORT[a.District_Name] || a.District_Name;
if (!districts.has(d)) districts.set(d, { pipeline: 0, activities: 0, accountsTouched: new Set(), totalAccounts: 0 });
const entry = districts.get(d)!;
entry.totalAccounts++;
if ((a.Touch_Count || 0) > 0) entry.accountsTouched.add(a.Account_Name);
});
pipeline.filter(p => p.Stage !== '06-Closed Won' && p.Stage !== '07-Closed Lost').forEach(p => {
const d = DISTRICT_SHORT[p.District_Name] || p.District_Name;
if (!districts.has(d)) districts.set(d, { pipeline: 0, activities: 0, accountsTouched: new Set(), totalAccounts: 0 });
districts.get(d)!.pipeline += p.Amount_USD;
});
activities.forEach(a => {
const d = DISTRICT_SHORT[a.District_Name] || a.District_Name;
if (!districts.has(d)) districts.set(d, { pipeline: 0, activities: 0, accountsTouched: new Set(), totalAccounts: 0 });
districts.get(d)!.activities++;
});
return Array.from(districts.entries())
.map(([name, data]) => ({
name,
pipeline: data.pipeline,
activities: data.activities,
accountsTouched: data.accountsTouched.size,
totalAccounts: data.totalAccounts,
}))
.sort((a, b) => b.pipeline - a.pipeline);
}, [pipeline, accounts, activities]);
const keyWins = useMemo(() => {
return pipeline
.filter(p => p.Stage === '06-Closed Won')
.sort((a, b) => (b.Closed_Amount_USD || 0) - (a.Closed_Amount_USD || 0))
.slice(0, 8);
}, [pipeline]);
const watchList = useMemo(() => {
const thirtyDaysAgo = format(subDays(now, 30), 'yyyy-MM-dd');
const staleAccounts = accounts
.filter(a => a.Priority === 'High' && (!a.Date_Last_Touched || a.Date_Last_Touched < thirtyDaysAgo))
.slice(0, 5);
const agingDeals = pipeline
.filter(p => {
if (p.Stage === '06-Closed Won' || p.Stage === '07-Closed Lost') return false;
if (!p.Stage_Entered_Date) return false;
const daysInStage = differenceInDays(now, parseISO(p.Stage_Entered_Date));
return daysInStage > 30;
})
.sort((a, b) => {
const dA = differenceInDays(now, parseISO(a.Stage_Entered_Date!));
const dB = differenceInDays(now, parseISO(b.Stage_Entered_Date!));
return dB - dA;
})
.slice(0, 5);
return { staleAccounts, agingDeals };
}, [accounts, pipeline, now]);
return (
<>
<style>{`
@media print {
nav, aside, header, [data-sidebar], [data-topbar], .no-print {
display: none !important;
}
main {
margin: 0 !important;
padding: 0 !important;
max-width: 100% !important;
width: 100% !important;
}
body {
background: white !important;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.report-container {
max-width: 100% !important;
padding: 0 !important;
box-shadow: none !important;
}
}
`}</style>
<div className="no-print mb-4 flex items-center gap-3">
<button
onClick={() => window.print()}
className="px-4 py-2 bg-[#005C8A] text-white rounded-lg text-sm font-medium hover:bg-[#004a6e] transition-colors"
>
Print / Save as PDF
</button>
<span className="text-xs text-muted">Use your browser&apos;s print dialog to save as PDF</span>
</div>
<div className="report-container max-w-4xl mx-auto bg-white p-8 rounded-lg shadow-sm border border-card-border">
{/* Header */}
<div className="border-b-2 border-[#005C8A] pb-4 mb-6">
<div className="flex justify-between items-start">
<div>
<h1 className="text-2xl font-bold text-[#005C8A]">AgentMinder &mdash; Executive Summary</h1>
<p className="text-sm text-gray-500 mt-1">SouthEast Territory</p>
</div>
<p className="text-sm text-gray-400">{format(now, 'MMMM d, yyyy')}</p>
</div>
</div>
{/* Territory Scorecard */}
<section className="mb-6">
<h2 className="text-xs font-bold text-gray-500 uppercase tracking-wider mb-3">Territory Scorecard</h2>
<div className="grid grid-cols-4 gap-4">
<div className="bg-[#005C8A] rounded-lg p-4 text-center text-white">
<div className="text-2xl font-bold">{formatCurrency(scorecard.totalPipeline, true)}</div>
<div className="text-xs opacity-80 mt-1">Total Pipeline</div>
</div>
<div className="bg-[#0098C7] rounded-lg p-4 text-center text-white">
<div className="text-2xl font-bold">{formatPercent(scorecard.touchRate)}</div>
<div className="text-xs opacity-80 mt-1">Accounts Touched</div>
</div>
<div className="bg-[#61A60E] rounded-lg p-4 text-center text-white">
<div className="text-2xl font-bold">{formatCurrency(scorecard.closedWon, true)}</div>
<div className="text-xs opacity-80 mt-1">Closed Won YTD</div>
</div>
<div className={`rounded-lg p-4 text-center text-white ${scorecard.paceVsTarget >= 80 ? 'bg-[#61A60E]' : scorecard.paceVsTarget >= 50 ? 'bg-amber-500' : 'bg-red-500'}`}>
<div className="text-2xl font-bold">{formatPercent(scorecard.paceVsTarget)}</div>
<div className="text-xs opacity-80 mt-1">Activity Pace vs Target</div>
</div>
</div>
</section>
{/* Pipeline Health */}
<section className="mb-6">
<h2 className="text-xs font-bold text-gray-500 uppercase tracking-wider mb-3">Pipeline Health</h2>
<div className="mb-2">
<div className="flex rounded-lg overflow-hidden h-10">
{pipelineByStage.stages.map(s => (
<div
key={s.stage}
style={{ width: `${s.pct}%`, backgroundColor: s.color, minWidth: s.pct > 0 ? '2px' : '0' }}
className="flex items-center justify-center text-white text-xs font-medium transition-all"
title={`${s.label}: ${formatCurrency(s.amount, true)}`}
>
{s.pct >= 10 && formatCurrency(s.amount, true)}
</div>
))}
</div>
</div>
<div className="flex flex-wrap gap-4 text-xs">
{pipelineByStage.stages.map(s => (
<div key={s.stage} className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: s.color }} />
<span className="text-gray-600">{s.label}</span>
<span className="font-medium text-gray-800">{formatCurrency(s.amount, true)}</span>
</div>
))}
</div>
</section>
{/* Campaign Progress + District Performance side by side */}
<div className="grid grid-cols-2 gap-6 mb-6">
{/* Campaign Progress */}
<section>
<h2 className="text-xs font-bold text-gray-500 uppercase tracking-wider mb-3">Campaign Progress</h2>
<div className="space-y-2">
{(['High', 'Medium', 'Low'] as const).map(priority => {
const count = campaignProgress.byPriority[priority];
const total = campaignProgress.totalAccounts;
const pct = total > 0 ? (count / total) * 100 : 0;
const colors = { High: CHART_COLORS.navy, Medium: CHART_COLORS.azure, Low: '#94A3B8' };
return (
<div key={priority}>
<div className="flex justify-between text-xs mb-0.5">
<span className="text-gray-600">{priority} Priority</span>
<span className="font-medium text-gray-800">{count} accounts</span>
</div>
<div className="h-4 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all"
style={{ width: `${pct}%`, backgroundColor: colors[priority] }}
/>
</div>
</div>
);
})}
</div>
<div className="mt-3 p-3 bg-gray-50 rounded-lg">
<div className="text-xs text-gray-500">Touch Rate</div>
<div className="text-lg font-bold text-[#005C8A]">
{campaignProgress.touchedAccounts} / {campaignProgress.totalAccounts}
<span className="text-sm font-normal text-gray-500 ml-1">
({formatPercent(campaignProgress.totalAccounts > 0 ? (campaignProgress.touchedAccounts / campaignProgress.totalAccounts) * 100 : 0)})
</span>
</div>
</div>
</section>
{/* District Performance */}
<section>
<h2 className="text-xs font-bold text-gray-500 uppercase tracking-wider mb-3">District Performance</h2>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200">
<th className="text-left py-1.5 font-medium text-gray-600 text-xs">District</th>
<th className="text-right py-1.5 font-medium text-gray-600 text-xs">Pipeline</th>
<th className="text-right py-1.5 font-medium text-gray-600 text-xs">Activities</th>
<th className="text-right py-1.5 font-medium text-gray-600 text-xs">Touched</th>
</tr>
</thead>
<tbody>
{districtPerformance.map(d => (
<tr key={d.name} className="border-b border-gray-100">
<td className="py-1.5 font-medium text-gray-700">{d.name}</td>
<td className="py-1.5 text-right text-gray-700">{formatCurrency(d.pipeline, true)}</td>
<td className="py-1.5 text-right text-gray-700">{d.activities}</td>
<td className="py-1.5 text-right text-gray-700">
{d.accountsTouched}/{d.totalAccounts}
</td>
</tr>
))}
</tbody>
</table>
</section>
</div>
{/* Key Wins + Watch List side by side */}
<div className="grid grid-cols-2 gap-6 mb-4">
{/* Key Wins */}
<section>
<h2 className="text-xs font-bold text-gray-500 uppercase tracking-wider mb-3">Key Wins</h2>
{keyWins.length > 0 ? (
<div className="space-y-2">
{keyWins.map(p => (
<div key={p.Opportunity_ID} className="flex items-center justify-between bg-green-50 rounded-lg px-3 py-2">
<div>
<div className="text-sm font-medium text-gray-800">{p.Account_Name}</div>
<div className="text-xs text-gray-500">{p.Product}</div>
</div>
<div className="text-sm font-bold text-green-700">
{formatCurrency(p.Closed_Amount_USD || 0, true)}
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-gray-400 italic">No closed-won deals in this period</p>
)}
</section>
{/* Watch List */}
<section>
<h2 className="text-xs font-bold text-gray-500 uppercase tracking-wider mb-3">Watch List</h2>
{watchList.staleAccounts.length > 0 && (
<div className="mb-3">
<h3 className="text-xs font-semibold text-red-600 mb-1">No Recent Activity</h3>
{watchList.staleAccounts.map(a => (
<div key={a.Account_Name} className="flex justify-between text-sm py-1 border-b border-gray-100">
<span className="text-gray-700">{a.Account_Name}</span>
<span className="text-xs text-red-500">
{a.Date_Last_Touched
? `${differenceInDays(now, parseISO(a.Date_Last_Touched))}d ago`
: 'Never'}
</span>
</div>
))}
</div>
)}
{watchList.agingDeals.length > 0 && (
<div>
<h3 className="text-xs font-semibold text-amber-600 mb-1">Aging in Stage</h3>
{watchList.agingDeals.map(p => (
<div key={p.Opportunity_ID} className="flex justify-between text-sm py-1 border-b border-gray-100">
<span className="text-gray-700">{p.Account_Name}</span>
<span className="text-xs text-amber-600">
{p.Stage_Entered_Date
? `${differenceInDays(now, parseISO(p.Stage_Entered_Date))}d in ${STAGE_LABELS[p.Stage] || p.Stage}`
: p.Stage}
</span>
</div>
))}
</div>
)}
{watchList.staleAccounts.length === 0 && watchList.agingDeals.length === 0 && (
<p className="text-sm text-green-600">No items require attention.</p>
)}
</section>
</div>
{/* Footer */}
<div className="border-t border-gray-200 pt-3 mt-6 text-xs text-gray-400 text-center">
AgentMinder Campaign Command Center &bull; Confidential
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,423 @@
'use client';
import { useData } from '@/lib/data-context';
import { formatCurrency, DISTRICT_SHORT } from '@/lib/formatters';
import { useMemo, useState } from 'react';
import { parseISO, format, startOfWeek, endOfWeek, differenceInDays } from 'date-fns';
export default function WeeklyStatusReport() {
const { filtered, raw } = useData();
const { pipeline, accounts, activities } = filtered;
const [nextWeekFocus, setNextWeekFocus] = useState('');
const now = new Date();
const weekStart = startOfWeek(now, { weekStartsOn: 1 });
const weekEnd = endOfWeek(now, { weekStartsOn: 1 });
const weekStartStr = format(weekStart, 'yyyy-MM-dd');
const weekEndStr = format(weekEnd, 'yyyy-MM-dd');
const thisWeekActivities = useMemo(() => {
return activities.filter(a => {
try {
const d = a.Activity_Date;
return d >= weekStartStr && d <= weekEndStr;
} catch {
return false;
}
});
}, [activities, weekStartStr, weekEndStr]);
const highlights = useMemo(() => {
const activitiesCount = thisWeekActivities.length;
const accountsTouched = new Set(thisWeekActivities.map(a => a.Account_Name)).size;
const newPipeline = pipeline.filter(p => {
try {
return p.Created_Date >= weekStartStr && p.Created_Date <= weekEndStr;
} catch {
return false;
}
}).reduce((sum, p) => sum + p.Amount_USD, 0);
return { activitiesCount, accountsTouched, newPipeline };
}, [thisWeekActivities, pipeline, weekStartStr, weekEndStr]);
const pipelineSummary = useMemo(() => {
const open = pipeline.filter(p => p.Stage !== '06-Closed Won' && p.Stage !== '07-Closed Lost');
const totalOpen = open.reduce((sum, p) => sum + p.Amount_USD, 0);
const byStage = new Map<string, { count: number; amount: number }>();
open.forEach(p => {
const entry = byStage.get(p.Stage) || { count: 0, amount: 0 };
entry.count++;
entry.amount += p.Amount_USD;
byStage.set(p.Stage, entry);
});
const byForecast = new Map<string, { count: number; amount: number }>();
open.forEach(p => {
const entry = byForecast.get(p.Forecast_Category) || { count: 0, amount: 0 };
entry.count++;
entry.amount += p.Amount_USD;
byForecast.set(p.Forecast_Category, entry);
});
return {
totalOpen,
byStage: Array.from(byStage.entries()).sort(([a], [b]) => a.localeCompare(b)),
byForecast: Array.from(byForecast.entries()).sort(([a], [b]) => a.localeCompare(b)),
};
}, [pipeline]);
const activitySummary = useMemo(() => {
const byType = new Map<string, number>();
const byDistrict = new Map<string, number>();
thisWeekActivities.forEach(a => {
byType.set(a.Activity_Type, (byType.get(a.Activity_Type) || 0) + 1);
const d = DISTRICT_SHORT[a.District_Name] || a.District_Name;
byDistrict.set(d, (byDistrict.get(d) || 0) + 1);
});
return {
byType: Array.from(byType.entries()).sort(([, a], [, b]) => b - a),
byDistrict: Array.from(byDistrict.entries()).sort(([, a], [, b]) => b - a),
};
}, [thisWeekActivities]);
const topAccountsTouched = useMemo(() => {
const accountMap = new Map<string, { type: string; date: string; contact: string | null }[]>();
thisWeekActivities.forEach(a => {
if (!accountMap.has(a.Account_Name)) accountMap.set(a.Account_Name, []);
accountMap.get(a.Account_Name)!.push({
type: a.Activity_Type,
date: a.Activity_Date,
contact: a.Contact_Name,
});
});
return Array.from(accountMap.entries())
.sort(([, a], [, b]) => b.length - a.length)
.slice(0, 15);
}, [thisWeekActivities]);
const risks = useMemo(() => {
const thirtyDaysAgo = format(new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000), 'yyyy-MM-dd');
const today = format(now, 'yyyy-MM-dd');
const staleHighPriority = accounts.filter(a =>
a.Priority === 'High' &&
(!a.Date_Last_Touched || a.Date_Last_Touched < thirtyDaysAgo)
);
const pastDueDeals = pipeline
.filter(p =>
p.Stage !== '06-Closed Won' &&
p.Stage !== '07-Closed Lost' &&
p.Expected_Close_Date < today
)
.sort((a, b) => a.Expected_Close_Date.localeCompare(b.Expected_Close_Date));
return { staleHighPriority, pastDueDeals };
}, [accounts, pipeline, now]);
return (
<>
<style>{`
@media print {
nav, aside, header, [data-sidebar], [data-topbar], .no-print {
display: none !important;
}
main {
margin: 0 !important;
padding: 0 !important;
max-width: 100% !important;
width: 100% !important;
}
body {
background: white !important;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.report-container {
max-width: 100% !important;
padding: 0 !important;
box-shadow: none !important;
}
}
`}</style>
<div className="no-print mb-4 flex items-center gap-3">
<button
onClick={() => window.print()}
className="px-4 py-2 bg-[#005C8A] text-white rounded-lg text-sm font-medium hover:bg-[#004a6e] transition-colors"
>
Print / Save as PDF
</button>
<span className="text-xs text-muted">Use your browser&apos;s print dialog to save as PDF</span>
</div>
<div className="report-container max-w-4xl mx-auto bg-white p-8 rounded-lg shadow-sm border border-card-border">
{/* Header */}
<div className="border-b-2 border-[#005C8A] pb-4 mb-6">
<h1 className="text-2xl font-bold text-[#005C8A]">AgentMinder Campaign Status Report</h1>
<div className="flex justify-between items-end mt-2">
<div>
<p className="text-sm text-gray-600">Territory: <span className="font-semibold text-gray-900">SouthEast</span></p>
<p className="text-sm text-gray-600">
Week of {format(weekStart, 'MMMM d')} &ndash; {format(weekEnd, 'MMMM d, yyyy')}
</p>
</div>
<p className="text-xs text-gray-400">Generated {format(now, 'MMMM d, yyyy h:mm a')}</p>
</div>
</div>
{/* This Week's Highlights */}
<section className="mb-6">
<h2 className="text-base font-bold text-gray-900 uppercase tracking-wide border-b border-gray-200 pb-1 mb-3">
This Week&apos;s Highlights
</h2>
<div className="grid grid-cols-3 gap-4">
<div className="bg-gray-50 rounded-lg p-4 text-center">
<div className="text-2xl font-bold text-[#005C8A]">{highlights.activitiesCount}</div>
<div className="text-xs text-gray-500 mt-1">Activities Logged</div>
</div>
<div className="bg-gray-50 rounded-lg p-4 text-center">
<div className="text-2xl font-bold text-[#005C8A]">{highlights.accountsTouched}</div>
<div className="text-xs text-gray-500 mt-1">Accounts Touched</div>
</div>
<div className="bg-gray-50 rounded-lg p-4 text-center">
<div className="text-2xl font-bold text-[#005C8A]">{formatCurrency(highlights.newPipeline, true)}</div>
<div className="text-xs text-gray-500 mt-1">New Pipeline Added</div>
</div>
</div>
</section>
{/* Pipeline Summary */}
<section className="mb-6">
<h2 className="text-base font-bold text-gray-900 uppercase tracking-wide border-b border-gray-200 pb-1 mb-3">
Pipeline Summary
</h2>
<p className="text-sm text-gray-700 mb-3">
Total Open Pipeline: <span className="font-bold text-[#005C8A]">{formatCurrency(pipelineSummary.totalOpen)}</span>
</p>
<div className="grid grid-cols-2 gap-4">
<div>
<h3 className="text-xs font-semibold text-gray-500 uppercase mb-2">By Stage</h3>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200">
<th className="text-left py-1 font-medium text-gray-600">Stage</th>
<th className="text-right py-1 font-medium text-gray-600">Opps</th>
<th className="text-right py-1 font-medium text-gray-600">Amount</th>
</tr>
</thead>
<tbody>
{pipelineSummary.byStage.map(([stage, data]) => (
<tr key={stage} className="border-b border-gray-100">
<td className="py-1 text-gray-700">{stage}</td>
<td className="py-1 text-right text-gray-700">{data.count}</td>
<td className="py-1 text-right text-gray-700">{formatCurrency(data.amount, true)}</td>
</tr>
))}
</tbody>
</table>
</div>
<div>
<h3 className="text-xs font-semibold text-gray-500 uppercase mb-2">By Forecast Category</h3>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200">
<th className="text-left py-1 font-medium text-gray-600">Category</th>
<th className="text-right py-1 font-medium text-gray-600">Opps</th>
<th className="text-right py-1 font-medium text-gray-600">Amount</th>
</tr>
</thead>
<tbody>
{pipelineSummary.byForecast.map(([cat, data]) => (
<tr key={cat} className="border-b border-gray-100">
<td className="py-1 text-gray-700">{cat}</td>
<td className="py-1 text-right text-gray-700">{data.count}</td>
<td className="py-1 text-right text-gray-700">{formatCurrency(data.amount, true)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</section>
{/* Activity Summary */}
<section className="mb-6">
<h2 className="text-base font-bold text-gray-900 uppercase tracking-wide border-b border-gray-200 pb-1 mb-3">
Activity Summary
</h2>
<div className="grid grid-cols-2 gap-4">
<div>
<h3 className="text-xs font-semibold text-gray-500 uppercase mb-2">By Type</h3>
<table className="w-full text-sm">
<tbody>
{activitySummary.byType.map(([type, count]) => (
<tr key={type} className="border-b border-gray-100">
<td className="py-1 text-gray-700">{type}</td>
<td className="py-1 text-right font-medium text-gray-900">{count}</td>
</tr>
))}
{activitySummary.byType.length === 0 && (
<tr><td colSpan={2} className="py-2 text-gray-400 text-center italic">No activities this week</td></tr>
)}
</tbody>
</table>
</div>
<div>
<h3 className="text-xs font-semibold text-gray-500 uppercase mb-2">By District</h3>
<table className="w-full text-sm">
<tbody>
{activitySummary.byDistrict.map(([district, count]) => (
<tr key={district} className="border-b border-gray-100">
<td className="py-1 text-gray-700">{district}</td>
<td className="py-1 text-right font-medium text-gray-900">{count}</td>
</tr>
))}
{activitySummary.byDistrict.length === 0 && (
<tr><td colSpan={2} className="py-2 text-gray-400 text-center italic">No activities this week</td></tr>
)}
</tbody>
</table>
</div>
</div>
</section>
{/* Top Accounts Touched This Week */}
<section className="mb-6">
<h2 className="text-base font-bold text-gray-900 uppercase tracking-wide border-b border-gray-200 pb-1 mb-3">
Top Accounts Touched This Week
</h2>
{topAccountsTouched.length > 0 ? (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200">
<th className="text-left py-1 font-medium text-gray-600">Account</th>
<th className="text-left py-1 font-medium text-gray-600">Activity Type</th>
<th className="text-left py-1 font-medium text-gray-600">Contact</th>
<th className="text-right py-1 font-medium text-gray-600">Date</th>
</tr>
</thead>
<tbody>
{topAccountsTouched.map(([account, touches]) =>
touches.map((t, i) => (
<tr key={`${account}-${i}`} className="border-b border-gray-100">
<td className="py-1 text-gray-700 font-medium">{i === 0 ? account : ''}</td>
<td className="py-1 text-gray-700">{t.type}</td>
<td className="py-1 text-gray-700">{t.contact || '—'}</td>
<td className="py-1 text-right text-gray-500">{format(parseISO(t.date), 'MMM d')}</td>
</tr>
))
)}
</tbody>
</table>
) : (
<p className="text-sm text-gray-400 italic">No accounts touched this week</p>
)}
</section>
{/* Risks & Attention Items */}
<section className="mb-6">
<h2 className="text-base font-bold text-gray-900 uppercase tracking-wide border-b border-gray-200 pb-1 mb-3">
Risks &amp; Attention Items
</h2>
{risks.staleHighPriority.length > 0 && (
<div className="mb-4">
<h3 className="text-xs font-semibold text-red-600 uppercase mb-2">
High-Priority Accounts &mdash; No Touch in 30+ Days ({risks.staleHighPriority.length})
</h3>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200">
<th className="text-left py-1 font-medium text-gray-600">Account</th>
<th className="text-left py-1 font-medium text-gray-600">Tier</th>
<th className="text-right py-1 font-medium text-gray-600">Last Touched</th>
<th className="text-right py-1 font-medium text-gray-600">Days Ago</th>
</tr>
</thead>
<tbody>
{risks.staleHighPriority.slice(0, 10).map(a => {
const daysAgo = a.Date_Last_Touched
? differenceInDays(now, parseISO(a.Date_Last_Touched))
: null;
return (
<tr key={a.Account_Name} className="border-b border-gray-100">
<td className="py-1 text-gray-700">{a.Account_Name}</td>
<td className="py-1 text-gray-700">{a.Tier}</td>
<td className="py-1 text-right text-gray-500">
{a.Date_Last_Touched ? format(parseISO(a.Date_Last_Touched), 'MMM d, yyyy') : 'Never'}
</td>
<td className="py-1 text-right font-medium text-red-600">
{daysAgo !== null ? daysAgo : '—'}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{risks.pastDueDeals.length > 0 && (
<div>
<h3 className="text-xs font-semibold text-amber-600 uppercase mb-2">
Deals with Past-Due Close Dates ({risks.pastDueDeals.length})
</h3>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200">
<th className="text-left py-1 font-medium text-gray-600">Account</th>
<th className="text-left py-1 font-medium text-gray-600">Stage</th>
<th className="text-right py-1 font-medium text-gray-600">Amount</th>
<th className="text-right py-1 font-medium text-gray-600">Expected Close</th>
<th className="text-right py-1 font-medium text-gray-600">Days Past</th>
</tr>
</thead>
<tbody>
{risks.pastDueDeals.slice(0, 10).map(p => {
const daysPast = differenceInDays(now, parseISO(p.Expected_Close_Date));
return (
<tr key={p.Opportunity_ID} className="border-b border-gray-100">
<td className="py-1 text-gray-700">{p.Account_Name}</td>
<td className="py-1 text-gray-700">{p.Stage}</td>
<td className="py-1 text-right text-gray-700">{formatCurrency(p.Amount_USD, true)}</td>
<td className="py-1 text-right text-gray-500">
{format(parseISO(p.Expected_Close_Date), 'MMM d, yyyy')}
</td>
<td className="py-1 text-right font-medium text-amber-600">{daysPast}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{risks.staleHighPriority.length === 0 && risks.pastDueDeals.length === 0 && (
<p className="text-sm text-green-600">No risk items identified this week.</p>
)}
</section>
{/* Next Week Focus */}
<section className="mb-4">
<h2 className="text-base font-bold text-gray-900 uppercase tracking-wide border-b border-gray-200 pb-1 mb-3">
Next Week Focus
</h2>
<textarea
value={nextWeekFocus}
onChange={(e) => setNextWeekFocus(e.target.value)}
placeholder="Enter your priorities and focus areas for next week..."
className="w-full border border-gray-300 rounded-lg p-3 text-sm text-gray-700 min-h-[100px] resize-y focus:outline-none focus:ring-2 focus:ring-[#005C8A] focus:border-transparent"
/>
</section>
{/* Footer */}
<div className="border-t border-gray-200 pt-3 mt-6 text-xs text-gray-400 text-center">
AgentMinder Campaign Command Center &bull; Confidential
</div>
</div>
</>
);
}

View File

@@ -3,7 +3,7 @@
import { useState, useEffect, useRef } from 'react';
import Link from 'next/link';
type Tab = 'import' | 'accounts' | 'pipeline' | 'activities' | 'targets';
type Tab = 'import' | 'accounts' | 'pipeline' | 'activities' | 'targets' | 'quality';
interface Stats {
accounts: number;
@@ -42,11 +42,14 @@ export default function AdminPage() {
</Link>
<h1 className="text-lg font-bold">Data Admin</h1>
</div>
<div className="flex gap-4 text-sm">
<div className="flex gap-4 text-sm items-center">
<span className="bg-white/10 px-3 py-1 rounded">{stats.accounts} accounts</span>
<span className="bg-white/10 px-3 py-1 rounded">{stats.pipeline} deals</span>
<span className="bg-white/10 px-3 py-1 rounded">{stats.activities} activities</span>
<span className="bg-white/10 px-3 py-1 rounded">{stats.targets} targets</span>
<Link href="/admin/goals" className="px-3 py-1 rounded bg-[#0098C7] hover:bg-[#007ba3] text-white font-medium transition">
Goals &amp; Progress
</Link>
</div>
</header>
@@ -57,7 +60,7 @@ export default function AdminPage() {
)}
<div className="px-6 pt-4 flex gap-1 border-b border-gray-200 bg-white">
{(['import', 'accounts', 'pipeline', 'activities', 'targets'] as Tab[]).map(t => (
{(['import', 'accounts', 'pipeline', 'activities', 'targets', 'quality'] as Tab[]).map(t => (
<button
key={t}
onClick={() => setTab(t)}
@@ -67,7 +70,7 @@ export default function AdminPage() {
: 'text-gray-500 hover:text-gray-800 hover:bg-gray-50'
}`}
>
{t === 'import' ? 'Import Data' : t}
{t === 'import' ? 'Import Data' : t === 'quality' ? 'Data Quality' : t}
</button>
))}
</div>
@@ -78,6 +81,7 @@ export default function AdminPage() {
{tab === 'pipeline' && <DataTable table="pipeline" onUpdate={refreshStats} showMessage={showMessage} />}
{tab === 'activities' && <DataTable table="activities" onUpdate={refreshStats} showMessage={showMessage} />}
{tab === 'targets' && <DataTable table="targets" onUpdate={refreshStats} showMessage={showMessage} />}
{tab === 'quality' && <DataQualityPanel onNavigate={(t: Tab) => setTab(t)} />}
</div>
</div>
);
@@ -246,6 +250,7 @@ const FIELD_OPTIONS: Record<string, string[]> = {
Outcome: ['Advanced', 'Follow-up Scheduled', 'Opportunity Created', 'No Decision Yet', 'No Response', 'No Interest', 'Disqualified'],
Implementation_Stage: ['Not Started', 'In Progress', 'Live', 'At Risk', 'Stalled'],
Health_Status: ['Green', 'Yellow', 'Red'],
Competitors: ['Microsoft', 'Okta', 'Google', 'Sailpoint', 'AWS', 'Other'],
};
interface FieldDef { key: string; label: string; required?: boolean; type?: string; options?: string[] }
@@ -271,6 +276,7 @@ const TABLE_FIELDS: Record<string, FieldDef[]> = {
{ key: 'Anchor_Contract_EAR', label: 'Anchor Contract EAR', type: 'number' },
{ key: 'Google_Drive_URL', label: 'Google Drive Link' },
{ key: 'Campaign_Artifacts_URL', label: 'Campaign Artifacts Link' },
{ key: 'Competitors', label: 'Competitors', options: FIELD_OPTIONS.Competitors },
],
pipeline: [
{ key: 'Opportunity_ID', label: 'Opp ID', required: true },
@@ -351,7 +357,28 @@ function RecordForm({ table, record, onSave, onCancel }: {
<label className="block text-xs font-medium text-gray-600 mb-1">
{f.label} {f.required && <span className="text-red-500">*</span>}
</label>
{f.options ? (
{f.key === 'Competitors' && f.options ? (
<div className="flex flex-wrap gap-2 pt-1">
{f.options.map(opt => {
const selected = (form[f.key] || '').split(',').filter(Boolean);
const checked = selected.includes(opt);
return (
<label key={opt} className="flex items-center gap-1.5 text-sm cursor-pointer">
<input
type="checkbox"
checked={checked}
onChange={() => {
const next = checked ? selected.filter(v => v !== opt) : [...selected, opt];
setForm(prev => ({ ...prev, [f.key]: next.join(',') }));
}}
className="rounded border-gray-300 text-[#0098C7] focus:ring-[#0098C7]/30"
/>
{opt}
</label>
);
})}
</div>
) : f.options ? (
<select
value={form[f.key] || ''}
onChange={e => setForm(prev => ({ ...prev, [f.key]: e.target.value }))}
@@ -451,7 +478,7 @@ function DataTable({ table, onUpdate, showMessage }: {
const allKeys = data.length > 0 ? Object.keys(data[0]) : [];
const isAccounts = table === 'accounts';
const displayKeys = allKeys.filter(k => k !== 'id' && !(isAccounts && (k === 'Logo_URL' || k === 'Google_Drive_URL' || k === 'Campaign_Artifacts_URL')));
const displayKeys = allKeys.filter(k => k !== 'id' && !(isAccounts && (k === 'Logo_URL' || k === 'Google_Drive_URL' || k === 'Campaign_Artifacts_URL' || k === 'Competitors')));
const filtered = data.filter(row =>
search === '' || Object.values(row).some(v => String(v ?? '').toLowerCase().includes(search.toLowerCase()))
);
@@ -536,3 +563,168 @@ function DataTable({ table, onUpdate, showMessage }: {
</div>
);
}
interface QualityIssue {
type: string;
severity: 'high' | 'medium' | 'low';
message: string;
table: string;
record_id: string;
}
interface QualityReport {
issues: QualityIssue[];
completeness: number;
totalAccounts: number;
totalPipeline: number;
}
function DataQualityPanel({ onNavigate }: { onNavigate: (tab: Tab) => void }) {
const [data, setData] = useState<QualityReport | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch('/api/phase2', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'quality.report' }),
})
.then(r => r.json())
.then(d => { setData(d.data); setLoading(false); })
.catch(() => setLoading(false));
}, []);
if (loading) return <div className="text-gray-500 text-sm">Analyzing data quality...</div>;
if (!data) return <div className="text-gray-500 text-sm">Unable to load quality report.</div>;
const highCount = data.issues.filter(i => i.severity === 'high').length;
const medCount = data.issues.filter(i => i.severity === 'medium').length;
const lowCount = data.issues.filter(i => i.severity === 'low').length;
const circumference = 2 * Math.PI * 70;
const progress = (data.completeness / 100) * circumference;
const scoreColor = data.completeness >= 80 ? '#16A34A' : data.completeness >= 60 ? '#F59E0B' : '#EF4444';
const tableToTab = (table: string): Tab => {
if (table === 'accounts') return 'accounts';
if (table === 'pipeline') return 'pipeline';
if (table === 'activities') return 'activities';
return 'accounts';
};
return (
<div className="max-w-5xl space-y-6">
{/* Summary Row */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{/* Completeness Score */}
<div className="bg-white rounded-xl border border-gray-200 p-6 flex flex-col items-center justify-center md:col-span-1">
<div className="relative w-40 h-40">
<svg viewBox="0 0 160 160" className="w-full h-full -rotate-90">
<circle cx="80" cy="80" r="70" fill="none" stroke="#E2E8F0" strokeWidth="10" />
<circle
cx="80" cy="80" r="70"
fill="none"
stroke={scoreColor}
strokeWidth="10"
strokeLinecap="round"
strokeDasharray={`${progress} ${circumference}`}
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-3xl font-bold" style={{ color: scoreColor }}>{Math.round(data.completeness)}%</span>
<span className="text-xs text-gray-500">Completeness</span>
</div>
</div>
</div>
{/* Issue Counts */}
<div className="md:col-span-3 grid grid-cols-3 gap-4">
<div className="bg-white rounded-xl border border-gray-200 p-5">
<div className="flex items-center gap-2 mb-2">
<div className="w-3 h-3 rounded-full bg-red-500" />
<span className="text-xs font-medium text-gray-500 uppercase tracking-wider">High Severity</span>
</div>
<div className="text-3xl font-bold text-red-600">{highCount}</div>
<div className="text-xs text-gray-400 mt-1">issues need attention</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-5">
<div className="flex items-center gap-2 mb-2">
<div className="w-3 h-3 rounded-full bg-amber-500" />
<span className="text-xs font-medium text-gray-500 uppercase tracking-wider">Medium Severity</span>
</div>
<div className="text-3xl font-bold text-amber-600">{medCount}</div>
<div className="text-xs text-gray-400 mt-1">issues to review</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-5">
<div className="flex items-center gap-2 mb-2">
<div className="w-3 h-3 rounded-full bg-gray-400" />
<span className="text-xs font-medium text-gray-500 uppercase tracking-wider">Low Severity</span>
</div>
<div className="text-3xl font-bold text-gray-500">{lowCount}</div>
<div className="text-xs text-gray-400 mt-1">minor improvements</div>
</div>
</div>
</div>
{/* Coverage Info */}
<div className="bg-white rounded-xl border border-gray-200 p-4 flex items-center gap-6 text-sm">
<span className="text-gray-500">Scanned:</span>
<span className="font-semibold">{data.totalAccounts} accounts</span>
<span className="text-gray-300">|</span>
<span className="font-semibold">{data.totalPipeline} pipeline records</span>
<span className="text-gray-300">|</span>
<span className="font-semibold">{data.issues.length} total issues</span>
</div>
{/* Issues Table */}
{data.issues.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<div className="px-5 py-3 border-b border-gray-200 bg-gray-50">
<h3 className="text-sm font-bold text-[#1B1D36]">Issues</h3>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 bg-gray-50">
<th className="text-left px-4 py-2 font-semibold text-gray-600 w-24">Severity</th>
<th className="text-left px-4 py-2 font-semibold text-gray-600 w-24">Table</th>
<th className="text-left px-4 py-2 font-semibold text-gray-600 w-40">Record</th>
<th className="text-left px-4 py-2 font-semibold text-gray-600">Issue</th>
<th className="px-4 py-2 w-20"></th>
</tr>
</thead>
<tbody>
{(['high', 'medium', 'low'] as const).map(severity =>
data.issues
.filter(i => i.severity === severity)
.map((issue, idx) => (
<tr key={`${severity}-${idx}`} className="border-b border-gray-100 hover:bg-gray-50">
<td className="px-4 py-2">
<span className={`inline-block px-2 py-0.5 rounded-full text-[10px] font-semibold text-white ${
severity === 'high' ? 'bg-red-500' : severity === 'medium' ? 'bg-amber-500' : 'bg-gray-400'
}`}>
{severity}
</span>
</td>
<td className="px-4 py-2 text-gray-600 capitalize">{issue.table}</td>
<td className="px-4 py-2 text-gray-700 font-medium truncate max-w-[160px]" title={issue.record_id}>{issue.record_id}</td>
<td className="px-4 py-2 text-gray-600">{issue.message}</td>
<td className="px-4 py-2">
<button
onClick={() => onNavigate(tableToTab(issue.table))}
className="px-2.5 py-1 text-xs font-medium text-[#0098C7] hover:text-[#007ba3] border border-[#0098C7]/30 rounded-lg hover:bg-[#0098C7]/5 transition"
>
Fix
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,3 @@
import { handlers } from '@/auth';
export const { GET, POST } = handlers;

101
src/app/api/phase2/route.ts Normal file
View File

@@ -0,0 +1,101 @@
import { NextRequest, NextResponse } from 'next/server';
import {
getAccountNotes, addAccountNote, deleteAccountNote, updateAccountNote,
getContacts, getAllContacts, upsertContact, deleteContact,
getSnapshots, captureSnapshot,
getPipelineSnapshots,
getPlaybooks, upsertPlaybook, getPlaybookProgress, upsertPlaybookProgress,
getDistrictTargets, upsertDistrictTarget,
getDataQualityReport,
getDashboardData,
} from '@/lib/db';
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const { action } = body;
switch (action) {
// Account Notes
case 'notes.list':
return NextResponse.json({ data: getAccountNotes(body.Account_Name) });
case 'notes.add':
return NextResponse.json({ data: addAccountNote(body) });
case 'notes.update':
updateAccountNote(body.id, body);
return NextResponse.json({ success: true });
case 'notes.delete':
deleteAccountNote(body.id);
return NextResponse.json({ success: true });
// Contacts
case 'contacts.list':
return NextResponse.json({ data: body.Account_Name ? getContacts(body.Account_Name) : getAllContacts() });
case 'contacts.upsert':
return NextResponse.json({ data: upsertContact(body) });
case 'contacts.delete':
deleteContact(body.id);
return NextResponse.json({ success: true });
// Snapshots
case 'snapshots.list':
return NextResponse.json({ data: getSnapshots(body.period_type) });
case 'snapshots.capture':
return NextResponse.json({ data: captureSnapshot(body.period_type) });
case 'snapshots.pipeline':
return NextResponse.json({ data: getPipelineSnapshots(body.date) });
// Playbooks
case 'playbooks.list':
return NextResponse.json({ data: getPlaybooks() });
case 'playbooks.upsert':
upsertPlaybook(body);
return NextResponse.json({ success: true });
case 'playbooks.progress.list':
return NextResponse.json({ data: getPlaybookProgress(body.Account_Name === '__all__' ? undefined : body.Account_Name) });
case 'playbooks.progress.upsert':
upsertPlaybookProgress(body);
return NextResponse.json({ success: true });
// District Targets
case 'targets.list':
return NextResponse.json({ data: getDistrictTargets(body.quarter) });
case 'targets.upsert':
upsertDistrictTarget(body);
return NextResponse.json({ success: true });
// Data Quality
case 'quality.report':
return NextResponse.json({ data: getDataQualityReport() });
// Export
case 'export': {
const data = getDashboardData();
const tableData = {
accounts: data.accounts,
pipeline: data.pipeline,
activities: data.activities,
}[body.table as string];
if (!tableData || !Array.isArray(tableData) || tableData.length === 0) {
return NextResponse.json({ error: 'No data' }, { status: 400 });
}
const headers = Object.keys(tableData[0] as object);
const csv = [headers.join(','), ...(tableData as unknown as Record<string, unknown>[]).map((row) =>
headers.map(h => {
const v = String(row[h] ?? '');
return v.includes(',') || v.includes('"') || v.includes('\n') ? `"${v.replace(/"/g, '""')}"` : v;
}).join(',')
)].join('\n');
return new NextResponse(csv, {
headers: { 'Content-Type': 'text/csv', 'Content-Disposition': `attachment; filename="${body.table}-export.csv"` },
});
}
default:
return NextResponse.json({ error: 'Unknown action' }, { status: 400 });
}
} catch (error) {
console.error('[Phase2 API]', error);
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}

View File

@@ -0,0 +1,39 @@
'use client';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { Suspense } from 'react';
function ErrorContent() {
const params = useSearchParams();
const error = params.get('error');
const messages: Record<string, string> = {
AccessDenied: 'Only @broadcom.com accounts are allowed.',
Configuration: 'Auth is not configured yet. Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET in .env.local.',
Default: 'An authentication error occurred.',
};
return (
<div className="min-h-screen flex items-center justify-center bg-[#F4F6F8]">
<div className="w-full max-w-sm bg-white rounded-2xl shadow-xl border border-gray-100 p-8 text-center">
<div className="w-14 h-14 mx-auto mb-4 rounded-full bg-red-50 flex items-center justify-center">
<svg className="w-7 h-7 text-red-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="10" /><line x1="15" y1="9" x2="9" y2="15" /><line x1="9" y1="9" x2="15" y2="15" /></svg>
</div>
<h1 className="text-lg font-bold text-[#1B1D36] mb-2">Access Denied</h1>
<p className="text-sm text-gray-500 mb-6">{messages[error || ''] || messages.Default}</p>
<Link href="/auth/signin" className="inline-block px-6 py-2.5 bg-[#0098C7] text-white text-sm font-semibold rounded-xl hover:bg-[#007ba3] transition">
Try Again
</Link>
</div>
</div>
);
}
export default function AuthErrorPage() {
return (
<Suspense fallback={<div className="min-h-screen flex items-center justify-center">Loading...</div>}>
<ErrorContent />
</Suspense>
);
}

View File

@@ -0,0 +1,38 @@
'use client';
import { signIn } from 'next-auth/react';
export default function SignInPage() {
return (
<div className="min-h-screen flex items-center justify-center bg-[#F4F6F8]">
<div className="w-full max-w-sm">
<div className="bg-white rounded-2xl shadow-xl border border-gray-100 p-8 text-center">
<div className="w-16 h-16 mx-auto mb-4 rounded-xl bg-[#1B1D36] flex items-center justify-center">
<svg className="w-8 h-8 text-[#0098C7]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="4" rx="1" /><rect x="14" y="10" width="7" height="11" rx="1" /><rect x="3" y="13" width="7" height="8" rx="1" />
</svg>
</div>
<h1 className="text-xl font-bold text-[#1B1D36] mb-1">AgentMinder</h1>
<p className="text-sm text-gray-500 mb-6">Campaign Command Center</p>
<button
onClick={() => signIn('google', { callbackUrl: '/' })}
className="w-full flex items-center justify-center gap-3 px-4 py-3 bg-white border border-gray-300 rounded-xl text-sm font-medium text-gray-700 hover:bg-gray-50 hover:border-gray-400 transition shadow-sm"
>
<svg className="w-5 h-5" viewBox="0 0 24 24">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4"/>
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
</svg>
Sign in with Google
</button>
<p className="text-[11px] text-gray-400 mt-4">
@broadcom.com accounts only
</p>
</div>
</div>
</div>
);
}

38
src/auth.ts Normal file
View File

@@ -0,0 +1,38 @@
import NextAuth from 'next-auth';
import Google from 'next-auth/providers/google';
const ALLOWED_DOMAINS = ['broadcom.com'];
const ADMIN_EMAILS = ['christopher.olson@broadcom.com'];
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [
Google({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
}),
],
callbacks: {
async signIn({ profile }) {
const email = profile?.email;
if (!email) return false;
const domain = email.split('@')[1];
return ALLOWED_DOMAINS.includes(domain);
},
async session({ session }) {
if (session.user?.email) {
(session as unknown as Record<string, unknown>).role = ADMIN_EMAILS.includes(session.user.email) ? 'admin' : 'readonly';
}
return session;
},
async jwt({ token, profile }) {
if (profile?.email) {
token.role = ADMIN_EMAILS.includes(profile.email) ? 'admin' : 'readonly';
}
return token;
},
},
pages: {
signIn: '/auth/signin',
error: '/auth/error',
},
});

View File

@@ -0,0 +1,272 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
interface AccountNote {
id: number;
Account_Name: string;
note_text: string;
next_action?: string;
is_pinned: number;
created_at: string;
}
interface AccountNotesProps {
accountName: string;
}
export function AccountNotes({ accountName }: AccountNotesProps) {
const [notes, setNotes] = useState<AccountNote[]>([]);
const [loading, setLoading] = useState(true);
const [noteText, setNoteText] = useState('');
const [nextAction, setNextAction] = useState('');
const [isPinned, setIsPinned] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [editText, setEditText] = useState('');
const [editNextAction, setEditNextAction] = useState('');
const fetchNotes = useCallback(async () => {
try {
const res = await fetch('/api/phase2', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'notes.list', Account_Name: accountName }),
});
const json = await res.json();
setNotes(json.data ?? []);
} catch {
console.error('Failed to fetch notes');
} finally {
setLoading(false);
}
}, [accountName]);
useEffect(() => {
fetchNotes();
}, [fetchNotes]);
const handleAdd = async (e: React.FormEvent) => {
e.preventDefault();
if (!noteText.trim()) return;
setSubmitting(true);
try {
await fetch('/api/phase2', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'notes.add',
Account_Name: accountName,
note_text: noteText.trim(),
next_action: nextAction.trim() || undefined,
is_pinned: isPinned ? 1 : 0,
}),
});
setNoteText('');
setNextAction('');
setIsPinned(false);
await fetchNotes();
} catch {
console.error('Failed to add note');
} finally {
setSubmitting(false);
}
};
const handleDelete = async (id: number) => {
try {
await fetch('/api/phase2', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'notes.delete', id }),
});
await fetchNotes();
} catch {
console.error('Failed to delete note');
}
};
const handleTogglePin = async (note: AccountNote) => {
try {
await fetch('/api/phase2', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'notes.update', id: note.id, is_pinned: note.is_pinned ? 0 : 1 }),
});
await fetchNotes();
} catch {
console.error('Failed to toggle pin');
}
};
const startEdit = (note: AccountNote) => {
setEditingId(note.id);
setEditText(note.note_text);
setEditNextAction(note.next_action ?? '');
};
const cancelEdit = () => {
setEditingId(null);
setEditText('');
setEditNextAction('');
};
const handleUpdate = async (id: number) => {
try {
await fetch('/api/phase2', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'notes.update',
id,
note_text: editText.trim(),
next_action: editNextAction.trim() || undefined,
}),
});
cancelEdit();
await fetchNotes();
} catch {
console.error('Failed to update note');
}
};
const formatDate = (dateStr: string) => {
const d = new Date(dateStr);
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' });
};
const sorted = [...notes].sort((a, b) => {
if (a.is_pinned !== b.is_pinned) return b.is_pinned - a.is_pinned;
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
});
return (
<div className="bg-card-bg rounded-xl border border-card-border p-4">
<h3 className="text-sm font-semibold text-foreground mb-3">Notes</h3>
{/* Add Note Form */}
<form onSubmit={handleAdd} className="mb-4 space-y-2">
<textarea
value={noteText}
onChange={(e) => setNoteText(e.target.value)}
placeholder="Add a note..."
rows={2}
className="w-full rounded-lg border border-card-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted focus:outline-none focus:ring-1 focus:ring-[#0098C7] resize-none"
/>
<input
type="text"
value={nextAction}
onChange={(e) => setNextAction(e.target.value)}
placeholder="Next action (optional)"
className="w-full rounded-lg border border-card-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted focus:outline-none focus:ring-1 focus:ring-[#0098C7]"
/>
<div className="flex items-center justify-between">
<label className="flex items-center gap-1.5 text-xs text-muted cursor-pointer">
<input
type="checkbox"
checked={isPinned}
onChange={(e) => setIsPinned(e.target.checked)}
className="rounded border-card-border accent-[#0098C7]"
/>
Pin this note
</label>
<button
type="submit"
disabled={submitting || !noteText.trim()}
className="px-3 py-1.5 text-xs font-medium text-white bg-[#0098C7] rounded-lg hover:bg-[#0098C7]/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{submitting ? 'Adding...' : 'Add Note'}
</button>
</div>
</form>
{/* Notes List */}
{loading ? (
<p className="text-xs text-muted">Loading notes...</p>
) : sorted.length === 0 ? (
<p className="text-xs text-muted">No notes yet.</p>
) : (
<div className="space-y-2">
{sorted.map((note) => (
<div key={note.id} className="rounded-lg border border-card-border p-3 bg-background">
{editingId === note.id ? (
<div className="space-y-2">
<textarea
value={editText}
onChange={(e) => setEditText(e.target.value)}
rows={2}
className="w-full rounded-lg border border-card-border bg-card-bg px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-[#0098C7] resize-none"
/>
<input
type="text"
value={editNextAction}
onChange={(e) => setEditNextAction(e.target.value)}
placeholder="Next action (optional)"
className="w-full rounded-lg border border-card-border bg-card-bg px-3 py-2 text-sm text-foreground placeholder:text-muted focus:outline-none focus:ring-1 focus:ring-[#0098C7]"
/>
<div className="flex gap-2 justify-end">
<button onClick={cancelEdit} className="px-2 py-1 text-xs text-muted hover:text-foreground transition-colors">Cancel</button>
<button onClick={() => handleUpdate(note.id)} className="px-2 py-1 text-xs font-medium text-white bg-[#0098C7] rounded-lg hover:bg-[#0098C7]/90 transition-colors">Save</button>
</div>
</div>
) : (
<>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 mb-1">
{note.is_pinned === 1 && (
<svg className="w-3 h-3 text-[#0098C7] flex-shrink-0" viewBox="0 0 24 24" fill="currentColor">
<path d="M16 2l-4 4-4-2-4 4 2 4-4 4h6v8l2 2 2-2v-8h6l-4-4 2-4-4-4z" />
</svg>
)}
<span className="text-xs text-muted">{formatDate(note.created_at)}</span>
</div>
<p className="text-sm text-foreground whitespace-pre-wrap">{note.note_text}</p>
{note.next_action && (
<div className="mt-1.5 flex items-center gap-1">
<span className="text-xs font-medium text-[#0098C7]">Next:</span>
<span className="text-xs text-muted">{note.next_action}</span>
</div>
)}
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<button
onClick={() => handleTogglePin(note)}
title={note.is_pinned ? 'Unpin' : 'Pin'}
className="p-1 rounded hover:bg-card-bg text-muted hover:text-foreground transition-colors"
>
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill={note.is_pinned ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="2">
<path d="M16 2l-4 4-4-2-4 4 2 4-4 4h6v8l2 2 2-2v-8h6l-4-4 2-4-4-4z" />
</svg>
</button>
<button
onClick={() => startEdit(note)}
title="Edit"
className="p-1 rounded hover:bg-card-bg text-muted hover:text-foreground transition-colors"
>
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
<button
onClick={() => handleDelete(note.id)}
title="Delete"
className="p-1 rounded hover:bg-card-bg text-muted hover:text-danger transition-colors"
>
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6m5 0V4a1 1 0 011-1h2a1 1 0 011 1v2" />
</svg>
</button>
</div>
</div>
</>
)}
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,244 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
const ROLES = ['Economic Buyer', 'Champion', 'Technical Evaluator', 'End User', 'Procurement', 'Executive Sponsor'] as const;
const SENTIMENTS = ['Champion', 'Supportive', 'Neutral', 'Skeptical', 'Blocker'] as const;
type Role = (typeof ROLES)[number];
type Sentiment = (typeof SENTIMENTS)[number];
interface Contact {
id: number;
Account_Name: string;
Contact_Name: string;
Role: Role;
Sentiment: Sentiment;
Email?: string;
LinkedIn_URL?: string;
}
interface ContactMapProps {
accountName: string;
}
const SENTIMENT_COLORS: Record<Sentiment, string> = {
Champion: 'bg-emerald-500',
Supportive: 'bg-emerald-400',
Neutral: 'bg-yellow-400',
Skeptical: 'bg-red-400',
Blocker: 'bg-red-600',
};
const ROLE_BADGE_CLASSES = 'inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-[#1B1D36]/10 text-[#1B1D36] dark:bg-[#0098C7]/15 dark:text-[#0098C7]';
const emptyForm = { Contact_Name: '', Role: 'Technical Evaluator' as Role, Sentiment: 'Neutral' as Sentiment, Email: '', LinkedIn_URL: '' };
export function ContactMap({ accountName }: ContactMapProps) {
const [contacts, setContacts] = useState<Contact[]>([]);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [form, setForm] = useState(emptyForm);
const [submitting, setSubmitting] = useState(false);
const fetchContacts = useCallback(async () => {
try {
const res = await fetch('/api/phase2', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'contacts.list', Account_Name: accountName }),
});
const json = await res.json();
setContacts(json.data ?? []);
} catch {
console.error('Failed to fetch contacts');
} finally {
setLoading(false);
}
}, [accountName]);
useEffect(() => {
fetchContacts();
}, [fetchContacts]);
const resetForm = () => {
setForm(emptyForm);
setShowForm(false);
setEditingId(null);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!form.Contact_Name.trim()) return;
setSubmitting(true);
try {
await fetch('/api/phase2', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'contacts.upsert',
Account_Name: accountName,
Contact_Name: form.Contact_Name.trim(),
Role: form.Role,
Sentiment: form.Sentiment,
Email: form.Email.trim() || undefined,
LinkedIn_URL: form.LinkedIn_URL.trim() || undefined,
...(editingId != null ? { id: editingId } : {}),
}),
});
resetForm();
await fetchContacts();
} catch {
console.error('Failed to save contact');
} finally {
setSubmitting(false);
}
};
const handleEdit = (contact: Contact) => {
setEditingId(contact.id);
setForm({
Contact_Name: contact.Contact_Name,
Role: contact.Role,
Sentiment: contact.Sentiment,
Email: contact.Email ?? '',
LinkedIn_URL: contact.LinkedIn_URL ?? '',
});
setShowForm(true);
};
const handleDelete = async (id: number) => {
try {
await fetch('/api/phase2', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'contacts.delete', id }),
});
await fetchContacts();
} catch {
console.error('Failed to delete contact');
}
};
const selectClasses = 'w-full rounded-lg border border-card-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-[#0098C7]';
const inputClasses = selectClasses;
return (
<div className="bg-card-bg rounded-xl border border-card-border p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-foreground">Stakeholder Map</h3>
{!showForm && (
<button
onClick={() => { resetForm(); setShowForm(true); }}
className="px-2.5 py-1 text-xs font-medium text-white bg-[#0098C7] rounded-lg hover:bg-[#0098C7]/90 transition-colors"
>
Add Contact
</button>
)}
</div>
{/* Inline Form */}
{showForm && (
<form onSubmit={handleSubmit} className="mb-4 space-y-2 rounded-lg border border-card-border p-3 bg-background">
<input
type="text"
value={form.Contact_Name}
onChange={(e) => setForm({ ...form, Contact_Name: e.target.value })}
placeholder="Contact name"
className={inputClasses}
/>
<div className="grid grid-cols-2 gap-2">
<select value={form.Role} onChange={(e) => setForm({ ...form, Role: e.target.value as Role })} className={selectClasses}>
{ROLES.map((r) => <option key={r} value={r}>{r}</option>)}
</select>
<select value={form.Sentiment} onChange={(e) => setForm({ ...form, Sentiment: e.target.value as Sentiment })} className={selectClasses}>
{SENTIMENTS.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
</div>
<input
type="email"
value={form.Email}
onChange={(e) => setForm({ ...form, Email: e.target.value })}
placeholder="Email (optional)"
className={inputClasses}
/>
<input
type="url"
value={form.LinkedIn_URL}
onChange={(e) => setForm({ ...form, LinkedIn_URL: e.target.value })}
placeholder="LinkedIn URL (optional)"
className={inputClasses}
/>
<div className="flex gap-2 justify-end">
<button type="button" onClick={resetForm} className="px-2 py-1 text-xs text-muted hover:text-foreground transition-colors">Cancel</button>
<button
type="submit"
disabled={submitting || !form.Contact_Name.trim()}
className="px-3 py-1.5 text-xs font-medium text-white bg-[#0098C7] rounded-lg hover:bg-[#0098C7]/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{submitting ? 'Saving...' : editingId != null ? 'Update' : 'Add'}
</button>
</div>
</form>
)}
{/* Contacts List */}
{loading ? (
<p className="text-xs text-muted">Loading contacts...</p>
) : contacts.length === 0 ? (
<p className="text-xs text-muted">No stakeholders added yet.</p>
) : (
<div className="space-y-2">
{contacts.map((contact) => (
<div key={contact.id} className="rounded-lg border border-card-border p-3 bg-background">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${SENTIMENT_COLORS[contact.Sentiment]}`} title={contact.Sentiment} />
<span className="text-sm font-medium text-foreground truncate">{contact.Contact_Name}</span>
<span className={ROLE_BADGE_CLASSES}>{contact.Role}</span>
</div>
<div className="flex items-center gap-3 ml-4">
{contact.Email && (
<a href={`mailto:${contact.Email}`} className="text-xs text-[#0098C7] hover:underline truncate">{contact.Email}</a>
)}
{contact.LinkedIn_URL && (
<a href={contact.LinkedIn_URL} target="_blank" rel="noopener noreferrer" className="text-muted hover:text-foreground transition-colors" title="LinkedIn">
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="currentColor">
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.064 2.064 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z" />
</svg>
</a>
)}
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<button
onClick={() => handleEdit(contact)}
title="Edit"
className="p-1 rounded hover:bg-card-bg text-muted hover:text-foreground transition-colors"
>
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
<button
onClick={() => handleDelete(contact.id)}
title="Delete"
className="p-1 rounded hover:bg-card-bg text-muted hover:text-danger transition-colors"
>
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6m5 0V4a1 1 0 011-1h2a1 1 0 011 1v2" />
</svg>
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

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

View File

@@ -4,6 +4,7 @@ import { ReactNode } from 'react';
import { DataProvider } from '@/lib/data-context';
import { Sidebar } from './Sidebar';
import { TopBar } from './TopBar';
import { MobileActivityFAB } from '@/components/ui/MobileActivityFAB';
import { DashboardData, DashboardConfig } from '@/types/data';
export function DashboardShell({ children, initialData, config }: { children: ReactNode; initialData: DashboardData; config: DashboardConfig }) {
@@ -17,6 +18,7 @@ export function DashboardShell({ children, initialData, config }: { children: Re
{children}
</main>
</div>
<MobileActivityFAB />
</div>
</DataProvider>
);

View File

@@ -10,6 +10,10 @@ const navItems = [
{ href: '/activity', label: 'Activities', icon: ActivityIcon, shortLabel: 'Activities' },
{ href: '/pipeline', label: 'Opportunities', icon: PipelineIcon, shortLabel: 'Opps' },
{ href: '/implementation', label: 'Implementation', icon: ImplementIcon, shortLabel: 'Implement' },
{ href: '/goals', label: 'Goals & Targets', icon: GoalsIcon, shortLabel: 'Goals' },
{ href: '/playbooks', label: 'Playbooks', icon: PlaybookIcon, shortLabel: 'Plays' },
{ href: '/reports/status', label: 'Weekly Report', icon: ReportsIcon, shortLabel: 'Report' },
{ href: '/reports/executive', label: 'Exec Summary', icon: ExecIcon, shortLabel: 'Summary' },
];
function DashboardIcon({ className }: { className?: string }) {
@@ -44,6 +48,38 @@ function ImplementIcon({ className }: { className?: string }) {
);
}
function PlaybookIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" /><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" />
</svg>
);
}
function GoalsIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" /><circle cx="12" cy="12" r="6" /><circle cx="12" cy="12" r="2" />
</svg>
);
}
function ReportsIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><polyline points="14 2 14 8 20 8" /><line x1="16" y1="13" x2="8" y2="13" /><line x1="16" y1="17" x2="8" y2="17" /><polyline points="10 9 9 9 8 9" />
</svg>
);
}
function ExecIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" /><line x1="8" y1="21" x2="16" y2="21" /><line x1="12" y1="17" x2="12" y2="21" />
</svg>
);
}
function AccountsIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">

View File

@@ -0,0 +1,34 @@
'use client';
import { useState } from 'react';
export function ExportButton({ table, label }: { table: string; label?: string }) {
const [loading, setLoading] = useState(false);
const handleExport = async () => {
setLoading(true);
try {
const res = await fetch('/api/phase2', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'export', table }),
});
if (!res.ok) throw new Error('Export failed');
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${table}-export.csv`;
a.click();
URL.revokeObjectURL(url);
} finally {
setLoading(false);
}
};
return (
<button onClick={handleExport} disabled={loading} className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-muted hover:text-foreground border border-card-border rounded-lg hover:bg-gray-50 transition disabled:opacity-50">
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
{loading ? 'Exporting...' : (label || 'Export CSV')}
</button>
);
}

View File

@@ -0,0 +1,221 @@
'use client';
import { useState, useEffect } from 'react';
const ACTIVITY_TYPES = ['Discovery', 'Demo', 'Exec Meeting', 'QBR Attach', 'Launch Briefing', 'Workshop', 'Email', 'Call'];
const PLAYS = ['Attach', 'Renewal-Trigger', 'Referral', 'Inbound', 'Whitespace'];
const CHANNELS = ['In Person', 'Video Call', 'Phone', 'Email', 'LinkedIn'];
const OUTCOMES = ['Advanced', 'Follow-up Scheduled', 'Opportunity Created', 'No Decision Yet', 'No Response', 'No Interest'];
export function MobileActivityFAB() {
const [open, setOpen] = useState(false);
const [accounts, setAccounts] = useState<string[]>([]);
const [saving, setSaving] = useState(false);
const [success, setSuccess] = useState(false);
const [form, setForm] = useState({
Account_Name: '',
Activity_Type: '',
Activity_Date: new Date().toISOString().split('T')[0],
District_Name: '',
Contact_Name: '',
Notes: '',
Play: '',
Channel: '',
Outcome: '',
});
useEffect(() => {
if (open && accounts.length === 0) {
fetch('/api/admin', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'list', table: 'accounts' }) })
.then(r => r.json())
.then(d => {
const names = (d.data || []).map((a: { Account_Name: string; District_Name: string }) => a.Account_Name).sort();
setAccounts(names);
});
}
}, [open, accounts.length]);
const handleAccountChange = async (name: string) => {
setForm(f => ({ ...f, Account_Name: name }));
if (name) {
const res = await fetch('/api/admin', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'list', table: 'accounts' }) });
const data = await res.json();
const acct = (data.data || []).find((a: { Account_Name: string; District_Name: string }) => a.Account_Name === name);
if (acct) setForm(f => ({ ...f, District_Name: acct.District_Name }));
}
};
const handleSubmit = async () => {
if (!form.Account_Name || !form.Activity_Type) return;
setSaving(true);
try {
await fetch('/api/admin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'upsert', table: 'activities', record: form }),
});
setSuccess(true);
setTimeout(() => {
setSuccess(false);
setOpen(false);
setForm({
Account_Name: '', Activity_Type: '', Activity_Date: new Date().toISOString().split('T')[0],
District_Name: '', Contact_Name: '', Notes: '', Play: '', Channel: '', Outcome: '',
});
}, 1500);
} finally {
setSaving(false);
}
};
return (
<>
{/* FAB Button - only on mobile */}
<button
onClick={() => setOpen(true)}
className="lg:hidden fixed bottom-20 right-4 z-40 w-14 h-14 rounded-full bg-[#0098C7] text-white shadow-lg hover:bg-[#007ba3] transition flex items-center justify-center"
aria-label="Log Activity"
>
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"><line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" /></svg>
</button>
{/* Quick Entry Sheet */}
{open && (
<div className="fixed inset-0 z-50 flex flex-col" onClick={() => setOpen(false)}>
<div className="absolute inset-0 bg-black/40" />
<div
className="relative mt-auto bg-white rounded-t-2xl shadow-2xl max-h-[85vh] overflow-y-auto animate-in slide-in-from-bottom"
onClick={e => e.stopPropagation()}
>
{/* Header */}
<div className="sticky top-0 bg-white rounded-t-2xl border-b border-gray-200 px-5 py-4 flex items-center justify-between z-10">
<h2 className="text-base font-bold text-[#1B1D36]">Log Activity</h2>
<button onClick={() => setOpen(false)} className="p-1 rounded-lg hover:bg-gray-100">
<svg className="w-5 h-5 text-gray-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>
</button>
</div>
{success ? (
<div className="flex flex-col items-center justify-center py-16 px-5">
<div className="w-16 h-16 rounded-full bg-green-100 flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-green-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"><polyline points="20 6 9 17 4 12" /></svg>
</div>
<div className="text-lg font-bold text-[#1B1D36]">Activity Logged</div>
<div className="text-sm text-gray-500 mt-1">Nice work! Keep the momentum going.</div>
</div>
) : (
<div className="px-5 py-4 space-y-4">
{/* Account - large touch target */}
<div>
<label className="block text-xs font-semibold text-gray-600 mb-1.5">Account *</label>
<select
value={form.Account_Name}
onChange={e => handleAccountChange(e.target.value)}
className="w-full px-3 py-3 border border-gray-300 rounded-xl text-sm bg-white focus:ring-2 focus:ring-[#0098C7]/30 focus:border-[#0098C7]"
>
<option value="">Select account...</option>
{accounts.map(a => <option key={a} value={a}>{a}</option>)}
</select>
</div>
{/* Activity Type - large buttons */}
<div>
<label className="block text-xs font-semibold text-gray-600 mb-1.5">Activity Type *</label>
<div className="grid grid-cols-2 gap-2">
{ACTIVITY_TYPES.map(type => (
<button
key={type}
type="button"
onClick={() => setForm(f => ({ ...f, Activity_Type: type }))}
className={`py-2.5 px-3 rounded-xl text-xs font-medium border transition ${
form.Activity_Type === type
? 'border-[#0098C7] bg-[#0098C7]/10 text-[#0098C7]'
: 'border-gray-200 text-gray-600 hover:border-gray-300'
}`}
>
{type}
</button>
))}
</div>
</div>
{/* Date */}
<div>
<label className="block text-xs font-semibold text-gray-600 mb-1.5">Date</label>
<input
type="date"
value={form.Activity_Date}
onChange={e => setForm(f => ({ ...f, Activity_Date: e.target.value }))}
className="w-full px-3 py-3 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#0098C7]/30"
/>
</div>
{/* Contact */}
<div>
<label className="block text-xs font-semibold text-gray-600 mb-1.5">Contact Name</label>
<input
type="text"
value={form.Contact_Name}
onChange={e => setForm(f => ({ ...f, Contact_Name: e.target.value }))}
placeholder="Who did you meet with?"
className="w-full px-3 py-3 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#0098C7]/30"
/>
</div>
{/* Play & Channel row */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-semibold text-gray-600 mb-1.5">Play</label>
<select value={form.Play} onChange={e => setForm(f => ({ ...f, Play: e.target.value }))} className="w-full px-3 py-3 border border-gray-300 rounded-xl text-sm bg-white focus:ring-2 focus:ring-[#0098C7]/30">
<option value=""></option>
{PLAYS.map(p => <option key={p} value={p}>{p}</option>)}
</select>
</div>
<div>
<label className="block text-xs font-semibold text-gray-600 mb-1.5">Channel</label>
<select value={form.Channel} onChange={e => setForm(f => ({ ...f, Channel: e.target.value }))} className="w-full px-3 py-3 border border-gray-300 rounded-xl text-sm bg-white focus:ring-2 focus:ring-[#0098C7]/30">
<option value=""></option>
{CHANNELS.map(c => <option key={c} value={c}>{c}</option>)}
</select>
</div>
</div>
{/* Outcome */}
<div>
<label className="block text-xs font-semibold text-gray-600 mb-1.5">Outcome</label>
<select value={form.Outcome} onChange={e => setForm(f => ({ ...f, Outcome: e.target.value }))} className="w-full px-3 py-3 border border-gray-300 rounded-xl text-sm bg-white focus:ring-2 focus:ring-[#0098C7]/30">
<option value=""></option>
{OUTCOMES.map(o => <option key={o} value={o}>{o}</option>)}
</select>
</div>
{/* Notes */}
<div>
<label className="block text-xs font-semibold text-gray-600 mb-1.5">Notes</label>
<textarea
value={form.Notes}
onChange={e => setForm(f => ({ ...f, Notes: e.target.value }))}
placeholder="Key takeaways, action items..."
rows={3}
className="w-full px-3 py-3 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#0098C7]/30 resize-none"
/>
</div>
{/* Submit */}
<button
onClick={handleSubmit}
disabled={!form.Account_Name || !form.Activity_Type || saving}
className="w-full py-3.5 bg-[#0098C7] text-white text-sm font-bold rounded-xl hover:bg-[#007ba3] disabled:opacity-40 disabled:cursor-not-allowed transition"
>
{saving ? 'Saving...' : 'Log Activity'}
</button>
<div className="h-4" />
</div>
)}
</div>
</div>
)}
</>
);
}

View File

@@ -0,0 +1,148 @@
'use client';
import { useMemo } from 'react';
import { AccountRecord, ActivityRecord, PipelineRecord } from '@/types/data';
import { differenceInDays, parseISO, format } from 'date-fns';
interface ActionItem {
priority: 'urgent' | 'high' | 'medium';
account: string;
message: string;
detail: string;
type: 'stale' | 'renewal' | 'pipeline_gap' | 'deal_risk' | 'untouched';
}
const PRIORITY_STYLES = {
urgent: 'border-l-red-500 bg-red-50',
high: 'border-l-amber-500 bg-amber-50',
medium: 'border-l-blue-500 bg-blue-50',
};
const PRIORITY_LABELS = {
urgent: { text: 'Urgent', color: 'text-red-600 bg-red-100' },
high: { text: 'Action Needed', color: 'text-amber-700 bg-amber-100' },
medium: { text: 'Consider', color: 'text-blue-600 bg-blue-100' },
};
export function NextBestAction({ accounts, activities, pipeline, limit = 8 }: {
accounts: AccountRecord[];
activities: ActivityRecord[];
pipeline: PipelineRecord[];
limit?: number;
}) {
const actions = useMemo(() => {
const items: ActionItem[] = [];
const now = new Date();
for (const acct of accounts) {
if (acct.Priority !== 'High' && acct.Priority !== 'Medium') continue;
const acctActivities = activities.filter(a => a.Account_Name === acct.Account_Name);
const acctPipeline = pipeline.filter(p => p.Account_Name === acct.Account_Name && p.Stage !== '06-Closed Won' && p.Stage !== '07-Closed Lost');
// Stale high-priority accounts
if (acct.Priority === 'High') {
if (acctActivities.length === 0) {
items.push({
priority: 'urgent',
account: acct.Account_Name,
message: 'High-priority account with zero touches',
detail: `${acct.Tier} account, ${acct.Current_ARR_USD ? `$${Math.round(acct.Current_ARR_USD / 1000)}K ARR` : 'no ARR recorded'}`,
type: 'untouched',
});
} else {
const lastDate = acctActivities.sort((a, b) => b.Activity_Date.localeCompare(a.Activity_Date))[0];
const daysSince = differenceInDays(now, parseISO(lastDate.Activity_Date));
if (daysSince > 30) {
items.push({
priority: daysSince > 60 ? 'urgent' : 'high',
account: acct.Account_Name,
message: `No activity in ${daysSince} days`,
detail: `Last touch: ${format(parseISO(lastDate.Activity_Date), 'MMM d')} (${lastDate.Activity_Type})`,
type: 'stale',
});
}
}
}
// Approaching renewals with no play
if (acct.Next_Renewal_Date) {
const daysToRenewal = differenceInDays(parseISO(acct.Next_Renewal_Date), now);
if (daysToRenewal > 0 && daysToRenewal <= 120 && acctPipeline.length === 0) {
items.push({
priority: daysToRenewal <= 60 ? 'urgent' : 'high',
account: acct.Account_Name,
message: `Renewal in ${daysToRenewal} days, no pipeline`,
detail: `${acct.Next_Renewal_EAR ? `$${Math.round(acct.Next_Renewal_EAR / 1000)}K EAR` : 'EAR not set'} — start attach/renewal play`,
type: 'renewal',
});
}
}
// High-priority with no pipeline
if (acct.Priority === 'High' && acctPipeline.length === 0 && acctActivities.length > 0) {
const lastDate = acctActivities.sort((a, b) => b.Activity_Date.localeCompare(a.Activity_Date))[0];
const daysSince = differenceInDays(now, parseISO(lastDate.Activity_Date));
if (daysSince <= 30) {
items.push({
priority: 'medium',
account: acct.Account_Name,
message: 'Active engagement but no pipeline',
detail: `${acctActivities.length} touches — consider creating an opportunity`,
type: 'pipeline_gap',
});
}
}
}
// Deal risks — overdue close dates
const openDeals = pipeline.filter(p => p.Stage !== '06-Closed Won' && p.Stage !== '07-Closed Lost');
for (const deal of openDeals) {
if (deal.Expected_Close_Date) {
const daysOverdue = differenceInDays(now, parseISO(deal.Expected_Close_Date));
if (daysOverdue > 14) {
items.push({
priority: daysOverdue > 30 ? 'urgent' : 'high',
account: deal.Account_Name,
message: `Deal close date ${daysOverdue} days overdue`,
detail: `$${Math.round(deal.Amount_USD / 1000)}K — update close date or advance stage`,
type: 'deal_risk',
});
}
}
}
// Sort by priority
const order = { urgent: 0, high: 1, medium: 2 };
return items.sort((a, b) => order[a.priority] - order[b.priority]).slice(0, limit);
}, [accounts, activities, pipeline, limit]);
if (actions.length === 0) {
return (
<div className="text-center py-6 text-sm text-muted">
No immediate actions needed territory is well-covered.
</div>
);
}
return (
<div className="space-y-2">
{actions.map((action, i) => (
<div key={i} className={`border-l-3 rounded-lg p-3 ${PRIORITY_STYLES[action.priority]}`}>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span className="text-xs font-bold text-foreground truncate">{action.account}</span>
<span className={`px-1.5 py-0.5 rounded text-[9px] font-semibold ${PRIORITY_LABELS[action.priority].color}`}>
{PRIORITY_LABELS[action.priority].text}
</span>
</div>
<div className="text-xs font-medium text-foreground">{action.message}</div>
<div className="text-[11px] text-muted mt-0.5">{action.detail}</div>
</div>
</div>
</div>
))}
</div>
);
}

View File

@@ -8,6 +8,13 @@ import {
ImplementationRecord,
MetricTarget,
DashboardData,
AccountNote,
Contact,
Snapshot,
PipelineSnapshot,
Playbook,
PlaybookProgress,
DistrictTarget,
} from '@/types/data';
const DB_PATH = path.join(process.cwd(), 'data', 'campaign.db');
@@ -23,10 +30,26 @@ function getDb(): Database.Database {
_db = new Database(DB_PATH);
_db.pragma('journal_mode = WAL');
initSchema(_db);
migrateSchema(_db);
seedDefaultPlaybooks();
}
return _db;
}
function migrateSchema(db: Database.Database) {
const cols = db.prepare("PRAGMA table_info(accounts)").all() as { name: string }[];
const colNames = new Set(cols.map(c => c.name));
if (!colNames.has('Competitors')) {
db.exec("ALTER TABLE accounts ADD COLUMN Competitors TEXT DEFAULT NULL");
}
if (!colNames.has('Google_Drive_URL')) {
db.exec("ALTER TABLE accounts ADD COLUMN Google_Drive_URL TEXT DEFAULT NULL");
}
if (!colNames.has('Campaign_Artifacts_URL')) {
db.exec("ALTER TABLE accounts ADD COLUMN Campaign_Artifacts_URL TEXT DEFAULT NULL");
}
}
function initSchema(db: Database.Database) {
db.exec(`
CREATE TABLE IF NOT EXISTS accounts (
@@ -123,11 +146,89 @@ function initSchema(db: Database.Database) {
Notes TEXT
);
CREATE TABLE IF NOT EXISTS account_notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
Account_Name TEXT NOT NULL,
note_text TEXT NOT NULL,
next_action TEXT,
is_pinned INTEGER DEFAULT 0,
created_at TEXT NOT NULL,
created_by TEXT
);
CREATE TABLE IF NOT EXISTS contacts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
Account_Name TEXT NOT NULL,
Contact_Name TEXT NOT NULL,
Role TEXT,
Sentiment TEXT,
Email TEXT,
LinkedIn_URL TEXT
);
CREATE TABLE IF NOT EXISTS snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
snapshot_date TEXT NOT NULL,
period_type TEXT NOT NULL,
total_accounts INTEGER DEFAULT 0,
accounts_touched INTEGER DEFAULT 0,
total_pipeline REAL DEFAULT 0,
open_opps INTEGER DEFAULT 0,
closed_won_amount REAL DEFAULT 0,
total_activities INTEGER DEFAULT 0,
accounts_by_priority_json TEXT,
pipeline_by_stage_json TEXT,
district_data_json TEXT
);
CREATE TABLE IF NOT EXISTS pipeline_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
snapshot_date TEXT NOT NULL,
Opportunity_ID TEXT NOT NULL,
Account_Name TEXT NOT NULL,
Stage TEXT NOT NULL,
Amount_USD REAL NOT NULL DEFAULT 0,
Forecast_Category TEXT
);
CREATE TABLE IF NOT EXISTS playbooks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
play_name TEXT NOT NULL UNIQUE,
description TEXT,
steps_json TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS playbook_progress (
id INTEGER PRIMARY KEY AUTOINCREMENT,
Account_Name TEXT NOT NULL,
playbook_id INTEGER NOT NULL,
current_step INTEGER DEFAULT 0,
status TEXT DEFAULT 'In Progress',
started_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (playbook_id) REFERENCES playbooks(id)
);
CREATE TABLE IF NOT EXISTS district_targets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
District_Name TEXT NOT NULL,
quarter TEXT NOT NULL,
activities_per_week REAL DEFAULT 0,
accounts_touched INTEGER DEFAULT 0,
pipeline_generated REAL DEFAULT 0,
UNIQUE(District_Name, quarter)
);
CREATE INDEX IF NOT EXISTS idx_pipeline_account ON pipeline(Account_Name);
CREATE INDEX IF NOT EXISTS idx_pipeline_district ON pipeline(District_Name);
CREATE INDEX IF NOT EXISTS idx_activities_account ON activities(Account_Name);
CREATE INDEX IF NOT EXISTS idx_activities_date ON activities(Activity_Date);
CREATE INDEX IF NOT EXISTS idx_impl_account ON implementations(Account_Name);
CREATE INDEX IF NOT EXISTS idx_notes_account ON account_notes(Account_Name);
CREATE INDEX IF NOT EXISTS idx_contacts_account ON contacts(Account_Name);
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);
`);
}
@@ -461,6 +562,225 @@ export function upsertPipeline(record: Record<string, unknown>) {
});
}
// --- Account Notes ---
export function getAccountNotes(accountName: string): AccountNote[] {
const db = getDb();
return db.prepare('SELECT * FROM account_notes WHERE Account_Name = ? ORDER BY is_pinned DESC, created_at DESC').all(accountName) as AccountNote[];
}
export function addAccountNote(note: { Account_Name: string; note_text: string; next_action?: string | null; is_pinned?: number; created_by?: string | null }): AccountNote {
const db = getDb();
const result = db.prepare(`INSERT INTO account_notes (Account_Name, note_text, next_action, is_pinned, created_at, created_by) VALUES (?, ?, ?, ?, ?, ?)`).run(
note.Account_Name, note.note_text, note.next_action || null, note.is_pinned || 0, new Date().toISOString(), note.created_by || null
);
return db.prepare('SELECT * FROM account_notes WHERE id = ?').get(result.lastInsertRowid) as AccountNote;
}
export function deleteAccountNote(id: number) {
getDb().prepare('DELETE FROM account_notes WHERE id = ?').run(id);
}
export function updateAccountNote(id: number, updates: { note_text?: string; next_action?: string | null; is_pinned?: number }) {
const db = getDb();
const sets: string[] = [];
const vals: unknown[] = [];
if (updates.note_text !== undefined) { sets.push('note_text = ?'); vals.push(updates.note_text); }
if (updates.next_action !== undefined) { sets.push('next_action = ?'); vals.push(updates.next_action); }
if (updates.is_pinned !== undefined) { sets.push('is_pinned = ?'); vals.push(updates.is_pinned); }
if (sets.length === 0) return;
vals.push(id);
db.prepare(`UPDATE account_notes SET ${sets.join(', ')} WHERE id = ?`).run(...vals);
}
// --- Contacts ---
export function getContacts(accountName: string): Contact[] {
const db = getDb();
return db.prepare('SELECT * FROM contacts WHERE Account_Name = ? ORDER BY Contact_Name').all(accountName) as Contact[];
}
export function getAllContacts(): Contact[] {
return getDb().prepare('SELECT * FROM contacts ORDER BY Account_Name, Contact_Name').all() as Contact[];
}
export function upsertContact(contact: { id?: number; Account_Name: string; Contact_Name: string; Role?: string | null; Sentiment?: string | null; Email?: string | null; LinkedIn_URL?: string | null }): Contact {
const db = getDb();
if (contact.id) {
db.prepare(`UPDATE contacts SET Contact_Name=?, Role=?, Sentiment=?, Email=?, LinkedIn_URL=? WHERE id=?`).run(
contact.Contact_Name, contact.Role || null, contact.Sentiment || null, contact.Email || null, contact.LinkedIn_URL || null, contact.id
);
return db.prepare('SELECT * FROM contacts WHERE id = ?').get(contact.id) as Contact;
}
const result = db.prepare(`INSERT INTO contacts (Account_Name, Contact_Name, Role, Sentiment, Email, LinkedIn_URL) VALUES (?, ?, ?, ?, ?, ?)`).run(
contact.Account_Name, contact.Contact_Name, contact.Role || null, contact.Sentiment || null, contact.Email || null, contact.LinkedIn_URL || null
);
return db.prepare('SELECT * FROM contacts WHERE id = ?').get(result.lastInsertRowid) as Contact;
}
export function deleteContact(id: number) {
getDb().prepare('DELETE FROM contacts WHERE id = ?').run(id);
}
// --- Snapshots ---
export function getSnapshots(periodType?: string): Snapshot[] {
const db = getDb();
if (periodType) return db.prepare('SELECT * FROM snapshots WHERE period_type = ? ORDER BY snapshot_date DESC').all(periodType) as Snapshot[];
return db.prepare('SELECT * FROM snapshots ORDER BY snapshot_date DESC').all() as Snapshot[];
}
export function captureSnapshot(periodType: 'weekly' | 'monthly' | 'quarterly') {
const db = getDb();
const today = new Date().toISOString().split('T')[0];
const existing = db.prepare('SELECT id FROM snapshots WHERE snapshot_date = ? AND period_type = ?').get(today, periodType);
if (existing) return existing;
const accounts = db.prepare('SELECT * FROM accounts').all() as AccountRecord[];
const pipeline = db.prepare("SELECT * FROM pipeline WHERE Status != 'Closed Lost'").all() as PipelineRecord[];
const activities = db.prepare('SELECT * FROM activities').all() as ActivityRecord[];
const openPipeline = pipeline.filter(p => p.Stage !== '06-Closed Won' && p.Stage !== '07-Closed Lost');
const closedWon = pipeline.filter(p => p.Stage === '06-Closed Won');
const priorityCounts: Record<string, number> = {};
accounts.forEach(a => { priorityCounts[a.Priority || 'Unset'] = (priorityCounts[a.Priority || 'Unset'] || 0) + 1; });
const stageCounts: Record<string, { count: number; amount: number }> = {};
openPipeline.forEach(p => {
if (!stageCounts[p.Stage]) stageCounts[p.Stage] = { count: 0, amount: 0 };
stageCounts[p.Stage].count++;
stageCounts[p.Stage].amount += p.Amount_USD;
});
const districtData: Record<string, { activities: number; pipeline: number; touched: number }> = {};
activities.forEach(a => {
if (!districtData[a.District_Name]) districtData[a.District_Name] = { activities: 0, pipeline: 0, touched: 0 };
districtData[a.District_Name].activities++;
});
openPipeline.forEach(p => {
if (!districtData[p.District_Name]) districtData[p.District_Name] = { activities: 0, pipeline: 0, touched: 0 };
districtData[p.District_Name].pipeline += p.Amount_USD;
});
const touchedAccounts = new Set(activities.map(a => a.Account_Name));
const result = db.prepare(`INSERT INTO snapshots (snapshot_date, period_type, total_accounts, accounts_touched, total_pipeline, open_opps, closed_won_amount, total_activities, accounts_by_priority_json, pipeline_by_stage_json, district_data_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
today, periodType, accounts.length, touchedAccounts.size,
openPipeline.reduce((s, p) => s + p.Amount_USD, 0), openPipeline.length,
closedWon.reduce((s, p) => s + (p.Closed_Amount_USD || p.Amount_USD), 0), activities.length,
JSON.stringify(priorityCounts), JSON.stringify(stageCounts), JSON.stringify(districtData)
);
// Also snapshot individual pipeline records for waterfall tracking
const pipeSnap = db.prepare(`INSERT INTO pipeline_snapshots (snapshot_date, Opportunity_ID, Account_Name, Stage, Amount_USD, Forecast_Category) VALUES (?, ?, ?, ?, ?, ?)`);
const tx = db.transaction(() => {
for (const p of pipeline) {
pipeSnap.run(today, p.Opportunity_ID, p.Account_Name, p.Stage, p.Amount_USD, p.Forecast_Category);
}
});
tx();
return { id: result.lastInsertRowid };
}
// --- Pipeline Snapshots (for waterfall) ---
export function getPipelineSnapshots(date?: string): PipelineSnapshot[] {
const db = getDb();
if (date) return db.prepare('SELECT * FROM pipeline_snapshots WHERE snapshot_date = ?').all(date) as PipelineSnapshot[];
return db.prepare('SELECT DISTINCT snapshot_date FROM pipeline_snapshots ORDER BY snapshot_date DESC').all() as PipelineSnapshot[];
}
// --- Playbooks ---
export function getPlaybooks(): Playbook[] {
return getDb().prepare('SELECT * FROM playbooks ORDER BY play_name').all() as Playbook[];
}
export function upsertPlaybook(playbook: { id?: number; play_name: string; description?: string | null; steps_json: string }) {
const db = getDb();
if (playbook.id) {
db.prepare('UPDATE playbooks SET play_name=?, description=?, steps_json=? WHERE id=?').run(playbook.play_name, playbook.description || null, playbook.steps_json, playbook.id);
} else {
db.prepare('INSERT INTO playbooks (play_name, description, steps_json) VALUES (?, ?, ?) ON CONFLICT(play_name) DO UPDATE SET description=excluded.description, steps_json=excluded.steps_json').run(playbook.play_name, playbook.description || null, playbook.steps_json);
}
}
export function getPlaybookProgress(accountName?: string): PlaybookProgress[] {
const db = getDb();
if (accountName) return db.prepare('SELECT * FROM playbook_progress WHERE Account_Name = ?').all(accountName) as PlaybookProgress[];
return db.prepare('SELECT * FROM playbook_progress ORDER BY updated_at DESC').all() as PlaybookProgress[];
}
export function upsertPlaybookProgress(progress: { id?: number; Account_Name: string; playbook_id: number; current_step: number; status: string }) {
const db = getDb();
const now = new Date().toISOString();
if (progress.id) {
db.prepare('UPDATE playbook_progress SET current_step=?, status=?, updated_at=? WHERE id=?').run(progress.current_step, progress.status, now, progress.id);
} else {
db.prepare('INSERT INTO playbook_progress (Account_Name, playbook_id, current_step, status, started_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)').run(
progress.Account_Name, progress.playbook_id, progress.current_step, progress.status, now, now
);
}
}
export function seedDefaultPlaybooks() {
const db = getDb();
const existing = db.prepare('SELECT COUNT(*) as c FROM playbooks').get() as { c: number };
if (existing.c > 0) return;
const playbooks = [
{ play_name: 'New Logo Acquisition', description: 'Full sales cycle for net-new accounts', steps_json: JSON.stringify(['Research & Targeting', 'Initial Outreach', 'Discovery Meeting', 'Demo / Workshop', 'Executive Alignment', 'Proposal & Negotiation', 'Close']) },
{ play_name: 'Attach / Cross-Sell', description: 'Expand within existing accounts', steps_json: JSON.stringify(['Account Review & Whitespace ID', 'Champion Alignment', 'Discovery / Needs Assessment', 'Demo / POC', 'Business Case', 'Close']) },
{ play_name: 'Renewal Defense', description: 'Protect existing contracts approaching renewal', steps_json: JSON.stringify(['Renewal Assessment', 'Stakeholder Re-engagement', 'Value Realization Review', 'QBR / Executive Briefing', 'Renewal Negotiation', 'Contract Execution']) },
{ play_name: 'Competitive Displacement', description: 'Displace an incumbent competitor', steps_json: JSON.stringify(['Competitive Intel Gathering', 'Pain Point Discovery', 'Differentiation Demo', 'Executive Sponsor Alignment', 'POC / Bake-off', 'Proposal & Close']) },
{ play_name: 'Inbound Response', description: 'Respond to inbound interest or RFP', steps_json: JSON.stringify(['Qualify Inbound', 'Discovery Call', 'Technical Deep Dive', 'Proposal / RFP Response', 'Negotiation', 'Close']) },
];
const stmt = db.prepare('INSERT INTO playbooks (play_name, description, steps_json) VALUES (?, ?, ?)');
const tx = db.transaction(() => { for (const p of playbooks) stmt.run(p.play_name, p.description, p.steps_json); });
tx();
}
// --- District Targets ---
export function getDistrictTargets(quarter?: string): DistrictTarget[] {
const db = getDb();
if (quarter) return db.prepare('SELECT * FROM district_targets WHERE quarter = ? ORDER BY District_Name').all(quarter) as DistrictTarget[];
return db.prepare('SELECT * FROM district_targets ORDER BY quarter DESC, District_Name').all() as DistrictTarget[];
}
export function upsertDistrictTarget(target: { District_Name: string; quarter: string; activities_per_week: number; accounts_touched: number; pipeline_generated: number }) {
const db = getDb();
db.prepare(`INSERT INTO district_targets (District_Name, quarter, activities_per_week, accounts_touched, pipeline_generated) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(District_Name, quarter) DO UPDATE SET activities_per_week=excluded.activities_per_week, accounts_touched=excluded.accounts_touched, pipeline_generated=excluded.pipeline_generated`).run(
target.District_Name, target.quarter, target.activities_per_week, target.accounts_touched, target.pipeline_generated
);
}
// --- Data Quality ---
export function getDataQualityReport() {
const db = getDb();
const accounts = db.prepare('SELECT * FROM accounts').all() as AccountRecord[];
const pipeline = db.prepare("SELECT * FROM pipeline WHERE Status != 'Closed Lost' AND Stage NOT IN ('06-Closed Won', '07-Closed Lost')").all() as PipelineRecord[];
const issues: { type: string; severity: string; message: string; table: string; record_id: string }[] = [];
for (const a of accounts) {
if (!a.Priority || a.Priority === '') issues.push({ type: 'missing_field', severity: 'medium', message: `Missing Priority`, table: 'accounts', record_id: a.Account_Name });
if (!a.Tier || a.Tier === '') issues.push({ type: 'missing_field', severity: 'medium', message: `Missing Tier`, table: 'accounts', record_id: a.Account_Name });
if (!a.AD) issues.push({ type: 'missing_field', severity: 'low', message: `Missing AD assignment`, table: 'accounts', record_id: a.Account_Name });
if (!a.Next_Renewal_Date) issues.push({ type: 'missing_field', severity: 'low', message: `Missing Renewal Date`, table: 'accounts', record_id: a.Account_Name });
}
for (const p of pipeline) {
if (!p.Next_Step) issues.push({ type: 'missing_field', severity: 'high', message: `No Next Step defined`, table: 'pipeline', record_id: p.Opportunity_ID });
if (p.Expected_Close_Date && p.Expected_Close_Date < new Date().toISOString().split('T')[0]) issues.push({ type: 'stale_data', severity: 'high', message: `Close date in the past`, table: 'pipeline', record_id: p.Opportunity_ID });
if (!p.Champion_Name) issues.push({ type: 'missing_field', severity: 'medium', message: `No Champion identified`, table: 'pipeline', record_id: p.Opportunity_ID });
}
const totalFields = accounts.length * 5 + pipeline.length * 3;
const missingFields = issues.filter(i => i.type === 'missing_field').length;
const completeness = totalFields > 0 ? Math.round(((totalFields - missingFields) / totalFields) * 100) : 100;
return { issues, completeness, totalAccounts: accounts.length, totalPipeline: pipeline.length };
}
export function upsertTarget(record: Record<string, unknown>) {
const db = getDb();
const id = (record.Implementation_ID as string) || `IMPL-${Date.now()}`;

View File

@@ -90,6 +90,9 @@ function generateAccounts(): AccountRecord[] {
Next_Renewal_EAR: null,
Anchor_Contract_Date: null,
Anchor_Contract_EAR: null,
Google_Drive_URL: null,
Campaign_Artifacts_URL: null,
Competitors: null,
};
});
}

198
src/lib/scoring.ts Normal file
View File

@@ -0,0 +1,198 @@
import { PipelineRecord, AccountRecord, ActivityRecord } from '@/types/data';
import { differenceInDays, parseISO } from 'date-fns';
// --- Deal Risk Scoring ---
export interface DealRisk {
Opportunity_ID: string;
Account_Name: string;
risk_score: number; // 0-100, higher = more risky
risk_level: 'Low' | 'Medium' | 'High' | 'Critical';
risk_factors: string[];
}
export function scoreDealRisk(
deal: PipelineRecord,
activities: ActivityRecord[],
allDeals: PipelineRecord[]
): DealRisk {
const factors: string[] = [];
let score = 0;
const now = new Date();
// Skip closed deals
if (deal.Stage === '06-Closed Won' || deal.Stage === '07-Closed Lost') {
return { Opportunity_ID: deal.Opportunity_ID, Account_Name: deal.Account_Name, risk_score: 0, risk_level: 'Low', risk_factors: [] };
}
// Factor 1: Close date in the past
if (deal.Expected_Close_Date) {
const closeDate = parseISO(deal.Expected_Close_Date);
const daysOverdue = differenceInDays(now, closeDate);
if (daysOverdue > 30) {
score += 30;
factors.push(`Close date ${daysOverdue} days overdue`);
} else if (daysOverdue > 0) {
score += 20;
factors.push(`Close date ${daysOverdue} days past`);
}
}
// Factor 2: No next step defined
if (!deal.Next_Step || deal.Next_Step.trim() === '') {
score += 20;
factors.push('No next step defined');
}
// Factor 3: Days in stage (aging)
if (deal.Stage_Entered_Date || deal.Created_Date) {
const stageDate = parseISO(deal.Stage_Entered_Date || deal.Created_Date);
const daysInStage = differenceInDays(now, stageDate);
// Calculate median days in stage for comparison
const sameStageDays = allDeals
.filter(d => d.Stage === deal.Stage && d.Opportunity_ID !== deal.Opportunity_ID)
.map(d => differenceInDays(now, parseISO(d.Stage_Entered_Date || d.Created_Date)));
const medianDays = sameStageDays.length > 0
? sameStageDays.sort((a, b) => a - b)[Math.floor(sameStageDays.length / 2)]
: 30;
if (daysInStage > medianDays * 2) {
score += 25;
factors.push(`${daysInStage} days in stage (2x median)`);
} else if (daysInStage > medianDays * 1.5) {
score += 15;
factors.push(`${daysInStage} days in stage (above median)`);
}
}
// Factor 4: No recent activity on the account
const accountActivities = activities.filter(a => a.Account_Name === deal.Account_Name);
if (accountActivities.length === 0) {
score += 20;
factors.push('No activities logged for account');
} else {
const lastActivity = accountActivities.sort((a, b) => b.Activity_Date.localeCompare(a.Activity_Date))[0];
const daysSinceActivity = differenceInDays(now, parseISO(lastActivity.Activity_Date));
if (daysSinceActivity > 30) {
score += 15;
factors.push(`No activity in ${daysSinceActivity} days`);
}
}
// Factor 5: No champion identified
if (!deal.Champion_Name) {
score += 10;
factors.push('No champion identified');
}
const risk_level = score >= 60 ? 'Critical' : score >= 40 ? 'High' : score >= 20 ? 'Medium' : 'Low';
return {
Opportunity_ID: deal.Opportunity_ID,
Account_Name: deal.Account_Name,
risk_score: Math.min(score, 100),
risk_level,
risk_factors: factors,
};
}
// --- Account Health Score ---
export interface AccountHealth {
Account_Name: string;
health_score: number; // 0-100, higher = healthier
health_level: 'Excellent' | 'Good' | 'Fair' | 'Poor' | 'Critical';
factors: { label: string; score: number; max: number }[];
}
export function scoreAccountHealth(
account: AccountRecord,
activities: ActivityRecord[],
pipeline: PipelineRecord[]
): AccountHealth {
const now = new Date();
const factors: { label: string; score: number; max: number }[] = [];
// Factor 1: Activity Recency (0-25 points)
const acctActivities = activities.filter(a => a.Account_Name === account.Account_Name);
let recencyScore = 0;
if (acctActivities.length > 0) {
const lastTouch = acctActivities.sort((a, b) => b.Activity_Date.localeCompare(a.Activity_Date))[0];
const daysSince = differenceInDays(now, parseISO(lastTouch.Activity_Date));
if (daysSince <= 7) recencyScore = 25;
else if (daysSince <= 14) recencyScore = 20;
else if (daysSince <= 30) recencyScore = 15;
else if (daysSince <= 60) recencyScore = 8;
else recencyScore = 0;
}
factors.push({ label: 'Activity Recency', score: recencyScore, max: 25 });
// Factor 2: Touch Frequency (0-20 points)
let frequencyScore = 0;
const touchCount = account.Touch_Count || 0;
if (touchCount >= 5) frequencyScore = 20;
else if (touchCount >= 3) frequencyScore = 15;
else if (touchCount >= 2) frequencyScore = 10;
else if (touchCount >= 1) frequencyScore = 5;
factors.push({ label: 'Touch Frequency', score: frequencyScore, max: 20 });
// Factor 3: Pipeline Presence (0-20 points)
const acctPipeline = pipeline.filter(p => p.Account_Name === account.Account_Name && p.Stage !== '07-Closed Lost');
const openPipeline = acctPipeline.filter(p => p.Stage !== '06-Closed Won');
const closedWon = acctPipeline.filter(p => p.Stage === '06-Closed Won');
let pipelineScore = 0;
if (closedWon.length > 0) pipelineScore = 20;
else if (openPipeline.length > 0) pipelineScore = 12;
else pipelineScore = 0;
factors.push({ label: 'Pipeline Presence', score: pipelineScore, max: 20 });
// Factor 4: Renewal Risk (0-20 points — inverted: closer renewal = lower score unless engaged)
let renewalScore = 10; // default if no renewal
if (account.Next_Renewal_Date) {
const daysToRenewal = differenceInDays(parseISO(account.Next_Renewal_Date), now);
if (daysToRenewal <= 90) {
renewalScore = recencyScore >= 15 ? 20 : 0; // close renewal + recent engagement = good; close + no engagement = bad
} else if (daysToRenewal <= 180) {
renewalScore = recencyScore >= 10 ? 15 : 5;
} else {
renewalScore = 10;
}
}
factors.push({ label: 'Renewal Position', score: renewalScore, max: 20 });
// Factor 5: Engagement Quality (0-15 points)
let qualityScore = 0;
const activityTypes = new Set(acctActivities.map(a => a.Activity_Type));
if (activityTypes.has('Exec Meeting')) qualityScore += 5;
if (activityTypes.has('Demo') || activityTypes.has('Workshop')) qualityScore += 5;
if (activityTypes.has('Discovery')) qualityScore += 3;
if (activityTypes.size >= 3) qualityScore += 2;
qualityScore = Math.min(qualityScore, 15);
factors.push({ label: 'Engagement Quality', score: qualityScore, max: 15 });
const total = factors.reduce((s, f) => s + f.score, 0);
const health_level = total >= 80 ? 'Excellent' : total >= 60 ? 'Good' : total >= 40 ? 'Fair' : total >= 20 ? 'Poor' : 'Critical';
return {
Account_Name: account.Account_Name,
health_score: total,
health_level,
factors,
};
}
export const HEALTH_LEVEL_COLORS: Record<string, string> = {
'Excellent': '#16A34A',
'Good': '#61A60E',
'Fair': '#F59E0B',
'Poor': '#EA580C',
'Critical': '#DC2626',
};
export const RISK_LEVEL_COLORS: Record<string, string> = {
'Low': '#61A60E',
'Medium': '#F59E0B',
'High': '#EA580C',
'Critical': '#DC2626',
};

29
src/middleware.ts Normal file
View File

@@ -0,0 +1,29 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export async function middleware(req: NextRequest) {
const isAuthConfigured = process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET && process.env.AUTH_SECRET;
if (!isAuthConfigured) return NextResponse.next();
const isAuthRoute = req.nextUrl.pathname.startsWith('/auth') || req.nextUrl.pathname.startsWith('/api/auth');
if (isAuthRoute) return NextResponse.next();
const { auth } = await import('@/auth');
const session = await auth();
if (!session) {
return NextResponse.redirect(new URL('/auth/signin', req.url));
}
const role = (session as unknown as Record<string, unknown>).role;
const isWriteRoute = req.nextUrl.pathname === '/admin' || (req.nextUrl.pathname.startsWith('/api/admin') && req.method === 'POST');
if (isWriteRoute && role === 'readonly') {
return new NextResponse('Forbidden', { status: 403 });
}
return NextResponse.next();
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico|logo.svg).*)'],
};

View File

@@ -48,6 +48,7 @@ export interface AccountRecord {
Anchor_Contract_EAR: number | null;
Google_Drive_URL: string | null;
Campaign_Artifacts_URL: string | null;
Competitors: string | null;
}
export interface ActivityRecord {
@@ -101,6 +102,77 @@ export interface MetricTarget {
Notes: string | null;
}
export interface AccountNote {
id: number;
Account_Name: string;
note_text: string;
next_action: string | null;
is_pinned: number;
created_at: string;
created_by: string | null;
}
export interface Contact {
id: number;
Account_Name: string;
Contact_Name: string;
Role: string | null;
Sentiment: string | null;
Email: string | null;
LinkedIn_URL: string | null;
}
export interface Snapshot {
id: number;
snapshot_date: string;
period_type: 'weekly' | 'monthly' | 'quarterly';
total_accounts: number;
accounts_touched: number;
total_pipeline: number;
open_opps: number;
closed_won_amount: number;
total_activities: number;
accounts_by_priority_json: string;
pipeline_by_stage_json: string;
district_data_json: string;
}
export interface PipelineSnapshot {
id: number;
snapshot_date: string;
Opportunity_ID: string;
Account_Name: string;
Stage: string;
Amount_USD: number;
Forecast_Category: string;
}
export interface Playbook {
id: number;
play_name: string;
description: string | null;
steps_json: string;
}
export interface PlaybookProgress {
id: number;
Account_Name: string;
playbook_id: number;
current_step: number;
status: string;
started_at: string;
updated_at: string;
}
export interface DistrictTarget {
id: number;
District_Name: string;
quarter: string;
activities_per_week: number;
accounts_touched: number;
pipeline_generated: number;
}
export interface DashboardData {
pipeline: PipelineRecord[];
accounts: AccountRecord[];