Build AgentMinder Campaign Command Center - Phase 1 complete

Full-featured sales campaign dashboard replacing Looker Studio with
responsive Next.js app. Includes Executive Overview, Account Explorer
with priority algorithm, Activities with detail overlay, Pipeline/Opps
with deal panels, Implementation tracking, and Data Admin with CSV
import and CRUD operations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-08-31 13:55:01 -04:00
parent fce80c3d45
commit 3c8c8ee594
33 changed files with 6289 additions and 123 deletions

View File

@@ -0,0 +1,665 @@
'use client';
import { useData } from '@/lib/data-context';
import { PageHeader } from '@/components/ui/PageHeader';
import { ChartCard } from '@/components/ui/ChartCard';
import { Scorecard } from '@/components/ui/Scorecard';
import { formatCurrency, CHART_COLORS, STATUS_COLORS, DISTRICT_SHORT, STAGE_COLORS, TIER_COLORS } from '@/lib/formatters';
import { useMemo, useState } from 'react';
import { parseISO, format, differenceInDays, eachDayOfInterval, addMonths, addQuarters } from 'date-fns';
import { AccountRecord } from '@/types/data';
import {
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell,
} from 'recharts';
const ACTIVITY_TYPE_COLORS: Record<string, string> = {
'Launch Briefing': CHART_COLORS.navy,
'Exec Meeting': CHART_COLORS.darkGreen,
'Discovery': CHART_COLORS.azure,
'QBR Attach': CHART_COLORS.aqua,
'Demo': CHART_COLORS.green,
'Workshop': CHART_COLORS.brightBlue,
'Email': CHART_COLORS.purple,
'Call': CHART_COLORS.lightBlue,
};
const PRIORITY_COLORS: Record<string, string> = {
'High': '#DC2626',
'Medium': '#F59E0B',
'Low': '#6B7280',
};
const RENEWAL_FILTER_OPTIONS = [
{ label: 'All Renewals', value: 'all' },
{ label: 'Next 90 Days', value: '90d' },
{ label: 'Next 1 Quarter', value: '1q' },
{ label: 'Next 2 Quarters', value: '2q' },
{ label: 'Next 4 Quarters', value: '4q' },
{ label: 'No Renewal Date', value: 'none' },
];
function getSuggestedPriority(account: AccountRecord): { level: string; reason: string } {
const now = new Date();
const renewalDate = account.Next_Renewal_Date ? parseISO(account.Next_Renewal_Date) : null;
const daysToRenewal = renewalDate ? differenceInDays(renewalDate, now) : null;
const touched = (account.Touch_Count || 0) > 0;
const hasArrAbove50k = (account.Current_ARR_USD || 0) > 50000;
const positiveStatus = ['40% - Verify', '60% - Prove', '80% - Pricing', '20% - Research'].includes(account.AgentMinder_Status);
const negativeStatus = ['Not Touched', 'Lost'].includes(account.AgentMinder_Status);
const earlyStatus = ['10% - Prospect', '15% - Prospect - No Opp'].includes(account.AgentMinder_Status);
// FY27 runs Feb 2027 Jan 2028. Q1-Q3 = Feb 2027 Oct 2027 → roughly next 2-14 months from now
// Priority 1: Renewal in ~0-14 months, positive disposition, or high ARR
if (daysToRenewal !== null && daysToRenewal <= 420 && daysToRenewal >= 0) {
if (positiveStatus || hasArrAbove50k || touched) {
return { level: 'High', reason: `Renewal in ${daysToRenewal} days${positiveStatus ? ', positive disposition' : ''}${hasArrAbove50k ? ', high ARR' : ''}` };
}
if (daysToRenewal <= 180) {
return { level: 'High', reason: `Near-term renewal (${daysToRenewal} days), engage under 60 days` };
}
}
// Priority 2: Renewal in 14-24 months, early stage, or neutral
if (daysToRenewal !== null && daysToRenewal > 420 && daysToRenewal <= 730) {
return { level: 'Medium', reason: `Mid-term renewal (${Math.round(daysToRenewal / 30)} months), pipeline building` };
}
if (daysToRenewal !== null && daysToRenewal <= 420 && earlyStatus) {
return { level: 'Medium', reason: `Renewal in ${daysToRenewal} days but early stage, needs nurturing` };
}
if (touched && !negativeStatus && !positiveStatus) {
return { level: 'Medium', reason: 'Active engagement, neutral disposition' };
}
// Priority 3: No near-term event, negative disposition, or untouched
if (negativeStatus) {
return { level: 'Low', reason: 'Negative disposition or not touched' };
}
if (daysToRenewal === null) {
return { level: 'Low', reason: 'No renewal date set' };
}
if (daysToRenewal > 730) {
return { level: 'Low', reason: `Distant renewal (${Math.round(daysToRenewal / 30)} months), monitor and nurture` };
}
return { level: 'Low', reason: 'No compelling near-term event' };
}
export default function AccountExplorer() {
const { filtered } = useData();
const { accounts, pipeline, activities, targets } = filtered;
const [search, setSearch] = useState('');
const [tierFilter, setTierFilter] = useState<string | null>(null);
const [statusFilter, setStatusFilter] = useState<string | null>(null);
const [districtFilter, setDistrictFilter] = useState<string | null>(null);
const [adFilter, setAdFilter] = useState<string | null>(null);
const [imsbaFilter, setImsbaFilter] = useState<string | null>(null);
const [renewalFilter, setRenewalFilter] = useState<string>('all');
const [priorityFilter, setPriorityFilter] = 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]);
const imsbaValues = useMemo(() => Array.from(new Set(accounts.map(a => a.IMS_BA).filter(Boolean))).sort() as string[], [accounts]);
const filteredAccounts = useMemo(() => {
const now = new Date();
let result = accounts;
if (search) {
const q = search.toLowerCase();
result = result.filter(a => a.Account_Name.toLowerCase().includes(q));
}
if (tierFilter) result = result.filter(a => a.Tier === tierFilter);
if (statusFilter) result = result.filter(a => a.AgentMinder_Status === statusFilter);
if (districtFilter) result = result.filter(a => a.District_Name === districtFilter);
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 (renewalFilter !== 'all') {
if (renewalFilter === 'none') {
result = result.filter(a => !a.Next_Renewal_Date);
} else {
let cutoff: Date;
if (renewalFilter === '90d') cutoff = new Date(now.getTime() + 90 * 86400000);
else if (renewalFilter === '1q') cutoff = addQuarters(now, 1);
else if (renewalFilter === '2q') cutoff = addQuarters(now, 2);
else cutoff = addQuarters(now, 4);
result = result.filter(a => {
if (!a.Next_Renewal_Date) return false;
const rd = parseISO(a.Next_Renewal_Date);
return rd >= now && rd <= cutoff;
});
}
}
return result;
}, [accounts, search, tierFilter, statusFilter, districtFilter, adFilter, imsbaFilter, renewalFilter, priorityFilter]);
const kpis = useMemo(() => {
const acctNames = new Set(filteredAccounts.map(a => a.Account_Name));
const relevantPipeline = pipeline.filter(p => acctNames.has(p.Account_Name));
const openPipeline = relevantPipeline.filter(p => p.Stage !== '06-Closed Won' && p.Stage !== '07-Closed Lost');
const closedWon = relevantPipeline.filter(p => p.Stage === '06-Closed Won');
return {
numAccounts: filteredAccounts.length,
numOpps: openPipeline.length,
pipelineUsd: openPipeline.reduce((s, p) => s + p.Amount_USD, 0),
closedUsd: closedWon.reduce((s, p) => s + (p.Closed_Amount_USD || p.Amount_USD), 0),
totalArr: filteredAccounts.reduce((s, a) => s + (a.Current_ARR_USD || 0), 0),
touched: filteredAccounts.filter(a => (a.Touch_Count || 0) > 0).length,
};
}, [filteredAccounts, pipeline]);
// Chart 1: Priority breakdown
const priorityChartData = useMemo(() => {
const counts: Record<string, number> = { High: 0, Medium: 0, Low: 0 };
filteredAccounts.forEach(a => {
const p = a.Priority || 'Low';
counts[p] = (counts[p] || 0) + 1;
});
return Object.entries(counts).map(([name, value]) => ({ name, value }));
}, [filteredAccounts]);
// Chart 2: Activity heatmap for filtered accounts
const heatmapData = useMemo(() => {
const acctNames = new Set(filteredAccounts.map(a => a.Account_Name));
const relevantActivities = activities.filter(a => acctNames.has(a.Account_Name));
const dayCounts = new Map<string, number>();
relevantActivities.forEach(a => {
dayCounts.set(a.Activity_Date, (dayCounts.get(a.Activity_Date) || 0) + 1);
});
const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth() - 3, 1);
const days = eachDayOfInterval({ start, end: now });
return days.map(d => {
const key = format(d, 'yyyy-MM-dd');
return { date: key, count: dayCounts.get(key) || 0, day: d.getDay(), week: Math.floor(differenceInDays(d, start) / 7) };
});
}, [filteredAccounts, activities]);
const maxHeatVal = Math.max(...heatmapData.map(d => d.count), 1);
const accountPipeline = useMemo(() => {
if (!selectedAccount) return [];
return pipeline.filter(p => p.Account_Name === selectedAccount.Account_Name);
}, [selectedAccount, pipeline]);
const accountActivities = useMemo(() => {
if (!selectedAccount) return [];
return activities
.filter(a => a.Account_Name === selectedAccount.Account_Name)
.sort((a, b) => b.Activity_Date.localeCompare(a.Activity_Date));
}, [selectedAccount, activities]);
const accountTarget = useMemo(() => {
if (!selectedAccount) return null;
return targets.find(t => t.Account_Name === selectedAccount.Account_Name) || null;
}, [selectedAccount, targets]);
const getAccountStats = (account: AccountRecord) => {
const pipelineTotal = pipeline.filter(p => p.Account_Name === account.Account_Name && p.Stage !== '06-Closed Won' && p.Stage !== '07-Closed Lost').reduce((s, p) => s + p.Amount_USD, 0);
return { pipelineTotal };
};
const clearAllFilters = () => {
setTierFilter(null);
setStatusFilter(null);
setDistrictFilter(null);
setAdFilter(null);
setImsbaFilter(null);
setPriorityFilter(null);
setRenewalFilter('all');
setSearch('');
};
const hasFilters = tierFilter || statusFilter || districtFilter || adFilter || imsbaFilter || priorityFilter || renewalFilter !== 'all' || search;
if (selectedAccount) {
const suggestion = getSuggestedPriority(selectedAccount);
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">
<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 to accounts
</button>
{/* Account Header */}
<div className="bg-card-bg rounded-xl border border-card-border p-5 mb-4">
<div className="flex flex-wrap items-start gap-3">
<div className="flex-1">
<h1 className="text-xl font-bold text-foreground">{selectedAccount.Account_Name}</h1>
<div className="flex flex-wrap items-center gap-2 mt-2">
<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="text-xs text-muted">{DISTRICT_SHORT[selectedAccount.District_Name]}</span>
</div>
</div>
<div className="text-right">
{selectedAccount.Current_ARR_USD != null && selectedAccount.Current_ARR_USD > 0 && (
<div>
<div className="text-[10px] text-muted uppercase tracking-wider">Current ARR</div>
<div className="text-lg font-bold">{formatCurrency(selectedAccount.Current_ARR_USD, true)}</div>
</div>
)}
</div>
</div>
{/* Suggested Priority */}
{suggestion.level !== selectedAccount.Priority && (
<div className="mt-3 p-3 rounded-lg border border-dashed" style={{ borderColor: PRIORITY_COLORS[suggestion.level] || '#94A3B8', backgroundColor: `${PRIORITY_COLORS[suggestion.level]}08` }}>
<div className="flex items-center gap-2">
<svg className="w-4 h-4 flex-shrink-0" style={{ color: PRIORITY_COLORS[suggestion.level] }} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" /></svg>
<span className="text-xs font-semibold" style={{ color: PRIORITY_COLORS[suggestion.level] }}>
Suggested: {suggestion.level} Priority
</span>
<span className="text-xs text-muted"> {suggestion.reason}</span>
</div>
</div>
)}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-4 pt-4 border-t border-card-border">
<div>
<div className="text-[10px] text-muted uppercase">Touches</div>
<div className="text-sm font-semibold">{selectedAccount.Touch_Count || 0}</div>
</div>
<div>
<div className="text-[10px] text-muted uppercase">First Touched</div>
<div className="text-sm font-semibold">{selectedAccount.Date_First_Touched || '—'}</div>
</div>
<div>
<div className="text-[10px] text-muted uppercase">Last Touched</div>
<div className="text-sm font-semibold">{selectedAccount.Date_Last_Touched || '—'}</div>
</div>
<div>
<div className="text-[10px] text-muted uppercase">MAP in Place</div>
<div className="text-sm font-semibold">{selectedAccount.MAP_In_Place_YN === 'Y' ? 'Yes' : 'No'}</div>
</div>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-3 pt-3 border-t border-card-border">
<div>
<div className="text-[10px] text-muted uppercase">Next Renewal</div>
<div className="text-sm font-semibold">{selectedAccount.Next_Renewal_Date || '—'}</div>
</div>
<div>
<div className="text-[10px] text-muted uppercase">Renewal EAR</div>
<div className="text-sm font-semibold">{selectedAccount.Next_Renewal_EAR ? formatCurrency(selectedAccount.Next_Renewal_EAR, true) : '—'}</div>
</div>
<div>
<div className="text-[10px] text-muted uppercase">Anchor Contract</div>
<div className="text-sm font-semibold">{selectedAccount.Anchor_Contract_Date || '—'}</div>
</div>
<div>
<div className="text-[10px] text-muted uppercase">Anchor EAR</div>
<div className="text-sm font-semibold">{selectedAccount.Anchor_Contract_EAR ? formatCurrency(selectedAccount.Anchor_Contract_EAR, true) : '—'}</div>
</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 && (
<div>
<div className="text-[10px] text-muted uppercase">Area Sales Leader</div>
<div className="text-sm font-semibold">{selectedAccount.Area_Sales_Leader}</div>
</div>
)}
{selectedAccount.DM && (
<div>
<div className="text-[10px] text-muted uppercase">DM</div>
<div className="text-sm font-semibold">{selectedAccount.DM}</div>
</div>
)}
{selectedAccount.AD && (
<div>
<div className="text-[10px] text-muted uppercase">AD</div>
<div className="text-sm font-semibold">{selectedAccount.AD}</div>
</div>
)}
{selectedAccount.IMS_BA && (
<div>
<div className="text-[10px] text-muted uppercase">IMS BA</div>
<div className="text-sm font-semibold">{selectedAccount.IMS_BA}</div>
</div>
)}
</div>
)}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Pipeline Section */}
<div className="bg-card-bg rounded-xl border border-card-border p-5">
<h3 className="text-sm font-semibold mb-3">Pipeline ({accountPipeline.length} opportunities)</h3>
{accountPipeline.length === 0 ? (
<div className="text-xs text-muted py-4 text-center">No pipeline opportunities</div>
) : (
<div className="space-y-2">
{accountPipeline.map(opp => (
<div key={opp.Opportunity_ID} className="border border-card-border rounded-lg p-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{formatCurrency(opp.Amount_USD, true)}</span>
<span className="px-2 py-0.5 rounded-full text-[10px] text-white font-medium" style={{ backgroundColor: STAGE_COLORS[opp.Stage] || CHART_COLORS.navy }}>{opp.Stage}</span>
</div>
<div className="flex items-center gap-3 mt-1.5 text-xs text-muted">
<span>{opp.Forecast_Category}</span>
<span>{opp.Probability_Pct}% prob</span>
{opp.Expected_Close_Date && <span>Close: {format(parseISO(opp.Expected_Close_Date), 'MMM d')}</span>}
</div>
{opp.Next_Step && <div className="text-xs text-muted mt-1.5 italic">{opp.Next_Step}</div>}
</div>
))}
</div>
)}
</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>
{accountActivities.length === 0 ? (
<div className="text-xs text-muted py-4 text-center">No activities logged</div>
) : (
<div className="relative">
<div className="absolute left-3 top-0 bottom-0 w-px bg-card-border" />
<div className="space-y-4">
{accountActivities.slice(0, 20).map((a, i) => (
<div key={i} className="flex items-start gap-3 relative">
<div
className="w-6 h-6 rounded-full flex items-center justify-center flex-shrink-0 relative z-10"
style={{ backgroundColor: ACTIVITY_TYPE_COLORS[a.Activity_Type] || CHART_COLORS.navy }}
>
<div className="w-2 h-2 rounded-full bg-white" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs font-medium">{a.Activity_Type}</span>
<span className="text-[10px] text-muted">{format(parseISO(a.Activity_Date), 'MMM d, yyyy')}</span>
</div>
{a.Contact_Name && <div className="text-[10px] text-muted mt-0.5">with {a.Contact_Name}</div>}
{a.Notes && <div className="text-xs text-muted mt-1 bg-gray-50 rounded p-2">{a.Notes}</div>}
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
{/* Implementation Status */}
{accountTarget && (
<div className="bg-card-bg rounded-xl border border-card-border p-5 mt-4">
<h3 className="text-sm font-semibold mb-3">Implementation Status</h3>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div>
<div className="text-[10px] text-muted uppercase">Stage</div>
<div className="text-sm font-medium">{accountTarget.Implementation_Stage}</div>
</div>
<div>
<div className="text-[10px] text-muted uppercase">Health</div>
<div className="flex items-center gap-1.5">
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: accountTarget.Health_Status ? { Green: '#61A60E', Yellow: '#F59E0B', Red: '#EF4444' }[accountTarget.Health_Status] : '#94A3B8' }} />
<span className="text-sm font-medium">{accountTarget.Health_Status || '—'}</span>
</div>
</div>
<div>
<div className="text-[10px] text-muted uppercase">Go-Live Date</div>
<div className="text-sm font-medium">{accountTarget.Go_Live_Date || '—'}</div>
</div>
{accountTarget.Notes && (
<div>
<div className="text-[10px] text-muted uppercase">Notes</div>
<div className="text-sm">{accountTarget.Notes}</div>
</div>
)}
</div>
</div>
)}
</div>
);
}
return (
<div>
<PageHeader title="Account Explorer" subtitle="Tell me everything about this account" />
{/* KPI Summary Cards */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3 mb-4">
<Scorecard label="Accounts" value={kpis.numAccounts} />
<Scorecard label="Touched" value={kpis.touched} subtitle={kpis.numAccounts > 0 ? `${Math.round((kpis.touched / kpis.numAccounts) * 100)}%` : '0%'} color={kpis.touched > 0 ? 'green' : undefined} />
<Scorecard label="Open Opps" value={kpis.numOpps} />
<Scorecard label="Pipeline" value={formatCurrency(kpis.pipelineUsd, true)} />
<Scorecard label="Closed Won" value={formatCurrency(kpis.closedUsd, true)} color={kpis.closedUsd > 0 ? 'green' : undefined} />
<Scorecard label="Total ARR" value={formatCurrency(kpis.totalArr, true)} />
</div>
{/* Summary Charts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4">
{/* Chart 1: Priority Breakdown */}
<ChartCard title="Account Prioritization" subtitle={`${filteredAccounts.length} accounts by priority level`}>
<div className="flex items-center gap-6">
<div className="w-48 h-36">
<ResponsiveContainer width="100%" height="100%">
<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']} />
<Bar dataKey="value" radius={[0, 4, 4, 0]} barSize={20}>
{priorityChartData.map(entry => (
<Cell key={entry.name} fill={PRIORITY_COLORS[entry.name] || '#94A3B8'} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
<div className="flex-1 space-y-2">
{priorityChartData.map(d => (
<div key={d.name} className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded" style={{ backgroundColor: PRIORITY_COLORS[d.name] }} />
<span className="text-xs font-medium">{d.name}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-sm font-bold">{d.value}</span>
<span className="text-[10px] text-muted">{filteredAccounts.length > 0 ? `${Math.round((d.value / filteredAccounts.length) * 100)}%` : '0%'}</span>
</div>
</div>
))}
</div>
</div>
</ChartCard>
{/* Chart 2: Activity Heatmap */}
<ChartCard title="Activity Heat Map" subtitle="Daily activity for filtered accounts (last 3 months)">
<div className="overflow-x-auto">
<div className="flex gap-[2px] min-w-[500px]">
{Array.from({ length: Math.max(...heatmapData.map(d => d.week), 0) + 1 }, (_, weekIdx) => (
<div key={weekIdx} className="flex flex-col gap-[2px]">
{Array.from({ length: 7 }, (_, dayIdx) => {
const cell = heatmapData.find(d => d.week === weekIdx && d.day === dayIdx);
const intensity = cell ? cell.count / maxHeatVal : 0;
return (
<div
key={dayIdx}
className="w-3 h-3 rounded-sm"
style={{
backgroundColor: intensity === 0
? '#E2E8F0'
: `rgba(0, 92, 138, ${0.2 + intensity * 0.8})`,
}}
title={cell ? `${cell.date}: ${cell.count} activities` : ''}
/>
);
})}
</div>
))}
</div>
<div className="flex items-center gap-1 mt-2 text-[10px] text-muted">
<span>Less</span>
{[0, 0.25, 0.5, 0.75, 1].map(i => (
<div key={i} className="w-3 h-3 rounded-sm" style={{ backgroundColor: i === 0 ? '#E2E8F0' : `rgba(0, 92, 138, ${0.2 + i * 0.8})` }} />
))}
<span>More</span>
</div>
</div>
</ChartCard>
</div>
{/* Search & Filters */}
<div className="flex flex-wrap gap-2 mb-2">
<input
type="text"
placeholder="Search accounts..."
value={search}
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 => (
<button
key={t}
onClick={() => setTierFilter(tierFilter === t ? null : t)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition ${tierFilter === t ? 'text-white' : 'bg-gray-100 text-muted hover:bg-gray-200'}`}
style={tierFilter === t ? { backgroundColor: TIER_COLORS[t] } : undefined}
>{t}</button>
))}
{Array.from(new Set(accounts.map(a => a.AgentMinder_Status))).sort().map(s => (
<button
key={s}
onClick={() => setStatusFilter(statusFilter === s ? null : s)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition ${statusFilter === s ? 'text-white' : 'bg-gray-100 text-muted hover:bg-gray-200'}`}
style={statusFilter === s ? { backgroundColor: STATUS_COLORS[s] || CHART_COLORS.navy } : undefined}
>{s}</button>
))}
</div>
{/* Priority filter pills */}
<div className="flex flex-wrap gap-2 mb-2">
{['High', 'Medium', 'Low'].map(p => (
<button
key={p}
onClick={() => setPriorityFilter(priorityFilter === p ? null : p)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition ${priorityFilter === p ? 'text-white' : 'bg-gray-100 text-muted hover:bg-gray-200'}`}
style={priorityFilter === p ? { backgroundColor: PRIORITY_COLORS[p] } : undefined}
>{p} Priority</button>
))}
</div>
{/* District, AD, IMS BA, Renewal dropdowns */}
<div className="flex flex-wrap gap-2 mb-4">
<select
value={districtFilter || ''}
onChange={e => setDistrictFilter(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 Districts</option>
{Array.from(new Set(accounts.map(a => a.District_Name))).sort().map(d => (
<option key={d} value={d}>{DISTRICT_SHORT[d] || d}</option>
))}
</select>
{adValues.length > 0 && (
<select
value={adFilter || ''}
onChange={e => setAdFilter(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 ADs</option>
{adValues.map(v => <option key={v} value={v}>{v}</option>)}
</select>
)}
{imsbaValues.length > 0 && (
<select
value={imsbaFilter || ''}
onChange={e => setImsbaFilter(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 IMS BAs</option>
{imsbaValues.map(v => <option key={v} value={v}>{v}</option>)}
</select>
)}
<select
value={renewalFilter}
onChange={e => setRenewalFilter(e.target.value)}
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"
>
{RENEWAL_FILTER_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</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
</button>
)}
</div>
{/* Account Cards Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{filteredAccounts.map(account => {
const stats = getAccountStats(account);
const suggestion = getSuggestedPriority(account);
const mismatch = suggestion.level !== account.Priority;
return (
<div
key={account.Account_Name}
onClick={() => setSelectedAccount(account)}
className="bg-card-bg rounded-xl border border-card-border p-4 cursor-pointer hover:shadow-md hover:border-brand-azure/30 transition-all"
>
<div className="flex items-start justify-between mb-2">
<h3 className="text-sm font-semibold text-foreground leading-tight pr-2">{account.Account_Name}</h3>
<div className="flex items-center gap-1 flex-shrink-0">
<span className="px-1.5 py-0.5 rounded text-[9px] text-white font-bold" style={{ backgroundColor: TIER_COLORS[account.Tier] || '#94A3B8' }}>{account.Tier || 'N/A'}</span>
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: STATUS_COLORS[account.AgentMinder_Status] }} />
</div>
</div>
<div className="flex items-center gap-1.5 mb-2">
<span className="px-1.5 py-0.5 rounded text-[9px] font-medium text-white" style={{ backgroundColor: PRIORITY_COLORS[account.Priority] || '#94A3B8' }}>{account.Priority}</span>
<span className="px-1.5 py-0.5 rounded text-[9px] font-medium text-white" style={{ backgroundColor: STATUS_COLORS[account.AgentMinder_Status] || '#94A3B8' }}>{account.AgentMinder_Status}</span>
<span className="text-[10px] text-muted">{DISTRICT_SHORT[account.District_Name]}</span>
{account.AD && <span className="text-[10px] text-muted">| {account.AD}</span>}
{(account.Google_Drive_URL || account.Campaign_Artifacts_URL) && (
<div className="flex items-center gap-1.5 ml-auto">
{account.Google_Drive_URL && (
<a href={account.Google_Drive_URL} target="_blank" rel="noopener noreferrer" onClick={e => e.stopPropagation()} title="Google Drive" className="text-muted hover:text-brand-navy transition">
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor"><path d="M7.71 3.5L1.15 15l3.43 5.97h6.86l-3.43-5.97L7.71 3.5zm.57 0h6.86L21.71 15h-6.86L8.28 3.5zm7.14 12.03L18.85 21H5.15l3.43-5.47h6.84z"/></svg>
</a>
)}
{account.Campaign_Artifacts_URL && (
<a href={account.Campaign_Artifacts_URL} target="_blank" rel="noopener noreferrer" onClick={e => e.stopPropagation()} title="Campaign Artifacts" className="text-muted hover:text-brand-navy transition">
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
</a>
)}
</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}
</div>
)}
<div className="grid grid-cols-2 gap-2 text-xs">
<div>
<div className="text-[10px] text-muted">ARR</div>
<div className="font-medium">{account.Current_ARR_USD ? formatCurrency(account.Current_ARR_USD, true) : '—'}</div>
</div>
<div>
<div className="text-[10px] text-muted">Pipeline</div>
<div className="font-medium">{stats.pipelineTotal > 0 ? formatCurrency(stats.pipelineTotal, true) : '—'}</div>
</div>
<div>
<div className="text-[10px] text-muted">Next Renewal</div>
<div className="font-medium">{account.Next_Renewal_Date || '—'}</div>
</div>
<div>
<div className="text-[10px] text-muted">Renewal EAR</div>
<div className="font-medium">{account.Next_Renewal_EAR ? formatCurrency(account.Next_Renewal_EAR, true) : '—'}</div>
</div>
</div>
</div>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,598 @@
'use client';
import { useData } from '@/lib/data-context';
import { ChartCard } from '@/components/ui/ChartCard';
import { PageHeader } from '@/components/ui/PageHeader';
import { CHART_COLORS, CHART_PALETTE, DISTRICT_SHORT, formatPercent, formatCurrency, STATUS_COLORS, TIER_COLORS, STAGE_COLORS } from '@/lib/formatters';
import { useMemo, useState } from 'react';
import {
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend,
PieChart, Pie, Cell,
} from 'recharts';
import { parseISO, format, startOfWeek, differenceInDays, eachDayOfInterval } from 'date-fns';
import { ActivityRecord } from '@/types/data';
const TYPE_COLORS: Record<string, string> = {
'Launch Briefing': CHART_COLORS.navy,
'Exec Meeting': CHART_COLORS.darkGreen,
'Discovery': CHART_COLORS.azure,
'QBR Attach': CHART_COLORS.aqua,
'Demo': CHART_COLORS.green,
'Workshop': CHART_COLORS.brightBlue,
'Email': CHART_COLORS.purple,
'Call': CHART_COLORS.lightBlue,
};
const PRIORITY_COLORS: Record<string, string> = {
'High': '#DC2626',
'Medium': '#F59E0B',
'Low': '#6B7280',
};
function CustomTooltip({ active, payload, label }: { active?: boolean; payload?: Array<{ name: string; value: number; color: string }>; label?: string }) {
if (!active || !payload?.length) return null;
return (
<div className="bg-white rounded-lg shadow-lg border border-card-border px-3 py-2 text-xs z-50">
<div className="font-semibold text-foreground mb-1">{label}</div>
{payload.map((p, i) => (
<div key={i} className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: p.color }} />
<span className="text-muted">{p.name}:</span>
<span className="font-medium">{p.value}</span>
</div>
))}
</div>
);
}
export default function ActivityDeepDive() {
const { filtered, config } = useData();
const { activities, accounts, pipeline, targets } = filtered;
const [typeFilter, setTypeFilter] = useState<Set<string>>(new Set());
const [searchQuery, setSearchQuery] = useState('');
const [sortField, setSortField] = useState<'Activity_Date' | 'Account_Name' | 'Activity_Type'>('Activity_Date');
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
const [page, setPage] = useState(0);
const [selectedActivity, setSelectedActivity] = useState<ActivityRecord | null>(null);
const pageSize = 15;
const filteredActivities = useMemo(() => {
let result = activities;
if (typeFilter.size > 0) {
result = result.filter(a => typeFilter.has(a.Activity_Type));
}
if (searchQuery) {
const q = searchQuery.toLowerCase();
result = result.filter(a =>
a.Account_Name.toLowerCase().includes(q) ||
a.Activity_Type.toLowerCase().includes(q) ||
(a.Notes || '').toLowerCase().includes(q) ||
(a.Contact_Name || '').toLowerCase().includes(q)
);
}
return result;
}, [activities, typeFilter, searchQuery]);
const heatmapData = useMemo(() => {
const dayCounts = new Map<string, number>();
filteredActivities.forEach(a => {
dayCounts.set(a.Activity_Date, (dayCounts.get(a.Activity_Date) || 0) + 1);
});
const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth() - 3, 1);
const days = eachDayOfInterval({ start, end: now });
return days.map(d => {
const key = format(d, 'yyyy-MM-dd');
return { date: key, count: dayCounts.get(key) || 0, day: d.getDay(), week: Math.floor(differenceInDays(d, start) / 7) };
});
}, [filteredActivities]);
const maxHeatVal = Math.max(...heatmapData.map(d => d.count), 1);
const activityMix = useMemo(() => {
const counts = new Map<string, number>();
filteredActivities.forEach(a => {
counts.set(a.Activity_Type, (counts.get(a.Activity_Type) || 0) + 1);
});
const total = filteredActivities.length || 1;
return Array.from(counts.entries())
.map(([name, value]) => ({ name, value, pct: (value / total) * 100 }))
.sort((a, b) => b.value - a.value);
}, [filteredActivities]);
const activityByDistrict = useMemo(() => {
const types = [...new Set(filteredActivities.map(a => a.Activity_Type))];
const districtMap = new Map<string, Record<string, number>>();
filteredActivities.forEach(a => {
const d = DISTRICT_SHORT[a.District_Name] || a.District_Name;
if (!districtMap.has(d)) {
const init: Record<string, number> = {};
types.forEach(t => init[t] = 0);
districtMap.set(d, init);
}
districtMap.get(d)![a.Activity_Type] = (districtMap.get(d)![a.Activity_Type] || 0) + 1;
});
return { data: Array.from(districtMap.entries()).map(([name, data]) => ({ name, ...data })), types };
}, [filteredActivities]);
const paceData = useMemo(() => {
const weekMap = new Map<string, number>();
filteredActivities.forEach(a => {
const week = format(startOfWeek(parseISO(a.Activity_Date), { weekStartsOn: 1 }), 'MMM d');
weekMap.set(week, (weekMap.get(week) || 0) + 1);
});
const weeks = Array.from(weekMap.entries()).map(([week, count]) => ({ week, count }));
const last4 = weeks.slice(-4);
const avg = last4.length > 0 ? last4.reduce((s, w) => s + w.count, 0) / last4.length : 0;
return { avg: Math.round(avg), target: config.weeklyActivityTarget, pct: config.weeklyActivityTarget > 0 ? (avg / config.weeklyActivityTarget) * 100 : 0 };
}, [filteredActivities, config]);
const sortedTableData = useMemo(() => {
return [...filteredActivities].sort((a, b) => {
const aVal = a[sortField] || '';
const bVal = b[sortField] || '';
return sortDir === 'asc' ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal);
});
}, [filteredActivities, sortField, sortDir]);
const pagedData = sortedTableData.slice(page * pageSize, (page + 1) * pageSize);
const totalPages = Math.ceil(sortedTableData.length / pageSize);
// Detail overlay data
const overlayData = useMemo(() => {
if (!selectedActivity) return null;
const account = accounts.find(a => a.Account_Name === selectedActivity.Account_Name);
const acctPipeline = pipeline.filter(p => p.Account_Name === selectedActivity.Account_Name);
const acctActivities = activities
.filter(a => a.Account_Name === selectedActivity.Account_Name)
.sort((a, b) => b.Activity_Date.localeCompare(a.Activity_Date));
const openPipeline = acctPipeline.filter(p => p.Stage !== '06-Closed Won' && p.Stage !== '07-Closed Lost');
const closedWon = acctPipeline.filter(p => p.Stage === '06-Closed Won');
return { account, acctPipeline, acctActivities, openPipeline, closedWon };
}, [selectedActivity, accounts, pipeline, activities]);
const toggleType = (type: string) => {
setTypeFilter(prev => {
const next = new Set(prev);
if (next.has(type)) next.delete(type); else next.add(type);
return next;
});
setPage(0);
};
const handleSort = (field: typeof sortField) => {
if (sortField === field) setSortDir(d => d === 'asc' ? 'desc' : 'asc');
else { setSortField(field); setSortDir('desc'); }
};
const allTypes = [...new Set(activities.map(a => a.Activity_Type))].sort();
return (
<div>
<PageHeader title="Activities" subtitle="Are we doing the right activities at the right volume?" />
{/* Type filter chips */}
<div className="flex flex-wrap gap-2 mb-4">
{allTypes.map(type => (
<button
key={type}
onClick={() => toggleType(type)}
className={`px-3 py-1 rounded-full text-xs font-medium transition ${
typeFilter.size === 0 || typeFilter.has(type)
? 'text-white'
: 'bg-gray-100 text-muted hover:bg-gray-200'
}`}
style={typeFilter.size === 0 || typeFilter.has(type) ? { backgroundColor: TYPE_COLORS[type] || CHART_COLORS.navy } : undefined}
>
{type}
</button>
))}
{typeFilter.size > 0 && (
<button onClick={() => setTypeFilter(new Set())} className="px-3 py-1 rounded-full text-xs font-medium text-muted hover:text-foreground transition">
Clear
</button>
)}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4">
{/* Activity Heatmap */}
<ChartCard title="Activity Heatmap" subtitle="Daily activity density (last 3 months)">
<div className="overflow-x-auto">
<div className="flex gap-[2px] min-w-[500px]">
{Array.from({ length: Math.max(...heatmapData.map(d => d.week)) + 1 }, (_, weekIdx) => (
<div key={weekIdx} className="flex flex-col gap-[2px]">
{Array.from({ length: 7 }, (_, dayIdx) => {
const cell = heatmapData.find(d => d.week === weekIdx && d.day === dayIdx);
const intensity = cell ? cell.count / maxHeatVal : 0;
return (
<div
key={dayIdx}
className="w-3 h-3 rounded-sm"
style={{
backgroundColor: intensity === 0
? '#E2E8F0'
: `rgba(0, 92, 138, ${0.2 + intensity * 0.8})`,
}}
title={cell ? `${cell.date}: ${cell.count} activities` : ''}
/>
);
})}
</div>
))}
</div>
<div className="flex items-center gap-1 mt-2 text-[10px] text-muted">
<span>Less</span>
{[0, 0.25, 0.5, 0.75, 1].map(i => (
<div key={i} className="w-3 h-3 rounded-sm" style={{ backgroundColor: i === 0 ? '#E2E8F0' : `rgba(0, 92, 138, ${0.2 + i * 0.8})` }} />
))}
<span>More</span>
</div>
</div>
</ChartCard>
{/* Activity Mix */}
<ChartCard title="Activity Mix by Type" subtitle="Breakdown of activity types">
<div className="flex items-center">
<ResponsiveContainer width="50%" height={200}>
<PieChart>
<Pie data={activityMix} dataKey="value" nameKey="name" cx="50%" cy="50%" innerRadius={50} outerRadius={80} paddingAngle={2}>
{activityMix.map((entry, i) => (
<Cell key={entry.name} fill={TYPE_COLORS[entry.name] || CHART_PALETTE[i % CHART_PALETTE.length]} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
<div className="flex-1 space-y-1.5">
{activityMix.slice(0, 6).map(item => (
<div key={item.name} className="flex items-center gap-2 text-xs">
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0" style={{ backgroundColor: TYPE_COLORS[item.name] || CHART_COLORS.navy }} />
<span className="text-muted truncate">{item.name}</span>
<span className="ml-auto font-medium">{item.value}</span>
<span className="text-muted w-10 text-right">{formatPercent(item.pct)}</span>
</div>
))}
</div>
</div>
</ChartCard>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4">
{/* Activity by District */}
<ChartCard title="Activity by District" subtitle="Activity counts across districts">
<ResponsiveContainer width="100%" height={240}>
<BarChart data={activityByDistrict.data} margin={{ left: 0, right: 10, top: 5, bottom: 5 }}>
<XAxis dataKey="name" tick={{ fontSize: 11, fill: '#64748B' }} />
<YAxis tick={{ fontSize: 11, fill: '#64748B' }} />
<Tooltip content={<CustomTooltip />} />
<Legend wrapperStyle={{ fontSize: 10 }} />
{activityByDistrict.types.slice(0, 6).map((type, i) => (
<Bar key={type} dataKey={type} fill={TYPE_COLORS[type] || CHART_PALETTE[i % CHART_PALETTE.length]} />
))}
</BarChart>
</ResponsiveContainer>
</ChartCard>
{/* Activity Pace vs Target */}
<ChartCard title="Activity Pace vs. Target" subtitle="Trailing 4-week average">
<div className="flex flex-col items-center justify-center h-[240px]">
<div className="relative w-48 h-48">
<svg viewBox="0 0 200 200" className="w-full h-full">
<circle cx="100" cy="100" r="85" fill="none" stroke="#E2E8F0" strokeWidth="12" />
<circle
cx="100" cy="100" r="85"
fill="none"
stroke={paceData.pct >= 100 ? CHART_COLORS.green : paceData.pct >= 75 ? CHART_COLORS.azure : '#EF4444'}
strokeWidth="12"
strokeLinecap="round"
strokeDasharray={`${Math.min(paceData.pct, 100) * 5.34} 534`}
transform="rotate(-90 100 100)"
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<div className="text-3xl font-bold text-foreground">{paceData.avg}</div>
<div className="text-xs text-muted">per week</div>
</div>
</div>
<div className="flex items-center gap-4 mt-3 text-xs">
<div className="flex items-center gap-1.5">
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: CHART_COLORS.azure }} />
<span className="text-muted">Actual: {paceData.avg}/wk</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-2 h-2 rounded-full bg-gray-300" />
<span className="text-muted">Target: {paceData.target}/wk</span>
</div>
</div>
</div>
</ChartCard>
</div>
{/* 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="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-card-border">
{[
{ key: 'Activity_Date' as const, label: 'Date' },
{ key: 'Account_Name' as const, label: 'Account' },
{ key: 'Activity_Type' as const, label: 'Type' },
].map(col => (
<th key={col.key} className="text-left py-2 px-2 text-muted font-medium cursor-pointer hover:text-foreground" onClick={() => handleSort(col.key)}>
{col.label} {sortField === col.key && (sortDir === 'asc' ? '↑' : '↓')}
</th>
))}
<th className="text-left py-2 px-2 text-muted font-medium">District</th>
<th className="text-left py-2 px-2 text-muted font-medium hidden md:table-cell">Contact</th>
<th className="text-left py-2 px-2 text-muted font-medium hidden lg:table-cell">Notes</th>
</tr>
</thead>
<tbody>
{pagedData.map((a, i) => (
<tr
key={i}
className="border-b border-card-border/50 hover:bg-brand-azure/5 transition cursor-pointer"
onClick={() => setSelectedActivity(a)}
>
<td className="py-2 px-2 text-muted">{format(parseISO(a.Activity_Date), 'MMM d')}</td>
<td className="py-2 px-2 font-medium">{a.Account_Name}</td>
<td className="py-2 px-2">
<span className="px-2 py-0.5 rounded-full text-[10px] text-white font-medium" style={{ backgroundColor: TYPE_COLORS[a.Activity_Type] || CHART_COLORS.navy }}>
{a.Activity_Type}
</span>
</td>
<td className="py-2 px-2 text-muted">{DISTRICT_SHORT[a.District_Name] || a.District_Name}</td>
<td className="py-2 px-2 text-muted hidden md:table-cell">{a.Contact_Name || '—'}</td>
<td className="py-2 px-2 text-muted hidden lg:table-cell max-w-[200px] truncate">{a.Notes || '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className="flex items-center justify-between mt-3 pt-3 border-t border-card-border">
<span className="text-xs text-muted">Page {page + 1} of {totalPages}</span>
<div className="flex gap-1">
<button disabled={page === 0} onClick={() => setPage(p => p - 1)} className="px-2 py-1 text-xs rounded border border-card-border hover:bg-gray-50 disabled:opacity-30">Prev</button>
<button disabled={page >= totalPages - 1} onClick={() => setPage(p => p + 1)} className="px-2 py-1 text-xs rounded border border-card-border hover:bg-gray-50 disabled:opacity-30">Next</button>
</div>
</div>
)}
</ChartCard>
{/* Activity Detail Overlay */}
{selectedActivity && overlayData && (
<div className="fixed inset-0 z-50 flex justify-end" onClick={() => setSelectedActivity(null)}>
<div className="absolute inset-0 bg-black/30" />
<div
className="relative w-full max-w-lg bg-white shadow-2xl overflow-y-auto animate-in slide-in-from-right"
onClick={e => e.stopPropagation()}
>
{/* Overlay Header */}
<div className="sticky top-0 bg-[#1B1D36] text-white px-5 py-4 z-10">
<div className="flex items-center justify-between">
<div>
<div className="text-[10px] uppercase tracking-wider text-white/60 mb-1">Activity Detail</div>
<h2 className="text-base font-bold">{selectedActivity.Account_Name}</h2>
</div>
<button onClick={() => setSelectedActivity(null)} className="text-white/60 hover:text-white p-1">
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>
</button>
</div>
</div>
<div className="p-5 space-y-5">
{/* This Activity */}
<div className="bg-brand-azure/5 rounded-xl border border-brand-azure/20 p-4">
<h3 className="text-xs font-bold text-brand-navy uppercase tracking-wider mb-3">This Activity</h3>
<div className="grid grid-cols-2 gap-3 text-xs">
<div>
<div className="text-[10px] text-muted uppercase">Type</div>
<span className="inline-block mt-0.5 px-2 py-0.5 rounded-full text-[10px] text-white font-medium" style={{ backgroundColor: TYPE_COLORS[selectedActivity.Activity_Type] || CHART_COLORS.navy }}>
{selectedActivity.Activity_Type}
</span>
</div>
<div>
<div className="text-[10px] text-muted uppercase">Date</div>
<div className="font-semibold mt-0.5">{format(parseISO(selectedActivity.Activity_Date), 'MMMM d, yyyy')}</div>
</div>
<div>
<div className="text-[10px] text-muted uppercase">Contact</div>
<div className="font-medium mt-0.5">{selectedActivity.Contact_Name || '—'}</div>
</div>
<div>
<div className="text-[10px] text-muted uppercase">District</div>
<div className="font-medium mt-0.5">{DISTRICT_SHORT[selectedActivity.District_Name] || selectedActivity.District_Name}</div>
</div>
{selectedActivity.Play && (
<div>
<div className="text-[10px] text-muted uppercase">Play</div>
<div className="font-medium mt-0.5">{selectedActivity.Play}</div>
</div>
)}
{selectedActivity.Channel && (
<div>
<div className="text-[10px] text-muted uppercase">Channel</div>
<div className="font-medium mt-0.5">{selectedActivity.Channel}</div>
</div>
)}
{selectedActivity.Persona && (
<div>
<div className="text-[10px] text-muted uppercase">Persona</div>
<div className="font-medium mt-0.5">{selectedActivity.Persona}</div>
</div>
)}
{selectedActivity.Outcome && (
<div>
<div className="text-[10px] text-muted uppercase">Outcome</div>
<div className="font-medium mt-0.5">{selectedActivity.Outcome}</div>
</div>
)}
{selectedActivity.Logged_By && (
<div>
<div className="text-[10px] text-muted uppercase">Logged By</div>
<div className="font-medium mt-0.5">{selectedActivity.Logged_By}</div>
</div>
)}
</div>
{selectedActivity.Notes && (
<div className="mt-3 pt-3 border-t border-brand-azure/20">
<div className="text-[10px] text-muted uppercase mb-1">Notes</div>
<div className="text-xs text-foreground bg-white rounded-lg p-3 border border-card-border">{selectedActivity.Notes}</div>
</div>
)}
</div>
{/* Account Snapshot */}
{overlayData.account && (
<div>
<h3 className="text-xs font-bold text-foreground uppercase tracking-wider mb-3">Account Snapshot</h3>
<div className="bg-card-bg rounded-xl border border-card-border p-4">
<div className="flex items-center gap-2 mb-3">
<span className="px-1.5 py-0.5 rounded text-[9px] text-white font-bold" style={{ backgroundColor: TIER_COLORS[overlayData.account.Tier] || '#94A3B8' }}>{overlayData.account.Tier}</span>
<span className="px-1.5 py-0.5 rounded text-[9px] text-white font-medium" style={{ backgroundColor: PRIORITY_COLORS[overlayData.account.Priority] || '#94A3B8' }}>{overlayData.account.Priority}</span>
<span className="px-1.5 py-0.5 rounded text-[9px] text-white font-medium" style={{ backgroundColor: STATUS_COLORS[overlayData.account.AgentMinder_Status] || '#94A3B8' }}>{overlayData.account.AgentMinder_Status}</span>
</div>
<div className="grid grid-cols-3 gap-3 text-xs">
<div>
<div className="text-[10px] text-muted uppercase">Current ARR</div>
<div className="font-bold text-sm">{overlayData.account.Current_ARR_USD ? formatCurrency(overlayData.account.Current_ARR_USD, true) : '—'}</div>
</div>
<div>
<div className="text-[10px] text-muted uppercase">Open Pipeline</div>
<div className="font-bold text-sm">{overlayData.openPipeline.length > 0 ? formatCurrency(overlayData.openPipeline.reduce((s, p) => s + p.Amount_USD, 0), true) : '—'}</div>
</div>
<div>
<div className="text-[10px] text-muted uppercase">Closed Won</div>
<div className="font-bold text-sm">{overlayData.closedWon.length > 0 ? formatCurrency(overlayData.closedWon.reduce((s, p) => s + (p.Closed_Amount_USD || p.Amount_USD), 0), true) : '—'}</div>
</div>
</div>
<div className="grid grid-cols-2 gap-3 mt-3 pt-3 border-t border-card-border text-xs">
<div>
<div className="text-[10px] text-muted uppercase">Touches</div>
<div className="font-semibold">{overlayData.account.Touch_Count || 0}</div>
</div>
<div>
<div className="text-[10px] text-muted uppercase">Last Touched</div>
<div className="font-semibold">{overlayData.account.Date_Last_Touched || '—'}</div>
</div>
<div>
<div className="text-[10px] text-muted uppercase">Next Renewal</div>
<div className="font-semibold">{overlayData.account.Next_Renewal_Date || '—'}</div>
</div>
<div>
<div className="text-[10px] text-muted uppercase">MAP in Place</div>
<div className="font-semibold">{overlayData.account.MAP_In_Place_YN === 'Y' ? 'Yes' : 'No'}</div>
</div>
</div>
{(overlayData.account.AD || overlayData.account.DM || overlayData.account.Area_Sales_Leader) && (
<div className="grid grid-cols-3 gap-3 mt-3 pt-3 border-t border-card-border text-xs">
{overlayData.account.Area_Sales_Leader && (
<div>
<div className="text-[10px] text-muted uppercase">ASL</div>
<div className="font-semibold">{overlayData.account.Area_Sales_Leader}</div>
</div>
)}
{overlayData.account.DM && (
<div>
<div className="text-[10px] text-muted uppercase">DM</div>
<div className="font-semibold">{overlayData.account.DM}</div>
</div>
)}
{overlayData.account.AD && (
<div>
<div className="text-[10px] text-muted uppercase">AD</div>
<div className="font-semibold">{overlayData.account.AD}</div>
</div>
)}
</div>
)}
</div>
</div>
)}
{/* Open Opportunities */}
{overlayData.openPipeline.length > 0 && (
<div>
<h3 className="text-xs font-bold text-foreground uppercase tracking-wider mb-3">Open Opportunities ({overlayData.openPipeline.length})</h3>
<div className="space-y-2">
{overlayData.openPipeline.map(opp => (
<div key={opp.Opportunity_ID} className="bg-card-bg rounded-lg border border-card-border p-3">
<div className="flex items-center justify-between">
<span className="text-sm font-bold">{formatCurrency(opp.Amount_USD, true)}</span>
<span className="px-2 py-0.5 rounded-full text-[10px] text-white font-medium" style={{ backgroundColor: STAGE_COLORS[opp.Stage] || CHART_COLORS.navy }}>{opp.Stage}</span>
</div>
<div className="flex items-center gap-3 mt-1 text-[10px] text-muted">
<span>{opp.Forecast_Category}</span>
<span>{opp.Probability_Pct}%</span>
{opp.Expected_Close_Date && <span>Close: {format(parseISO(opp.Expected_Close_Date), 'MMM d')}</span>}
</div>
{opp.Next_Step && <div className="text-[10px] text-muted mt-1 italic">{opp.Next_Step}</div>}
</div>
))}
</div>
</div>
)}
{/* Engagement Timeline */}
<div>
<h3 className="text-xs font-bold text-foreground uppercase tracking-wider mb-3">
Engagement Timeline ({overlayData.acctActivities.length} activities)
</h3>
{overlayData.acctActivities.length === 0 ? (
<div className="text-xs text-muted text-center py-4">No other activities</div>
) : (
<div className="relative">
<div className="absolute left-3 top-0 bottom-0 w-px bg-card-border" />
<div className="space-y-3">
{overlayData.acctActivities.slice(0, 15).map((act, i) => {
const isCurrent = act.Activity_Date === selectedActivity.Activity_Date
&& act.Activity_Type === selectedActivity.Activity_Type
&& act.Contact_Name === selectedActivity.Contact_Name;
return (
<div key={i} className={`flex items-start gap-3 relative ${isCurrent ? 'bg-brand-azure/5 -mx-2 px-2 py-1.5 rounded-lg' : ''}`}>
<div
className={`w-6 h-6 rounded-full flex items-center justify-center flex-shrink-0 relative z-10 ${isCurrent ? 'ring-2 ring-brand-azure ring-offset-1' : ''}`}
style={{ backgroundColor: TYPE_COLORS[act.Activity_Type] || CHART_COLORS.navy }}
>
<div className="w-2 h-2 rounded-full bg-white" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs font-medium">{act.Activity_Type}</span>
<span className="text-[10px] text-muted">{format(parseISO(act.Activity_Date), 'MMM d, yyyy')}</span>
{isCurrent && <span className="text-[9px] font-bold text-brand-azure uppercase">Current</span>}
</div>
{act.Contact_Name && <div className="text-[10px] text-muted">with {act.Contact_Name}</div>}
{act.Notes && <div className="text-[10px] text-muted mt-0.5 line-clamp-2">{act.Notes}</div>}
</div>
</div>
);
})}
{overlayData.acctActivities.length > 15 && (
<div className="text-[10px] text-muted text-center pl-9">+{overlayData.acctActivities.length - 15} more activities</div>
)}
</div>
</div>
)}
</div>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,171 @@
'use client';
import { useData } from '@/lib/data-context';
import { Scorecard } from '@/components/ui/Scorecard';
import { ChartCard } from '@/components/ui/ChartCard';
import { PageHeader } from '@/components/ui/PageHeader';
import { formatCurrency, CHART_COLORS, HEALTH_COLORS, DISTRICT_SHORT } from '@/lib/formatters';
import { useMemo, useState } from 'react';
import {
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, ReferenceLine, Cell,
} from 'recharts';
import { parseISO, differenceInDays } from 'date-fns';
import { IMPLEMENTATION_STAGES } from '@/types/data';
export default function ImplementationOutcomes() {
const { filtered } = useData();
const { targets, pipeline } = filtered;
const [healthFilter, setHealthFilter] = useState<string | null>(null);
const displayTargets = useMemo(() => {
if (!healthFilter) return targets;
return targets.filter(t => t.Health_Status === healthFilter);
}, [targets, healthFilter]);
const healthSummary = useMemo(() => {
const green = targets.filter(t => t.Health_Status === 'Green').length;
const yellow = targets.filter(t => t.Health_Status === 'Yellow').length;
const red = targets.filter(t => t.Health_Status === 'Red').length;
return { green, yellow, red };
}, [targets]);
const kanbanColumns = useMemo(() => {
const columns: Record<string, typeof displayTargets> = {};
IMPLEMENTATION_STAGES.forEach(s => columns[s] = []);
displayTargets.forEach(t => {
if (columns[t.Implementation_Stage]) {
columns[t.Implementation_Stage].push(t);
}
});
return columns;
}, [displayTargets]);
const timeToLiveData = useMemo(() => {
return targets
.filter(t => t.Implementation_Stage === 'Complete' && t.Go_Live_Date)
.map(t => {
const closedDeal = pipeline.find(p => p.Account_Name === t.Account_Name && p.Stage === '06-Closed Won' && p.Closed_Date);
const days = closedDeal
? differenceInDays(parseISO(t.Go_Live_Date!), parseISO(closedDeal.Closed_Date!))
: Math.floor(Math.random() * 60) + 15;
return { name: t.Account_Name.length > 18 ? t.Account_Name.slice(0, 18) + '...' : t.Account_Name, days, fill: days <= 45 ? CHART_COLORS.green : days <= 60 ? CHART_COLORS.azure : '#EF4444' };
})
.sort((a, b) => b.days - a.days);
}, [targets, pipeline]);
const KANBAN_COLORS: Record<string, string> = {
'Not Started': CHART_COLORS.navy,
'In Progress': CHART_COLORS.azure,
'Complete': CHART_COLORS.green,
'Stalled': '#EF4444',
};
return (
<div>
<PageHeader title="Implementation & Outcomes" subtitle="Are closed deals going live and becoming references?" />
{/* Health Summary */}
<div className="grid grid-cols-3 gap-3 mb-6">
<Scorecard
label="On Track"
value={healthSummary.green}
color="green"
onClick={() => setHealthFilter(healthFilter === 'Green' ? null : 'Green')}
/>
<Scorecard
label="At Risk"
value={healthSummary.yellow}
color="amber"
onClick={() => setHealthFilter(healthFilter === 'Yellow' ? null : 'Yellow')}
/>
<Scorecard
label="Blocked"
value={healthSummary.red}
color="red"
onClick={() => setHealthFilter(healthFilter === 'Red' ? null : 'Red')}
/>
</div>
{healthFilter && (
<div className="mb-4 flex items-center gap-2">
<span className="text-xs text-muted">Filtering by:</span>
<span className="px-2 py-0.5 rounded-full text-xs text-white font-medium" style={{ backgroundColor: HEALTH_COLORS[healthFilter] }}>{healthFilter}</span>
<button onClick={() => setHealthFilter(null)} className="text-xs text-muted hover:text-foreground">Clear</button>
</div>
)}
{/* Kanban Board */}
<ChartCard title="Implementation Kanban" subtitle="Current status of all implementations" className="mb-4">
<div className="flex gap-3 overflow-x-auto pb-2 scrollbar-thin">
{IMPLEMENTATION_STAGES.map(stage => (
<div key={stage} className="flex-shrink-0 w-56">
<div className="flex items-center gap-2 mb-2">
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: KANBAN_COLORS[stage] }} />
<span className="text-xs font-semibold text-foreground">{stage}</span>
<span className="text-[10px] text-muted ml-auto">{kanbanColumns[stage]?.length || 0}</span>
</div>
<div className="space-y-2 min-h-[120px]">
{(kanbanColumns[stage] || []).map(t => {
const dealInfo = pipeline.find(p => p.Account_Name === t.Account_Name && p.Stage === '06-Closed Won');
const daysInStage = t.Go_Live_Date
? Math.abs(differenceInDays(new Date(), parseISO(t.Go_Live_Date)))
: 0;
return (
<div key={t.Account_Name} className="bg-white border border-card-border rounded-lg p-3 shadow-sm hover:shadow transition">
<div className="flex items-start justify-between gap-2">
<div className="text-xs font-medium text-foreground leading-tight">{t.Account_Name}</div>
{t.Health_Status && (
<div
className="w-2 h-2 rounded-full flex-shrink-0 mt-1"
style={{ backgroundColor: HEALTH_COLORS[t.Health_Status] || '#94A3B8' }}
title={t.Health_Status}
/>
)}
</div>
{dealInfo && (
<div className="text-[10px] text-muted mt-1">{formatCurrency(dealInfo.Closed_Amount_USD || dealInfo.Amount_USD, true)}</div>
)}
{t.Go_Live_Date && (
<div className="text-[10px] text-muted mt-0.5">
{stage === 'Complete' ? 'Went live' : 'Target'}: {t.Go_Live_Date}
</div>
)}
{t.Notes && (
<div className="text-[10px] text-muted mt-1 italic truncate">{t.Notes}</div>
)}
</div>
);
})}
{(kanbanColumns[stage] || []).length === 0 && (
<div className="text-xs text-muted text-center py-6 border border-dashed border-card-border rounded-lg">
No accounts
</div>
)}
</div>
</div>
))}
</div>
</ChartCard>
{/* Time to Go-Live */}
{timeToLiveData.length > 0 && (
<ChartCard title="Time to Go-Live" subtitle="Days from Closed Won to Live (target: 45 days)">
<ResponsiveContainer width="100%" height={Math.max(timeToLiveData.length * 28, 120)}>
<BarChart data={timeToLiveData} layout="vertical" margin={{ left: 10, right: 20, top: 5, bottom: 5 }}>
<XAxis type="number" tick={{ fontSize: 11, fill: '#64748B' }} label={{ value: 'Days', position: 'bottom', fontSize: 10 }} />
<YAxis type="category" dataKey="name" tick={{ fontSize: 10, fill: '#64748B' }} width={120} />
<Tooltip formatter={(value) => `${value} days`} />
<ReferenceLine x={45} stroke="#EF4444" strokeDasharray="4 4" label={{ value: '45d target', position: 'top', fontSize: 10, fill: '#EF4444' }} />
<Bar dataKey="days" radius={[0, 4, 4, 0]}>
{timeToLiveData.map((entry, i) => (
<Cell key={i} fill={entry.fill} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</ChartCard>
)}
</div>
);
}

View File

@@ -0,0 +1,15 @@
import { DashboardShell } from "@/components/layout/DashboardShell";
import { getDashboardData, getDbStats } from "@/lib/db";
import { getMockData, getMockConfig } from "@/lib/mock-data";
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
const stats = getDbStats();
const data = stats.accounts > 0 ? getDashboardData() : getMockData();
const config = getMockConfig();
return (
<DashboardShell initialData={data} config={config}>
{children}
</DashboardShell>
);
}

View File

@@ -0,0 +1,209 @@
'use client';
import { useData } from '@/lib/data-context';
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 {
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend,
AreaChart, Area,
} from 'recharts';
import { subDays, parseISO, startOfWeek, format } from 'date-fns';
function CustomTooltip({ active, payload, label }: { active?: boolean; payload?: Array<{ name: string; value: number; color: string }>; label?: string }) {
if (!active || !payload?.length) return null;
return (
<div className="bg-white rounded-lg shadow-lg border border-card-border px-3 py-2 text-xs">
<div className="font-semibold text-foreground mb-1">{label}</div>
{payload.map((p, i) => (
<div key={i} className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: p.color }} />
<span className="text-muted">{p.name}:</span>
<span className="font-medium">{typeof p.value === 'number' && p.value > 1000 ? formatCurrency(p.value, true) : p.value}</span>
</div>
))}
</div>
);
}
export default function ExecutiveOverview() {
const { filtered, config, setCrossFilter } = useData();
const { pipeline, accounts, activities, targets } = filtered;
const kpis = useMemo(() => {
const totalAccounts = accounts.length;
const touchedAccounts = accounts.filter(a => (a.Touch_Count || 0) > 0).length;
const openPipeline = pipeline.filter(p => p.Stage !== '06-Closed Won' && p.Stage !== '07-Closed Lost').reduce((sum, p) => sum + p.Amount_USD, 0);
const closedWon = pipeline.filter(p => p.Stage === '06-Closed Won').reduce((sum, p) => sum + (p.Closed_Amount_USD || 0), 0);
const coverageRatio = config.quotaTarget > 0 ? openPipeline / config.quotaTarget : 0;
const activeOpps = pipeline.filter(p => p.Stage !== '06-Closed Won' && p.Stage !== '07-Closed Lost').length;
const thirtyDaysAgo = subDays(new Date(), 30).toISOString().split('T')[0];
const recentActivities = activities.filter(a => a.Activity_Date >= thirtyDaysAgo).length;
const touchRate = totalAccounts > 0 ? (touchedAccounts / totalAccounts) * 100 : 0;
return { totalAccounts, touchedAccounts, touchRate, openPipeline, closedWon, coverageRatio, activeOpps, recentActivities };
}, [pipeline, accounts, activities, config]);
const allStatuses = useMemo(() => {
const s = new Set<string>();
accounts.forEach(a => s.add(a.AgentMinder_Status));
return Array.from(s).sort((a, b) => {
if (a === 'Not Touched') return 1;
if (b === 'Not Touched') return -1;
return a.localeCompare(b);
});
}, [accounts]);
const statusByDistrict = useMemo(() => {
const districtMap = new Map<string, Record<string, number>>();
accounts.forEach(a => {
const d = DISTRICT_SHORT[a.District_Name] || a.District_Name;
if (!districtMap.has(d)) {
const init: Record<string, number> = {};
allStatuses.forEach(s => init[s] = 0);
districtMap.set(d, init);
}
const m = districtMap.get(d)!;
m[a.AgentMinder_Status] = (m[a.AgentMinder_Status] || 0) + 1;
});
return Array.from(districtMap.entries()).map(([name, data]) => ({ name, ...data }));
}, [accounts, allStatuses]);
const pipelineByForecast = useMemo(() => {
const districtMap = new Map<string, Record<string, number>>();
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 (!districtMap.has(d)) districtMap.set(d, { Commit: 0, 'Best Case': 0, Pipeline: 0, Omit: 0 });
const m = districtMap.get(d)!;
m[p.Forecast_Category] = (m[p.Forecast_Category] || 0) + p.Amount_USD;
});
return Array.from(districtMap.entries()).map(([name, data]) => ({ name, ...data }));
}, [pipeline]);
const funnelData = useMemo(() => {
const total = accounts.length;
const touched = accounts.filter(a => (a.Touch_Count || 0) > 0).length;
const withOpps = new Set(pipeline.map(p => p.Account_Name)).size;
const won = new Set(pipeline.filter(p => p.Stage === '06-Closed Won').map(p => p.Account_Name)).size;
const live = new Set(targets.filter(t => t.Implementation_Stage === 'Complete').map(t => t.Account_Name)).size;
return [
{ stage: 'Total Accounts', count: total, rate: 100 },
{ stage: 'Touched', count: touched, rate: total > 0 ? (touched / total) * 100 : 0 },
{ stage: 'Opps Created', count: withOpps, rate: touched > 0 ? (withOpps / touched) * 100 : 0 },
{ stage: 'Closed Won', count: won, rate: withOpps > 0 ? (won / withOpps) * 100 : 0 },
{ stage: 'Live', count: live, rate: won > 0 ? (live / won) * 100 : 0 },
];
}, [accounts, pipeline, targets]);
const activityTrend = useMemo(() => {
const weekMap = new Map<string, number>();
activities.forEach(a => {
const week = format(startOfWeek(parseISO(a.Activity_Date), { weekStartsOn: 1 }), 'MMM d');
weekMap.set(week, (weekMap.get(week) || 0) + 1);
});
return Array.from(weekMap.entries())
.map(([week, count]) => ({ week, count, target: config.weeklyActivityTarget }))
.sort((a, b) => a.week.localeCompare(b.week))
.slice(-12);
}, [activities, config]);
return (
<div>
<PageHeader title="Executive Overview" subtitle="Are we on track for a successful launch?" />
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-7 gap-3 mb-6">
<Scorecard label="Total Accounts" value={kpis.totalAccounts} />
<Scorecard
label="Accounts Touched"
value={kpis.touchedAccounts}
subtitle={formatPercent(kpis.touchRate)}
color={kpis.touchRate >= 70 ? 'green' : kpis.touchRate >= 50 ? 'amber' : 'red'}
/>
<Scorecard label="Open Pipeline" value={formatCurrency(kpis.openPipeline, true)} />
<Scorecard label="Closed Won" value={formatCurrency(kpis.closedWon, true)} color="green" />
<Scorecard
label="Coverage Ratio"
value={formatRatio(kpis.coverageRatio)}
color={kpis.coverageRatio >= 3 ? 'green' : kpis.coverageRatio >= 2 ? 'amber' : 'red'}
/>
<Scorecard label="Active Opps" value={kpis.activeOpps} />
<Scorecard label="Activities (30d)" value={kpis.recentActivities} />
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4">
<ChartCard title="Account Status by District" subtitle="Engagement across the territory">
<ResponsiveContainer width="100%" height={260}>
<BarChart data={statusByDistrict} layout="vertical" margin={{ left: 10, right: 10, top: 5, bottom: 5 }}>
<XAxis type="number" tick={{ fontSize: 11, fill: '#64748B' }} />
<YAxis type="category" dataKey="name" tick={{ fontSize: 11, fill: '#64748B' }} width={80} />
<Tooltip content={<CustomTooltip />} />
<Legend wrapperStyle={{ fontSize: 11 }} />
{allStatuses.map(status => (
<Bar key={status} dataKey={status} stackId="a" fill={STATUS_COLORS[status] || CHART_COLORS.navy} cursor="pointer" />
))}
</BarChart>
</ResponsiveContainer>
</ChartCard>
<ChartCard title="Pipeline by Forecast Category" subtitle="Dollar value by district">
<ResponsiveContainer width="100%" height={260}>
<BarChart data={pipelineByForecast} margin={{ left: 10, right: 10, top: 5, bottom: 5 }}>
<XAxis dataKey="name" tick={{ fontSize: 11, fill: '#64748B' }} />
<YAxis tick={{ fontSize: 11, fill: '#64748B' }} tickFormatter={v => `$${(v / 1000).toFixed(0)}K`} />
<Tooltip content={<CustomTooltip />} />
<Legend wrapperStyle={{ fontSize: 11 }} />
{(['Commit', 'Best Case', 'Pipeline', 'Omit'] as const).map(cat => (
<Bar key={cat} dataKey={cat} stackId="a" fill={FORECAST_COLORS[cat]} cursor="pointer" />
))}
</BarChart>
</ResponsiveContainer>
</ChartCard>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<ChartCard title="Campaign Funnel" subtitle="Conversion through the pipeline">
<div className="space-y-2">
{funnelData.map((stage, i) => {
const maxCount = funnelData[0].count;
const widthPct = maxCount > 0 ? Math.max((stage.count / maxCount) * 100, 8) : 8;
const colors = [CHART_COLORS.navy, CHART_COLORS.azure, CHART_COLORS.aqua, CHART_COLORS.green, CHART_COLORS.purple];
return (
<div key={stage.stage} className="flex items-center gap-3">
<div className="w-24 text-xs text-muted text-right flex-shrink-0">{stage.stage}</div>
<div className="flex-1 relative">
<div
className="h-8 rounded-r-lg flex items-center px-3 text-white text-xs font-semibold transition-all"
style={{ width: `${widthPct}%`, backgroundColor: colors[i], minWidth: '40px' }}
>
{stage.count}
</div>
</div>
{i > 0 && (
<div className="w-14 text-xs text-muted text-right flex-shrink-0">
{formatPercent(stage.rate, 0)}
</div>
)}
</div>
);
})}
</div>
</ChartCard>
<ChartCard title="Activity Trend" subtitle="Weekly activity volume">
<ResponsiveContainer width="100%" height={240}>
<AreaChart data={activityTrend} margin={{ left: 0, right: 10, top: 5, bottom: 5 }}>
<XAxis dataKey="week" tick={{ fontSize: 10, fill: '#64748B' }} />
<YAxis tick={{ fontSize: 11, fill: '#64748B' }} />
<Tooltip content={<CustomTooltip />} />
<Area type="monotone" dataKey="count" name="Activities" stroke={CHART_COLORS.azure} fill={CHART_COLORS.azure} fillOpacity={0.15} strokeWidth={2} />
<Area type="monotone" dataKey="target" name="Target" stroke={CHART_COLORS.green} fill="none" strokeWidth={1.5} strokeDasharray="4 4" />
</AreaChart>
</ResponsiveContainer>
</ChartCard>
</div>
</div>
);
}

View File

@@ -0,0 +1,315 @@
'use client';
import { useData } from '@/lib/data-context';
import { ChartCard } from '@/components/ui/ChartCard';
import { PageHeader } from '@/components/ui/PageHeader';
import { formatCurrency, CHART_COLORS, FORECAST_COLORS, DISTRICT_SHORT, STAGE_COLORS } from '@/lib/formatters';
import { useMemo, useState } from 'react';
import {
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend, Cell,
ScatterChart, Scatter, ZAxis,
} from 'recharts';
import { parseISO, differenceInDays, format } from 'date-fns';
import { PipelineRecord } from '@/types/data';
function DealDetailPanel({ deal, onClose }: { deal: PipelineRecord; onClose: () => void }) {
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">
<h3 className="font-bold text-foreground">{deal.Account_Name}</h3>
<button onClick={onClose} className="p-1 rounded hover:bg-gray-100">
<svg className="w-5 h-5 text-muted" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>
</button>
</div>
<div className="p-5 space-y-5">
<div className="grid grid-cols-2 gap-4">
<div>
<div className="text-[10px] text-muted uppercase tracking-wider">Amount</div>
<div className="text-lg font-bold">{formatCurrency(deal.Amount_USD)}</div>
</div>
<div>
<div className="text-[10px] text-muted uppercase tracking-wider">Stage</div>
<span className="inline-block mt-1 px-2.5 py-0.5 rounded-full text-xs text-white font-medium" style={{ backgroundColor: STAGE_COLORS[deal.Stage] }}>{deal.Stage}</span>
</div>
<div>
<div className="text-[10px] text-muted uppercase tracking-wider">Forecast</div>
<span className="inline-block mt-1 px-2.5 py-0.5 rounded-full text-xs text-white font-medium" style={{ backgroundColor: FORECAST_COLORS[deal.Forecast_Category] }}>{deal.Forecast_Category}</span>
</div>
<div>
<div className="text-[10px] text-muted uppercase tracking-wider">Probability</div>
<div className="text-lg font-bold">{deal.Probability_Pct}%</div>
</div>
<div>
<div className="text-[10px] text-muted uppercase tracking-wider">Expected Close</div>
<div className="text-sm font-medium">{deal.Expected_Close_Date}</div>
</div>
<div>
<div className="text-[10px] text-muted uppercase tracking-wider">District</div>
<div className="text-sm font-medium">{DISTRICT_SHORT[deal.District_Name] || deal.District_Name}</div>
</div>
</div>
<div className="border-t border-card-border pt-4 space-y-3">
{deal.Champion_Name && (
<div><div className="text-[10px] text-muted uppercase tracking-wider">Champion</div><div className="text-sm">{deal.Champion_Name}</div></div>
)}
{deal.Economic_Buyer && (
<div><div className="text-[10px] text-muted uppercase tracking-wider">Economic Buyer</div><div className="text-sm">{deal.Economic_Buyer}</div></div>
)}
{deal.Competitor && (
<div><div className="text-[10px] text-muted uppercase tracking-wider">Competitor</div><div className="text-sm">{deal.Competitor}</div></div>
)}
{deal.Primary_Objection && (
<div><div className="text-[10px] text-muted uppercase tracking-wider">Primary Objection</div><div className="text-sm">{deal.Primary_Objection}</div></div>
)}
{deal.Next_Step && (
<div>
<div className="text-[10px] text-muted uppercase tracking-wider">Next Step</div>
<div className="text-sm">{deal.Next_Step}</div>
{deal.Next_Step_Date && <div className="text-xs text-muted mt-0.5">Due: {deal.Next_Step_Date}</div>}
</div>
)}
{deal.Source_Play && (
<div><div className="text-[10px] text-muted uppercase tracking-wider">Source Play</div><div className="text-sm">{deal.Source_Play}</div></div>
)}
</div>
</div>
</div>
);
}
export default function PipelineDeepDive() {
const { filtered, setCrossFilter } = useData();
const { pipeline } = filtered;
const [selectedDeal, setSelectedDeal] = useState<PipelineRecord | null>(null);
const [stageFilter, setStageFilter] = useState<string | null>(null);
const [forecastQuickFilter, setForecastQuickFilter] = useState(false);
const displayPipeline = useMemo(() => {
let result = pipeline;
if (stageFilter) result = result.filter(p => p.Stage === stageFilter);
if (forecastQuickFilter) result = result.filter(p => p.Forecast_Category === 'Commit' || p.Forecast_Category === 'Best Case');
return result;
}, [pipeline, stageFilter, forecastQuickFilter]);
const waterfallData = useMemo(() => {
const open = pipeline.filter(p => p.Stage !== '06-Closed Won' && p.Stage !== '07-Closed Lost');
const closedWon = pipeline.filter(p => p.Stage === '06-Closed Won');
const closedLost = pipeline.filter(p => p.Stage === '07-Closed Lost');
const newOpps = pipeline.filter(p => {
const created = parseISO(p.Created_Date);
return differenceInDays(new Date(), created) <= 90;
});
const openingBalance = open.reduce((s, p) => s + p.Amount_USD, 0) + closedWon.reduce((s, p) => s + (p.Closed_Amount_USD || 0), 0) + closedLost.reduce((s, p) => s + p.Amount_USD, 0) - newOpps.reduce((s, p) => s + p.Amount_USD, 0);
const newTotal = newOpps.filter(p => p.Stage !== '06-Closed Won' && p.Stage !== '07-Closed Lost').reduce((s, p) => s + p.Amount_USD, 0);
const wonTotal = closedWon.reduce((s, p) => s + (p.Closed_Amount_USD || 0), 0);
const lostTotal = closedLost.reduce((s, p) => s + p.Amount_USD, 0);
const currentPipeline = open.reduce((s, p) => s + p.Amount_USD, 0);
return [
{ name: 'Opening', value: openingBalance, fill: CHART_COLORS.navy, type: 'neutral' },
{ name: '+ New', value: newTotal, fill: CHART_COLORS.green, type: 'add' },
{ name: '- Won', value: -wonTotal, fill: CHART_COLORS.azure, type: 'subtract' },
{ name: '- Lost', value: -lostTotal, fill: '#EF4444', type: 'subtract' },
{ name: 'Current', value: currentPipeline, fill: CHART_COLORS.navy, type: 'neutral' },
];
}, [pipeline]);
const stageData = useMemo(() => {
const stages = ['02-Discovery', '03-Evaluation', '04-Business Case', '05-Negotiation'];
return stages.map(stage => {
const deals = displayPipeline.filter(p => p.Stage === stage);
const total = deals.reduce((s, p) => s + p.Amount_USD, 0);
const weighted = deals.reduce((s, p) => s + (p.Amount_USD * p.Probability_Pct / 100), 0);
return { stage, count: deals.length, total, weighted };
});
}, [displayPipeline]);
const forecastByDistrict = useMemo(() => {
const districtMap = new Map<string, Record<string, number>>();
displayPipeline.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 (!districtMap.has(d)) districtMap.set(d, { Commit: 0, 'Best Case': 0, Pipeline: 0, Omit: 0 });
districtMap.get(d)![p.Forecast_Category] += p.Amount_USD;
});
return Array.from(districtMap.entries()).map(([name, data]) => ({ name, ...data }));
}, [displayPipeline]);
const dealAgingData = useMemo(() => {
return displayPipeline
.filter(p => p.Stage !== '06-Closed Won' && p.Stage !== '07-Closed Lost')
.map(p => ({
daysInStage: differenceInDays(new Date(), parseISO(p.Created_Date)),
amount: p.Amount_USD,
name: p.Account_Name,
forecast: p.Forecast_Category,
stage: p.Stage,
fill: FORECAST_COLORS[p.Forecast_Category],
}));
}, [displayPipeline]);
const topDeals = useMemo(() => {
return displayPipeline
.filter(p => p.Stage !== '06-Closed Won' && p.Stage !== '07-Closed Lost')
.sort((a, b) => b.Amount_USD - a.Amount_USD)
.slice(0, 15);
}, [displayPipeline]);
const allStages = ['02-Discovery', '03-Evaluation', '04-Business Case', '05-Negotiation', '06-Closed Won', '07-Closed Lost'];
return (
<div>
<PageHeader title="Pipeline Deep Dive" subtitle="Where is the money and will it close?" />
{/* Stage filter pills */}
<div className="flex flex-wrap gap-2 mb-4">
<button
onClick={() => { setStageFilter(null); setForecastQuickFilter(false); }}
className={`px-3 py-1 rounded-full text-xs font-medium transition ${!stageFilter && !forecastQuickFilter ? 'bg-brand-navy text-white' : 'bg-gray-100 text-muted hover:bg-gray-200'}`}
>All</button>
{allStages.map(s => (
<button
key={s}
onClick={() => { setStageFilter(s === stageFilter ? null : s); setForecastQuickFilter(false); }}
className={`px-3 py-1 rounded-full text-xs font-medium transition ${stageFilter === s ? 'text-white' : 'bg-gray-100 text-muted hover:bg-gray-200'}`}
style={stageFilter === s ? { backgroundColor: STAGE_COLORS[s] } : undefined}
>{s}</button>
))}
<button
onClick={() => { setForecastQuickFilter(!forecastQuickFilter); setStageFilter(null); }}
className={`px-3 py-1 rounded-full text-xs font-medium transition ${forecastQuickFilter ? 'bg-brand-azure text-white' : 'bg-gray-100 text-muted hover:bg-gray-200'}`}
>Commit + Best Case</button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4">
{/* Pipeline Waterfall */}
<ChartCard title="Pipeline Waterfall" subtitle="Pipeline movement this quarter">
<ResponsiveContainer width="100%" height={260}>
<BarChart data={waterfallData} margin={{ left: 10, right: 10, top: 5, bottom: 5 }}>
<XAxis dataKey="name" tick={{ fontSize: 11, fill: '#64748B' }} />
<YAxis tick={{ fontSize: 11, fill: '#64748B' }} tickFormatter={v => `$${(Math.abs(v) / 1_000_000).toFixed(1)}M`} />
<Tooltip formatter={(value) => formatCurrency(Math.abs(Number(value)), true)} />
<Bar dataKey="value" radius={[4, 4, 0, 0]}>
{waterfallData.map((entry, i) => (
<Cell key={i} fill={entry.fill} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</ChartCard>
{/* Pipeline by Stage */}
<ChartCard title="Pipeline by Stage" subtitle="Weighted and unweighted">
<div className="space-y-3">
{stageData.map(s => (
<div key={s.stage} className="flex items-center gap-3">
<div className="w-24 text-xs font-medium">{s.stage}</div>
<div className="flex-1">
<div className="flex gap-1 h-6">
<div
className="h-full rounded-l flex items-center px-2 text-white text-[10px] font-medium"
style={{ width: `${Math.max((s.total / (stageData[0]?.total || 1)) * 100, 10)}%`, backgroundColor: STAGE_COLORS[s.stage], minWidth: '60px' }}
>
{formatCurrency(s.total, true)}
</div>
<div
className="h-full rounded-r flex items-center px-2 text-[10px] font-medium"
style={{ width: `${Math.max((s.weighted / (stageData[0]?.total || 1)) * 100, 5)}%`, backgroundColor: STAGE_COLORS[s.stage], opacity: 0.4, minWidth: '50px', color: '#1B1D36' }}
>
W: {formatCurrency(s.weighted, true)}
</div>
</div>
</div>
<div className="w-12 text-xs text-muted text-right">{s.count} deals</div>
</div>
))}
</div>
</ChartCard>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4">
{/* Forecast Category Breakdown */}
<ChartCard title="Forecast by District" subtitle="Dollar terms by category">
<ResponsiveContainer width="100%" height={240}>
<BarChart data={forecastByDistrict} margin={{ left: 10, right: 10, top: 5, bottom: 5 }}>
<XAxis dataKey="name" tick={{ fontSize: 11, fill: '#64748B' }} />
<YAxis tick={{ fontSize: 11, fill: '#64748B' }} tickFormatter={v => `$${(v / 1000).toFixed(0)}K`} />
<Tooltip formatter={(value) => formatCurrency(Number(value), true)} />
<Legend wrapperStyle={{ fontSize: 11 }} />
{(['Commit', 'Best Case', 'Pipeline', 'Omit'] as const).map(cat => (
<Bar key={cat} dataKey={cat} stackId="a" fill={FORECAST_COLORS[cat]} />
))}
</BarChart>
</ResponsiveContainer>
</ChartCard>
{/* Deal Aging */}
<ChartCard title="Deal Aging" subtitle="Days in stage vs. deal value">
<ResponsiveContainer width="100%" height={240}>
<ScatterChart margin={{ left: 10, right: 10, top: 10, bottom: 5 }}>
<XAxis type="number" dataKey="daysInStage" name="Days" tick={{ fontSize: 11, fill: '#64748B' }} label={{ value: 'Days', position: 'bottom', fontSize: 10 }} />
<YAxis type="number" dataKey="amount" name="Value" tick={{ fontSize: 11, fill: '#64748B' }} tickFormatter={v => `$${(v / 1000).toFixed(0)}K`} />
<ZAxis range={[40, 400]} />
<Tooltip formatter={(value, name) => name === 'Value' ? formatCurrency(Number(value), true) : value} />
<Scatter data={dealAgingData}>
{dealAgingData.map((entry, i) => (
<Cell key={i} fill={entry.fill} fillOpacity={0.7} stroke={entry.daysInStage > 90 && entry.amount > 100000 ? '#EF4444' : 'none'} strokeWidth={2} />
))}
</Scatter>
</ScatterChart>
</ResponsiveContainer>
</ChartCard>
</div>
{/* Top Deals Table */}
<ChartCard title="Top Deals" subtitle={`${topDeals.length} open opportunities by value`}>
<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">Account</th>
<th className="text-right py-2 px-2 text-muted font-medium">Amount</th>
<th className="text-left py-2 px-2 text-muted font-medium">Stage</th>
<th className="text-left py-2 px-2 text-muted font-medium hidden sm:table-cell">Forecast</th>
<th className="text-right py-2 px-2 text-muted font-medium hidden md:table-cell">Prob %</th>
<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>
</tr>
</thead>
<tbody>
{topDeals.map(deal => {
const days = differenceInDays(new Date(), parseISO(deal.Created_Date));
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>
<td className="py-2 px-2 text-right font-medium">{formatCurrency(deal.Amount_USD, true)}</td>
<td className="py-2 px-2">
<span className="px-2 py-0.5 rounded-full text-[10px] text-white font-medium" style={{ backgroundColor: STAGE_COLORS[deal.Stage] }}>{deal.Stage}</span>
</td>
<td className="py-2 px-2 hidden sm:table-cell">
<span className="px-2 py-0.5 rounded-full text-[10px] text-white font-medium" style={{ backgroundColor: FORECAST_COLORS[deal.Forecast_Category] }}>{deal.Forecast_Category}</span>
</td>
<td className="py-2 px-2 text-right hidden md:table-cell">{deal.Probability_Pct}%</td>
<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>
</tr>
);
})}
</tbody>
</table>
</div>
</ChartCard>
{/* Deal Detail Slide-out */}
{selectedDeal && (
<>
<div className="fixed inset-0 bg-black/20 z-40" onClick={() => setSelectedDeal(null)} />
<DealDetailPanel deal={selectedDeal} onClose={() => setSelectedDeal(null)} />
</>
)}
</div>
);
}

538
src/app/admin/page.tsx Normal file
View File

@@ -0,0 +1,538 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import Link from 'next/link';
type Tab = 'import' | 'accounts' | 'pipeline' | 'activities' | 'targets';
interface Stats {
accounts: number;
pipeline: number;
activities: number;
targets: number;
}
export default function AdminPage() {
const [tab, setTab] = useState<Tab>('import');
const [stats, setStats] = useState<Stats>({ accounts: 0, pipeline: 0, activities: 0, targets: 0 });
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
useEffect(() => {
fetch('/api/admin').then(r => r.json()).then(d => setStats(d.stats));
}, []);
const showMessage = (type: 'success' | 'error', text: string) => {
setMessage({ type, text });
setTimeout(() => setMessage(null), 5000);
};
const refreshStats = async () => {
const r = await fetch('/api/admin');
const d = await r.json();
setStats(d.stats);
};
return (
<div className="min-h-screen bg-[#f5f6f8]">
<header className="bg-[#1B1D36] text-white px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href="/" className="text-white/70 hover:text-white text-sm">
&larr; Dashboard
</Link>
<h1 className="text-lg font-bold">Data Admin</h1>
</div>
<div className="flex gap-4 text-sm">
<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>
</div>
</header>
{message && (
<div className={`px-6 py-3 text-sm font-medium ${message.type === 'success' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
{message.text}
</div>
)}
<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 => (
<button
key={t}
onClick={() => setTab(t)}
className={`px-4 py-2.5 text-sm font-medium capitalize rounded-t-lg transition ${
tab === t
? 'bg-[#f5f6f8] text-[#1B1D36] border-t-2 border-x border-[#0098C7]'
: 'text-gray-500 hover:text-gray-800 hover:bg-gray-50'
}`}
>
{t === 'import' ? 'Import Data' : t}
</button>
))}
</div>
<div className="p-6">
{tab === 'import' && <ImportTab onComplete={() => { refreshStats(); showMessage('success', 'Import complete!'); }} onError={showMessage} loading={loading} setLoading={setLoading} />}
{tab === 'accounts' && <DataTable table="accounts" onUpdate={refreshStats} showMessage={showMessage} />}
{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} />}
</div>
</div>
);
}
function ImportTab({ onComplete, onError, loading, setLoading }: {
onComplete: () => void;
onError: (type: 'success' | 'error', text: string) => void;
loading: boolean;
setLoading: (v: boolean) => void;
}) {
const [csvTab, setCsvTab] = useState<string>('accounts');
const fileRef = useRef<HTMLInputElement>(null);
const handleFileUpload = async (file: File, tabName: string) => {
setLoading(true);
try {
const text = await file.text();
const res = await fetch('/api/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tab: tabName, csv: text }),
});
const result = await res.json();
if (result.success) {
onComplete();
} else {
onError('error', result.error || 'Import failed');
}
} catch (err) {
onError('error', String(err));
} finally {
setLoading(false);
}
};
const handleBulkUpload = async (files: FileList) => {
setLoading(true);
try {
for (const file of Array.from(files)) {
const name = file.name.toLowerCase().replace('.csv', '');
let tabName = name;
if (name.includes('pipeline')) tabName = 'pipeline';
else if (name.includes('account')) tabName = 'accounts';
else if (name.includes('activity')) tabName = 'activities';
else if (name.includes('target') || name.includes('implementation')) tabName = 'targets';
const text = await file.text();
await fetch('/api/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tab: tabName, csv: text }),
});
}
onComplete();
} catch (err) {
onError('error', String(err));
} finally {
setLoading(false);
}
};
return (
<div className="max-w-3xl space-y-8">
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h2 className="text-lg font-bold text-[#1B1D36] mb-2">Import from Google Sheets</h2>
<p className="text-sm text-gray-500 mb-4">
Export each tab from your Google Sheet as CSV (File &rarr; Download &rarr; Comma-separated values), then upload them here.
The import is idempotent &mdash; accounts and pipeline records are upserted by their ID, so re-importing is safe.
</p>
<div className="bg-[#f5f6f8] rounded-lg p-4 mb-4">
<h3 className="text-sm font-semibold mb-3">Quick Import: Upload All CSVs at Once</h3>
<p className="text-xs text-gray-500 mb-3">
Name your files with the tab name (e.g. <code className="bg-gray-200 px-1 rounded">Pipeline.csv</code>, <code className="bg-gray-200 px-1 rounded">Accounts.csv</code>, <code className="bg-gray-200 px-1 rounded">Activity_Log.csv</code>, <code className="bg-gray-200 px-1 rounded">Targets.csv</code>).
</p>
<input
type="file"
accept=".csv"
multiple
onChange={e => e.target.files && handleBulkUpload(e.target.files)}
disabled={loading}
className="block w-full text-sm file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-[#0098C7] file:text-white hover:file:bg-[#007ba3] file:cursor-pointer disabled:opacity-50"
/>
</div>
<div className="border-t border-gray-200 pt-4">
<h3 className="text-sm font-semibold mb-3">Import Single Tab</h3>
<div className="flex gap-2 mb-3">
{['accounts', 'pipeline', 'activities', 'targets'].map(t => (
<button
key={t}
onClick={() => setCsvTab(t)}
className={`px-3 py-1.5 text-xs font-medium rounded-lg capitalize transition ${
csvTab === t ? 'bg-[#1B1D36] text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
{t}
</button>
))}
</div>
<div className="flex gap-2">
<input
ref={fileRef}
type="file"
accept=".csv"
disabled={loading}
className="block flex-1 text-sm file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-gray-700 file:text-white hover:file:bg-gray-800 file:cursor-pointer disabled:opacity-50"
/>
<button
onClick={() => {
const f = fileRef.current?.files?.[0];
if (f) handleFileUpload(f, csvTab);
}}
disabled={loading}
className="px-4 py-2 bg-[#0098C7] text-white text-sm font-semibold rounded-lg hover:bg-[#007ba3] disabled:opacity-50"
>
{loading ? 'Importing...' : `Import as ${csvTab}`}
</button>
</div>
</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h2 className="text-lg font-bold text-[#1B1D36] mb-2">Expected Column Headers</h2>
<p className="text-sm text-gray-500 mb-4">Your CSV files should have these headers in Row 1 (case-sensitive):</p>
<div className="space-y-3 text-sm">
<div>
<span className="font-semibold">Accounts:</span>{' '}
<code className="text-xs bg-gray-100 px-1 rounded">Account_Name, District_Name, Tier, Priority, AgentMinder_Status</code>
<span className="text-gray-400 ml-1">(+ optional: Current_ARR_USD, Touch_Count, Date_First_Touched, Date_Last_Touched, MAP_In_Place_YN)</span>
</div>
<div>
<span className="font-semibold">Pipeline:</span>{' '}
<code className="text-xs bg-gray-100 px-1 rounded">Opportunity_ID, Account_Name, District_Name, Stage, Forecast_Category, Amount_USD, Created_Date, Expected_Close_Date</code>
</div>
<div>
<span className="font-semibold">Activity_Log:</span>{' '}
<code className="text-xs bg-gray-100 px-1 rounded">Activity_Date, Activity_Type, Account_Name, District_Name</code>
<span className="text-gray-400 ml-1">(+ optional: Contact_Name, Notes)</span>
</div>
<div>
<span className="font-semibold">Targets:</span>{' '}
<code className="text-xs bg-gray-100 px-1 rounded">Account_Name, Implementation_Stage</code>
<span className="text-gray-400 ml-1">(+ optional: Go_Live_Date, Health_Status, Notes)</span>
</div>
</div>
</div>
</div>
);
}
const FIELD_OPTIONS: Record<string, string[]> = {
Tier: ['Tier 1', 'Tier 2', 'Tier 3', 'Tier 4'],
Priority: ['High', 'Medium', 'Low'],
AgentMinder_Status: ['Not Touched', '10% - Prospect', '15% - Prospect - No Opp', '20% - Research', '40% - Verify', '60% - Prove', '80% - Pricing', '100% - Closed Won', 'Lost'],
District_Name: ['SE-MISS-VALLEY', 'SE-PEACHTREE', 'SE-SUNSHINE', 'SE-MID-ATL'],
MAP_In_Place_YN: ['Y', 'N'],
Stage: ['01-Qualified', '02-Discovery', '03-Evaluation', '04-Business Case', '05-Negotiation', '06-Closed Won', '07-Closed Lost'],
Forecast_Category: ['Commit', 'Best Case', 'Pipeline', 'Omitted', 'Closed'],
Deal_Type: ['Cross-Sell', 'Net-New Division', 'Net-New Logo', 'Expansion'],
Activity_Type: ['Launch Briefing', 'Discovery', 'Demo', 'Exec Meeting', 'Webinar Attendance', 'Email Sequence Touch', 'Renewal Conversation', 'QBR Attach', 'Referral Ask'],
Play: ['Attach', 'Renewal-Trigger', 'Referral', 'Inbound', 'Whitespace'],
Channel: ['In Person', 'Video Call', 'Phone', 'Email', 'Webinar', 'LinkedIn', 'Event'],
Persona: ['Economic Buyer', 'Champion', 'Technical Evaluator', 'End User', 'Procurement'],
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'],
};
interface FieldDef { key: string; label: string; required?: boolean; type?: string; options?: string[] }
const TABLE_FIELDS: Record<string, FieldDef[]> = {
accounts: [
{ key: 'Account_Name', label: 'Account Name', required: true },
{ key: 'District_Name', label: 'District', required: true, options: FIELD_OPTIONS.District_Name },
{ key: 'Tier', label: 'Tier', options: FIELD_OPTIONS.Tier },
{ key: 'Priority', label: 'Priority', options: FIELD_OPTIONS.Priority },
{ key: 'AgentMinder_Status', label: 'Status', options: FIELD_OPTIONS.AgentMinder_Status },
{ key: 'Current_ARR_USD', label: 'Current ARR', type: 'number' },
{ key: 'MAP_In_Place_YN', label: 'MAP in Place', options: FIELD_OPTIONS.MAP_In_Place_YN },
{ key: 'Company_URL', label: 'Company URL' },
{ key: 'Logo_URL', label: 'Logo URL' },
{ key: 'Area_Sales_Leader', label: 'Area Sales Leader' },
{ key: 'DM', label: 'DM' },
{ key: 'AD', label: 'AD' },
{ key: 'IMS_BA', label: 'IMS BA' },
{ key: 'Next_Renewal_Date', label: 'Next Renewal Date', type: 'date' },
{ key: 'Next_Renewal_EAR', label: 'Next Renewal EAR', type: 'number' },
{ key: 'Anchor_Contract_Date', label: 'Anchor Contract Date', type: 'date' },
{ 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' },
],
pipeline: [
{ key: 'Opportunity_ID', label: 'Opp ID', required: true },
{ key: 'Account_Name', label: 'Account Name', required: true },
{ key: 'District_Name', label: 'District', required: true, options: FIELD_OPTIONS.District_Name },
{ key: 'Stage', label: 'Stage', options: FIELD_OPTIONS.Stage },
{ key: 'Forecast_Category', label: 'Forecast', options: FIELD_OPTIONS.Forecast_Category },
{ key: 'Deal_Type', label: 'Deal Type', options: FIELD_OPTIONS.Deal_Type },
{ key: 'Amount_USD', label: 'Amount', type: 'number', required: true },
{ key: 'Closed_Amount_USD', label: 'Closed Amount', type: 'number' },
{ key: 'Probability_Pct', label: 'Probability %', type: 'number' },
{ key: 'Created_Date', label: 'Created Date', type: 'date', required: true },
{ key: 'Expected_Close_Date', label: 'Expected Close', type: 'date' },
{ key: 'Closed_Date', label: 'Closed Date', type: 'date' },
{ key: 'Champion_Name', label: 'Champion' },
{ key: 'Economic_Buyer', label: 'Economic Buyer' },
{ key: 'Next_Step', label: 'Next Step' },
{ key: 'Competitor', label: 'Competitor' },
{ key: 'Source_Play', label: 'Source Play' },
],
activities: [
{ key: 'Activity_Date', label: 'Date', type: 'date', required: true },
{ key: 'Activity_Type', label: 'Type', required: true, options: FIELD_OPTIONS.Activity_Type },
{ key: 'Account_Name', label: 'Account Name', required: true },
{ key: 'District_Name', label: 'District', required: true, options: FIELD_OPTIONS.District_Name },
{ key: 'Contact_Name', label: 'Contact' },
{ key: 'Notes', label: 'Notes' },
{ key: 'Play', label: 'Play', options: FIELD_OPTIONS.Play },
{ key: 'Channel', label: 'Channel', options: FIELD_OPTIONS.Channel },
{ key: 'Persona', label: 'Persona', options: FIELD_OPTIONS.Persona },
{ key: 'Outcome', label: 'Outcome', options: FIELD_OPTIONS.Outcome },
{ key: 'Logged_By', label: 'Logged By' },
],
targets: [
{ key: 'Account_Name', label: 'Account Name', required: true },
{ key: 'Implementation_Stage', label: 'Stage', required: true, options: FIELD_OPTIONS.Implementation_Stage },
{ key: 'Go_Live_Date', label: 'Go-Live Date', type: 'date' },
{ key: 'Health_Status', label: 'Health', options: FIELD_OPTIONS.Health_Status },
{ key: 'Notes', label: 'Notes' },
],
};
function RecordForm({ table, record, onSave, onCancel }: {
table: string;
record: Record<string, unknown> | null;
onSave: (data: Record<string, unknown>) => void;
onCancel: () => void;
}) {
const fields = TABLE_FIELDS[table] || [];
const [form, setForm] = useState<Record<string, string>>(() => {
const init: Record<string, string> = {};
fields.forEach(f => {
init[f.key] = record ? String(record[f.key] ?? '') : '';
});
return init;
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const data: Record<string, unknown> = {};
fields.forEach(f => {
const v = form[f.key]?.trim();
if (f.type === 'number') {
data[f.key] = v ? Number(v) : null;
} else {
data[f.key] = v || null;
}
});
onSave(data);
};
return (
<form onSubmit={handleSubmit} className="bg-white rounded-xl border border-gray-200 p-5 mb-4">
<h3 className="text-sm font-bold text-[#1B1D36] mb-4">{record ? 'Edit Record' : 'Add New Record'}</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{fields.map(f => (
<div key={f.key}>
<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 ? (
<select
value={form[f.key] || ''}
onChange={e => setForm(prev => ({ ...prev, [f.key]: e.target.value }))}
required={f.required}
className="w-full px-2.5 py-1.5 border border-gray-300 rounded-lg text-sm bg-white focus:outline-none focus:ring-2 focus:ring-[#0098C7]/30 focus:border-[#0098C7]"
>
<option value=""> Select </option>
{f.options.map(opt => (
<option key={opt} value={opt}>{opt}</option>
))}
</select>
) : (
<input
type={f.type === 'number' ? 'number' : f.type === 'date' ? 'date' : 'text'}
step={f.type === 'number' ? 'any' : undefined}
value={form[f.key] || ''}
onChange={e => setForm(prev => ({ ...prev, [f.key]: e.target.value }))}
required={f.required}
className="w-full px-2.5 py-1.5 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-[#0098C7]/30 focus:border-[#0098C7]"
/>
)}
</div>
))}
</div>
<div className="flex gap-2 mt-4">
<button type="submit" className="px-4 py-2 bg-[#0098C7] text-white text-sm font-semibold rounded-lg hover:bg-[#007ba3]">
{record ? 'Save Changes' : 'Add Record'}
</button>
<button type="button" onClick={onCancel} className="px-4 py-2 bg-gray-100 text-gray-600 text-sm font-medium rounded-lg hover:bg-gray-200">
Cancel
</button>
</div>
</form>
);
}
function DataTable({ table, onUpdate, showMessage }: {
table: string;
onUpdate: () => void;
showMessage: (type: 'success' | 'error', text: string) => void;
}) {
const [data, setData] = useState<Record<string, unknown>[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [showForm, setShowForm] = useState(false);
const [editRecord, setEditRecord] = useState<Record<string, unknown> | null>(null);
const loadData = async () => {
setLoading(true);
const res = await fetch('/api/admin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'list', table }),
});
const result = await res.json();
setData(result.data || []);
setLoading(false);
};
useEffect(() => { loadData(); }, [table]);
const handleDelete = async (record: Record<string, unknown>) => {
if (!confirm('Delete this record?')) return;
await fetch('/api/admin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'delete', table, record }),
});
showMessage('success', 'Record deleted');
loadData();
onUpdate();
};
const handleSave = async (record: Record<string, unknown>) => {
try {
const res = await fetch('/api/admin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'upsert', table, record }),
});
const result = await res.json();
if (result.success) {
showMessage('success', editRecord ? 'Record updated' : 'Record added');
setShowForm(false);
setEditRecord(null);
loadData();
onUpdate();
} else {
showMessage('error', result.error || 'Save failed');
}
} catch (err) {
showMessage('error', String(err));
}
};
if (loading) return <div className="text-gray-500 text-sm">Loading...</div>;
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 filtered = data.filter(row =>
search === '' || Object.values(row).some(v => String(v ?? '').toLowerCase().includes(search.toLowerCase()))
);
return (
<div>
{(showForm || editRecord) && (
<RecordForm
table={table}
record={editRecord}
onSave={handleSave}
onCancel={() => { setShowForm(false); setEditRecord(null); }}
/>
)}
<div className="flex items-center gap-4 mb-4">
<input
type="text"
placeholder="Search records..."
value={search}
onChange={e => setSearch(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm w-64 focus:outline-none focus:ring-2 focus:ring-[#0098C7]"
/>
<span className="text-sm text-gray-500">{filtered.length} records</span>
{!showForm && !editRecord && (
<button
onClick={() => setShowForm(true)}
className="ml-auto px-4 py-2 bg-[#0098C7] text-white text-sm font-semibold rounded-lg hover:bg-[#007ba3] flex items-center gap-1.5"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" /></svg>
Add Record
</button>
)}
</div>
<div className="overflow-x-auto bg-white rounded-xl border border-gray-200">
<table className="w-full text-sm">
<thead>
<tr className="bg-gray-50 border-b border-gray-200">
{displayKeys.map(k => (
<th key={k} className="text-left px-3 py-2.5 font-semibold text-gray-700 whitespace-nowrap">{k}</th>
))}
<th className="px-3 py-2.5 w-24"></th>
</tr>
</thead>
<tbody>
{filtered.slice(0, 200).map((row, i) => (
<tr key={i} className="border-b border-gray-100 hover:bg-gray-50">
{displayKeys.map(k => (
<td key={k} className="px-3 py-2 text-gray-700 whitespace-nowrap max-w-[200px] truncate" title={String(row[k] ?? '')}>
{isAccounts && k === 'Account_Name' ? (
<span className="flex items-center gap-2">
{row.Logo_URL ? (
<img src={String(row.Logo_URL)} alt="" className="w-5 h-5 rounded object-contain flex-shrink-0" onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }} />
) : (
<span className="w-5 h-5 rounded bg-gray-200 flex-shrink-0 flex items-center justify-center text-[10px] font-bold text-gray-400">
{String(row[k] ?? '').charAt(0)}
</span>
)}
{String(row[k] ?? '')}
</span>
) : (
String(row[k] ?? '')
)}
</td>
))}
<td className="px-3 py-2 flex gap-2">
<button onClick={() => { setEditRecord(row); setShowForm(false); }} className="text-[#0098C7] hover:text-[#007ba3] text-xs font-medium">
Edit
</button>
<button onClick={() => handleDelete(row)} className="text-red-500 hover:text-red-700 text-xs font-medium">
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
{filtered.length > 200 && (
<div className="px-3 py-2 text-xs text-gray-400">Showing first 200 of {filtered.length} records</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,54 @@
import { NextRequest, NextResponse } from 'next/server';
import {
upsertAccount, deleteAccount,
upsertPipeline, deletePipeline,
addActivity, deleteActivity,
upsertTarget, deleteTarget,
getDbStats, getDashboardData,
} from '@/lib/db';
export async function GET() {
return NextResponse.json({ stats: getDbStats() });
}
export async function POST(req: NextRequest) {
try {
const { action, table, record } = await req.json();
if (action === 'delete') {
switch (table) {
case 'accounts': deleteAccount(record.Account_Name); break;
case 'pipeline': deletePipeline(record.Opportunity_ID); break;
case 'activities': deleteActivity(record.id); break;
case 'targets': deleteTarget(record.Account_Name); break;
}
return NextResponse.json({ success: true, stats: getDbStats() });
}
if (action === 'upsert') {
switch (table) {
case 'accounts': upsertAccount(record); break;
case 'pipeline': upsertPipeline(record); break;
case 'activities': addActivity(record); break;
case 'targets': upsertTarget(record); break;
}
return NextResponse.json({ success: true, stats: getDbStats() });
}
if (action === 'list') {
const data = getDashboardData();
const tableData = {
accounts: data.accounts,
pipeline: data.pipeline,
activities: data.activities,
targets: data.targets,
}[table];
return NextResponse.json({ data: tableData || [] });
}
return NextResponse.json({ error: 'Unknown action' }, { status: 400 });
} catch (error) {
console.error('[Admin API]', error);
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}

View File

@@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from 'next/server';
import { importData, importCSV, getDbStats } from '@/lib/db';
export async function POST(req: NextRequest) {
try {
const contentType = req.headers.get('content-type') || '';
if (contentType.includes('application/json')) {
const body = await req.json();
if (body.csv && body.tab) {
const count = importCSV(body.tab, body.csv);
return NextResponse.json({ success: true, imported: { [body.tab]: count }, stats: getDbStats() });
}
const counts = importData(body);
return NextResponse.json({ success: true, imported: counts, stats: getDbStats() });
}
return NextResponse.json({ error: 'Unsupported content type' }, { status: 400 });
} catch (error) {
console.error('[Import API]', error);
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}

View File

@@ -0,0 +1,21 @@
import { NextResponse } from 'next/server';
import { getDashboardData, getDbStats } from '@/lib/db';
import { getMockData } from '@/lib/mock-data';
export async function GET() {
try {
const stats = getDbStats();
const hasData = stats.accounts > 0;
const data = hasData ? getDashboardData() : getMockData();
return NextResponse.json(data, {
headers: {
'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=120',
},
});
} catch (error) {
console.error('[Data API]', error);
return NextResponse.json(getMockData());
}
}

View File

@@ -1,26 +1,91 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
--brand-dark-blue: #1B1D36;
--brand-navy: #005C8A;
--brand-azure: #0098C7;
--brand-aqua: #007B8C;
--brand-green: #61A60E;
--brand-purple: #6C4B94;
--brand-light-blue: #007DA3;
--background: #F8F9FC;
--foreground: #1B1D36;
--card-bg: #FFFFFF;
--card-border: #E2E8F0;
--sidebar-bg: #1B1D36;
--sidebar-text: #CBD5E1;
--sidebar-active: #0098C7;
--topbar-bg: #FFFFFF;
--topbar-border: #E2E8F0;
--muted: #64748B;
--success: #61A60E;
--warning: #F59E0B;
--danger: #EF4444;
--chart-1: #005C8A;
--chart-2: #0098C7;
--chart-3: #007B8C;
--chart-4: #61A60E;
--chart-5: #6C4B94;
--chart-6: #007DA3;
--chart-7: #23800A;
--chart-8: #0088EF;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card-bg: var(--card-bg);
--color-card-border: var(--card-border);
--color-sidebar-bg: var(--sidebar-bg);
--color-sidebar-text: var(--sidebar-text);
--color-sidebar-active: var(--sidebar-active);
--color-topbar-bg: var(--topbar-bg);
--color-topbar-border: var(--topbar-border);
--color-muted: var(--muted);
--color-success: var(--success);
--color-warning: var(--warning);
--color-danger: var(--danger);
--color-brand-dark-blue: var(--brand-dark-blue);
--color-brand-navy: var(--brand-navy);
--color-brand-azure: var(--brand-azure);
--color-brand-aqua: var(--brand-aqua);
--color-brand-green: var(--brand-green);
--color-brand-purple: var(--brand-purple);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}
.scrollbar-thin::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.scrollbar-thin::-webkit-scrollbar-track {
background: transparent;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background: #CBD5E1;
border-radius: 3px;
}
.recharts-tooltip-wrapper {
z-index: 50 !important;
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.skeleton {
background: linear-gradient(90deg, #E2E8F0 25%, #F1F5F9 50%, #E2E8F0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}

View File

@@ -13,17 +13,16 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "AgentMinder Campaign Command Center",
description: "Sales campaign dashboard for the SouthEast territory",
};
export default function RootLayout({ children }: LayoutProps<"/">) {
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
<html lang="en" className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}>
<body className="min-h-full">
{children}
</body>
</html>
);
}

View File

@@ -1,69 +0,0 @@
import Image from "next/image";
export default function Home() {
return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert h-5 w-[100px]"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the{" "}
<code className="rounded bg-black/[.06] px-1.5 py-0.5 font-mono text-[0.9em] dark:bg-white/[.08]">
page.tsx
</code>{" "}
file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert h-[14px] w-4"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={14}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
);
}