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

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>
{mismatch && (
<div className="flex items-center gap-1 mb-2 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}
{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 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,16 +313,69 @@ 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={
<input
type="text"
placeholder="Search activities..."
value={searchQuery}
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"
/>
<div className="flex items-center gap-2">
<input
type="text"
placeholder="Search activities..."
value={searchQuery}
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>
);
}