From d2d563208606c7820a3d6112fbfe86790afdbd94 Mon Sep 17 00:00:00 2001 From: Chris Olson Date: Mon, 31 Aug 2026 18:07:46 -0400 Subject: [PATCH] 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 --- .env.example | 11 + src/app/(dashboard)/accounts/page.tsx | 78 +++- src/app/(dashboard)/activity/page.tsx | 76 +++- src/app/(dashboard)/goals/page.tsx | 293 ++++++++++++ src/app/(dashboard)/page.tsx | 10 +- src/app/(dashboard)/pipeline/page.tsx | 37 +- src/app/(dashboard)/playbooks/page.tsx | 252 +++++++++++ .../(dashboard)/reports/executive/page.tsx | 382 ++++++++++++++++ src/app/(dashboard)/reports/status/page.tsx | 423 ++++++++++++++++++ src/app/admin/page.tsx | 204 ++++++++- src/app/api/auth/[...nextauth]/route.ts | 3 + src/app/api/phase2/route.ts | 101 +++++ src/app/auth/error/page.tsx | 39 ++ src/app/auth/signin/page.tsx | 38 ++ src/auth.ts | 38 ++ src/components/account/AccountNotes.tsx | 272 +++++++++++ src/components/account/ContactMap.tsx | 244 ++++++++++ src/components/account/index.ts | 2 + src/components/layout/DashboardShell.tsx | 2 + src/components/layout/Sidebar.tsx | 36 ++ src/components/ui/ExportButton.tsx | 34 ++ src/components/ui/MobileActivityFAB.tsx | 221 +++++++++ src/components/ui/NextBestAction.tsx | 148 ++++++ src/lib/db.ts | 320 +++++++++++++ src/lib/mock-data.ts | 3 + src/lib/scoring.ts | 198 ++++++++ src/middleware.ts | 29 ++ src/types/data.ts | 72 +++ 28 files changed, 3541 insertions(+), 25 deletions(-) create mode 100644 .env.example create mode 100644 src/app/(dashboard)/goals/page.tsx create mode 100644 src/app/(dashboard)/playbooks/page.tsx create mode 100644 src/app/(dashboard)/reports/executive/page.tsx create mode 100644 src/app/(dashboard)/reports/status/page.tsx create mode 100644 src/app/api/auth/[...nextauth]/route.ts create mode 100644 src/app/api/phase2/route.ts create mode 100644 src/app/auth/error/page.tsx create mode 100644 src/app/auth/signin/page.tsx create mode 100644 src/auth.ts create mode 100644 src/components/account/AccountNotes.tsx create mode 100644 src/components/account/ContactMap.tsx create mode 100644 src/components/account/index.ts create mode 100644 src/components/ui/ExportButton.tsx create mode 100644 src/components/ui/MobileActivityFAB.tsx create mode 100644 src/components/ui/NextBestAction.tsx create mode 100644 src/lib/scoring.ts create mode 100644 src/middleware.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..89a823b --- /dev/null +++ b/.env.example @@ -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 diff --git a/src/app/(dashboard)/accounts/page.tsx b/src/app/(dashboard)/accounts/page.tsx index 4780ff6..2dea9f1 100644 --- a/src/app/(dashboard)/accounts/page.tsx +++ b/src/app/(dashboard)/accounts/page.tsx @@ -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 = { 'Low': '#6B7280', }; +const COMPETITOR_COLORS: Record = { + '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(null); const [renewalFilter, setRenewalFilter] = useState('all'); const [priorityFilter, setPriorityFilter] = useState(null); + const [competitorFilter, setCompetitorFilter] = useState(null); const [selectedAccount, setSelectedAccount] = useState(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 (
@@ -298,6 +321,17 @@ export default function AccountExplorer() { + {selectedAccount.Competitors && ( +
+
Competitors
+
+ {selectedAccount.Competitors.split(',').map(c => c.trim()).filter(Boolean).map(comp => ( + {comp} + ))} +
+
+ )} + {(selectedAccount.AD || selectedAccount.IMS_BA || selectedAccount.Area_Sales_Leader || selectedAccount.DM) && (
{selectedAccount.Area_Sales_Leader && ( @@ -354,6 +388,12 @@ export default function AccountExplorer() { )}
+ {/* Account Notes & Contacts */} +
+ + +
+ {/* Activity Timeline */}

Activity Timeline ({accountActivities.length} activities)

@@ -444,7 +484,7 @@ export default function AccountExplorer() { - [value, 'Accounts']} /> + [Number(value), 'Accounts']} /> {priorityChartData.map(entry => ( @@ -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" /> + {['Tier 1', 'Tier 2', 'Tier 3', 'Tier 4'].map(t => ( + +
+ + ) : ( +
+ {/* 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 ( +
+
+ {metric.label} + + {metric.format(metric.actual)} / {target ? metric.format(metric.target) : '—'} + +
+
+
+
+ {target && ( +
+ {pct}% +
+ )} +
+ ); + })} + +
+ +
+
+ )} + + ); + })} +
+ + {/* Territory Summary */} + +
+ + + + + + + + + + + + {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 ( + + + {target ? ( + <> + + + + + + ) : ( + + )} + + ); + })} + +
DistrictActivities/WkAccts TouchedPipelineOverall
{DISTRICT_SHORT[d] || d} + + {metrics[0]}% + + + + {metrics[1]}% + + + + {metrics[2]}% + + + + {overall}% + + No targets set
+
+
+ + ); +} diff --git a/src/app/(dashboard)/page.tsx b/src/app/(dashboard)/page.tsx index c9adf86..add27fe 100644 --- a/src/app/(dashboard)/page.tsx +++ b/src/app/(dashboard)/page.tsx @@ -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() { -
+
{funnelData.map((stage, i) => { @@ -204,6 +205,11 @@ export default function ExecutiveOverview() {
+ + {/* Next Best Action Engine */} + + +
); } diff --git a/src/app/(dashboard)/pipeline/page.tsx b/src/app/(dashboard)/pipeline/page.tsx index ab7e7bc..57b9c4a 100644 --- a/src/app/(dashboard)/pipeline/page.tsx +++ b/src/app/(dashboard)/pipeline/page.tsx @@ -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[1], allDeals); return (
@@ -23,6 +26,23 @@ function DealDetailPanel({ deal, onClose }: { deal: PipelineRecord; onClose: ()
+ {/* Deal Risk Score */} + {risk.risk_score > 0 && ( +
+
+ Deal Risk: {risk.risk_level} + {risk.risk_score} +
+
+
+
+ {risk.risk_factors.map((f, i) => ( +
+ ! {f} +
+ ))} +
+ )}
Amount
@@ -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(null); const [stageFilter, setStageFilter] = useState(null); const [forecastQuickFilter, setForecastQuickFilter] = useState(false); @@ -162,6 +182,10 @@ export default function PipelineDeepDive() {
+
+ +
+ {/* Stage filter pills */}
+ +
+ + {editing ? ( + +
+
+ + 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" /> +
+
+ + 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" /> +
+
+ +