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:
665
src/app/(dashboard)/accounts/page.tsx
Normal file
665
src/app/(dashboard)/accounts/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
598
src/app/(dashboard)/activity/page.tsx
Normal file
598
src/app/(dashboard)/activity/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
171
src/app/(dashboard)/implementation/page.tsx
Normal file
171
src/app/(dashboard)/implementation/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
src/app/(dashboard)/layout.tsx
Normal file
15
src/app/(dashboard)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
209
src/app/(dashboard)/page.tsx
Normal file
209
src/app/(dashboard)/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
315
src/app/(dashboard)/pipeline/page.tsx
Normal file
315
src/app/(dashboard)/pipeline/page.tsx
Normal 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
538
src/app/admin/page.tsx
Normal 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">
|
||||
← 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 → Download → Comma-separated values), then upload them here.
|
||||
The import is idempotent — 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>
|
||||
);
|
||||
}
|
||||
54
src/app/api/admin/route.ts
Normal file
54
src/app/api/admin/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
25
src/app/api/import/route.ts
Normal file
25
src/app/api/import/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
21
src/app/api/sheets/route.ts
Normal file
21
src/app/api/sheets/route.ts
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user