Implement Phase 2 features for Campaign Command Center
Adds 14 new features across the dashboard: - Deal Risk Scoring (0-100) with risk factors on pipeline deals - Account Health Score with multi-factor analysis on account cards - Next Best Action Engine on Executive Overview - Account Notes with pin/edit/delete in activity overlay and account detail - Stakeholder Contact Map with role, sentiment, email, LinkedIn - Competitive Intelligence Tracker with multi-select competitors and filtering - CSV/Excel Export on accounts, activities, and pipeline pages - Data Quality Dashboard in admin with completeness scoring - Campaign Playbook Templates with 5 default plays and account progress tracking - Goals & District Targets with quarterly tracking and progress bars - Weekly Status Report (printable) and Executive Summary (printable) - Activity Effectiveness Scoring showing pipeline conversion by activity type - Mobile Activity FAB for quick activity logging on mobile - Multi-User Auth (NextAuth + Google OAuth, @broadcom.com domain, role-based) Also adds Phase 2 API route, scoring utilities, sidebar navigation updates, and database schema extensions for notes, contacts, snapshots, playbooks, and district targets. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
11
.env.example
Normal file
11
.env.example
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Google Apps Script URL (provides live sheet data — no Cloud project needed)
|
||||||
|
# See GOOGLE_SHEETS_SETUP.md for how to deploy the Apps Script
|
||||||
|
APPS_SCRIPT_URL=https://script.google.com/macros/s/YOUR_DEPLOYMENT_ID/exec
|
||||||
|
|
||||||
|
# ISR revalidation interval in seconds (default: 300 = 5 minutes)
|
||||||
|
REVALIDATE_INTERVAL=300
|
||||||
|
|
||||||
|
# Google OAuth (optional — leave unset to disable auth in dev)
|
||||||
|
GOOGLE_CLIENT_ID=
|
||||||
|
GOOGLE_CLIENT_SECRET=
|
||||||
|
AUTH_SECRET= # generate with: openssl rand -base64 32
|
||||||
@@ -8,6 +8,10 @@ import { formatCurrency, CHART_COLORS, STATUS_COLORS, DISTRICT_SHORT, STAGE_COLO
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { parseISO, format, differenceInDays, eachDayOfInterval, addMonths, addQuarters } from 'date-fns';
|
import { parseISO, format, differenceInDays, eachDayOfInterval, addMonths, addQuarters } from 'date-fns';
|
||||||
import { AccountRecord } from '@/types/data';
|
import { AccountRecord } from '@/types/data';
|
||||||
|
import { ExportButton } from '@/components/ui/ExportButton';
|
||||||
|
import { scoreAccountHealth, HEALTH_LEVEL_COLORS } from '@/lib/scoring';
|
||||||
|
import { AccountNotes } from '@/components/account/AccountNotes';
|
||||||
|
import { ContactMap } from '@/components/account/ContactMap';
|
||||||
import {
|
import {
|
||||||
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell,
|
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell,
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
@@ -29,6 +33,15 @@ const PRIORITY_COLORS: Record<string, string> = {
|
|||||||
'Low': '#6B7280',
|
'Low': '#6B7280',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const COMPETITOR_COLORS: Record<string, string> = {
|
||||||
|
'Microsoft': '#00A4EF',
|
||||||
|
'Okta': '#00297A',
|
||||||
|
'Google': '#34A853',
|
||||||
|
'Sailpoint': '#0033A0',
|
||||||
|
'AWS': '#FF9900',
|
||||||
|
'Other': '#6B7280',
|
||||||
|
};
|
||||||
|
|
||||||
const RENEWAL_FILTER_OPTIONS = [
|
const RENEWAL_FILTER_OPTIONS = [
|
||||||
{ label: 'All Renewals', value: 'all' },
|
{ label: 'All Renewals', value: 'all' },
|
||||||
{ label: 'Next 90 Days', value: '90d' },
|
{ label: 'Next 90 Days', value: '90d' },
|
||||||
@@ -96,6 +109,7 @@ export default function AccountExplorer() {
|
|||||||
const [imsbaFilter, setImsbaFilter] = useState<string | null>(null);
|
const [imsbaFilter, setImsbaFilter] = useState<string | null>(null);
|
||||||
const [renewalFilter, setRenewalFilter] = useState<string>('all');
|
const [renewalFilter, setRenewalFilter] = useState<string>('all');
|
||||||
const [priorityFilter, setPriorityFilter] = useState<string | null>(null);
|
const [priorityFilter, setPriorityFilter] = useState<string | null>(null);
|
||||||
|
const [competitorFilter, setCompetitorFilter] = useState<string | null>(null);
|
||||||
const [selectedAccount, setSelectedAccount] = useState<AccountRecord | 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 adValues = useMemo(() => Array.from(new Set(accounts.map(a => a.AD).filter(Boolean))).sort() as string[], [accounts]);
|
||||||
@@ -114,6 +128,10 @@ export default function AccountExplorer() {
|
|||||||
if (adFilter) result = result.filter(a => a.AD === adFilter);
|
if (adFilter) result = result.filter(a => a.AD === adFilter);
|
||||||
if (imsbaFilter) result = result.filter(a => a.IMS_BA === imsbaFilter);
|
if (imsbaFilter) result = result.filter(a => a.IMS_BA === imsbaFilter);
|
||||||
if (priorityFilter) result = result.filter(a => a.Priority === priorityFilter);
|
if (priorityFilter) result = result.filter(a => a.Priority === priorityFilter);
|
||||||
|
if (competitorFilter) result = result.filter(a => {
|
||||||
|
const comps = (a.Competitors || '').split(',').map(c => c.trim()).filter(Boolean);
|
||||||
|
return comps.includes(competitorFilter);
|
||||||
|
});
|
||||||
|
|
||||||
if (renewalFilter !== 'all') {
|
if (renewalFilter !== 'all') {
|
||||||
if (renewalFilter === 'none') {
|
if (renewalFilter === 'none') {
|
||||||
@@ -132,7 +150,7 @@ export default function AccountExplorer() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}, [accounts, search, tierFilter, statusFilter, districtFilter, adFilter, imsbaFilter, renewalFilter, priorityFilter]);
|
}, [accounts, search, tierFilter, statusFilter, districtFilter, adFilter, imsbaFilter, renewalFilter, priorityFilter, competitorFilter]);
|
||||||
|
|
||||||
const kpis = useMemo(() => {
|
const kpis = useMemo(() => {
|
||||||
const acctNames = new Set(filteredAccounts.map(a => a.Account_Name));
|
const acctNames = new Set(filteredAccounts.map(a => a.Account_Name));
|
||||||
@@ -210,14 +228,16 @@ export default function AccountExplorer() {
|
|||||||
setAdFilter(null);
|
setAdFilter(null);
|
||||||
setImsbaFilter(null);
|
setImsbaFilter(null);
|
||||||
setPriorityFilter(null);
|
setPriorityFilter(null);
|
||||||
|
setCompetitorFilter(null);
|
||||||
setRenewalFilter('all');
|
setRenewalFilter('all');
|
||||||
setSearch('');
|
setSearch('');
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasFilters = tierFilter || statusFilter || districtFilter || adFilter || imsbaFilter || priorityFilter || renewalFilter !== 'all' || search;
|
const hasFilters = tierFilter || statusFilter || districtFilter || adFilter || imsbaFilter || priorityFilter || competitorFilter || renewalFilter !== 'all' || search;
|
||||||
|
|
||||||
if (selectedAccount) {
|
if (selectedAccount) {
|
||||||
const suggestion = getSuggestedPriority(selectedAccount);
|
const suggestion = getSuggestedPriority(selectedAccount);
|
||||||
|
const detailHealth = scoreAccountHealth(selectedAccount, activities, pipeline);
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<button onClick={() => setSelectedAccount(null)} className="flex items-center gap-1.5 text-xs text-brand-azure hover:text-brand-navy mb-4 transition">
|
<button onClick={() => setSelectedAccount(null)} className="flex items-center gap-1.5 text-xs text-brand-azure hover:text-brand-navy mb-4 transition">
|
||||||
@@ -234,6 +254,9 @@ export default function AccountExplorer() {
|
|||||||
<span className="px-2 py-0.5 rounded-full text-[10px] text-white font-semibold" style={{ backgroundColor: TIER_COLORS[selectedAccount.Tier] || '#94A3B8' }}>{selectedAccount.Tier || 'N/A'}</span>
|
<span className="px-2 py-0.5 rounded-full text-[10px] text-white font-semibold" style={{ backgroundColor: 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: STATUS_COLORS[selectedAccount.AgentMinder_Status] || '#94A3B8' }}>{selectedAccount.AgentMinder_Status}</span>
|
||||||
<span className="px-2 py-0.5 rounded-full text-[10px] text-white font-semibold" style={{ backgroundColor: PRIORITY_COLORS[selectedAccount.Priority] || '#94A3B8' }}>{selectedAccount.Priority} Priority</span>
|
<span className="px-2 py-0.5 rounded-full text-[10px] text-white font-semibold" style={{ backgroundColor: PRIORITY_COLORS[selectedAccount.Priority] || '#94A3B8' }}>{selectedAccount.Priority} Priority</span>
|
||||||
|
<span className="px-2 py-0.5 rounded-full text-[10px] font-bold" style={{ color: HEALTH_LEVEL_COLORS[detailHealth.health_level], backgroundColor: `${HEALTH_LEVEL_COLORS[detailHealth.health_level]}15` }}>
|
||||||
|
Health: {detailHealth.health_level} ({detailHealth.health_score})
|
||||||
|
</span>
|
||||||
<span className="text-xs text-muted">{DISTRICT_SHORT[selectedAccount.District_Name]}</span>
|
<span className="text-xs text-muted">{DISTRICT_SHORT[selectedAccount.District_Name]}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -298,6 +321,17 @@ export default function AccountExplorer() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{selectedAccount.Competitors && (
|
||||||
|
<div className="mt-3 pt-3 border-t border-card-border">
|
||||||
|
<div className="text-[10px] text-muted uppercase mb-1.5">Competitors</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{selectedAccount.Competitors.split(',').map(c => c.trim()).filter(Boolean).map(comp => (
|
||||||
|
<span key={comp} className="px-2 py-0.5 rounded-full text-[10px] text-white font-medium" style={{ backgroundColor: COMPETITOR_COLORS[comp] || '#6B7280' }}>{comp}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{(selectedAccount.AD || selectedAccount.IMS_BA || selectedAccount.Area_Sales_Leader || selectedAccount.DM) && (
|
{(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">
|
<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 && (
|
{selectedAccount.Area_Sales_Leader && (
|
||||||
@@ -354,6 +388,12 @@ export default function AccountExplorer() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Account Notes & Contacts */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
<AccountNotes accountName={selectedAccount.Account_Name} />
|
||||||
|
<ContactMap accountName={selectedAccount.Account_Name} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Activity Timeline */}
|
{/* Activity Timeline */}
|
||||||
<div className="bg-card-bg rounded-xl border border-card-border p-5">
|
<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>
|
<h3 className="text-sm font-semibold mb-3">Activity Timeline ({accountActivities.length} activities)</h3>
|
||||||
@@ -444,7 +484,7 @@ export default function AccountExplorer() {
|
|||||||
<BarChart data={priorityChartData} layout="vertical" margin={{ left: 0, right: 10, top: 0, bottom: 0 }}>
|
<BarChart data={priorityChartData} layout="vertical" margin={{ left: 0, right: 10, top: 0, bottom: 0 }}>
|
||||||
<XAxis type="number" hide />
|
<XAxis type="number" hide />
|
||||||
<YAxis type="category" dataKey="name" width={55} tick={{ fontSize: 11 }} axisLine={false} tickLine={false} />
|
<YAxis type="category" dataKey="name" width={55} tick={{ fontSize: 11 }} axisLine={false} tickLine={false} />
|
||||||
<Tooltip formatter={(value: number) => [value, 'Accounts']} />
|
<Tooltip formatter={(value) => [Number(value), 'Accounts']} />
|
||||||
<Bar dataKey="value" radius={[0, 4, 4, 0]} barSize={20}>
|
<Bar dataKey="value" radius={[0, 4, 4, 0]} barSize={20}>
|
||||||
{priorityChartData.map(entry => (
|
{priorityChartData.map(entry => (
|
||||||
<Cell key={entry.name} fill={PRIORITY_COLORS[entry.name] || '#94A3B8'} />
|
<Cell key={entry.name} fill={PRIORITY_COLORS[entry.name] || '#94A3B8'} />
|
||||||
@@ -515,6 +555,7 @@ export default function AccountExplorer() {
|
|||||||
onChange={e => setSearch(e.target.value)}
|
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"
|
className="text-xs border border-card-border rounded-lg px-3 py-2 w-56 focus:outline-none focus:ring-2 focus:ring-brand-azure/30"
|
||||||
/>
|
/>
|
||||||
|
<ExportButton table="accounts" />
|
||||||
{['Tier 1', 'Tier 2', 'Tier 3', 'Tier 4'].map(t => (
|
{['Tier 1', 'Tier 2', 'Tier 3', 'Tier 4'].map(t => (
|
||||||
<button
|
<button
|
||||||
key={t}
|
key={t}
|
||||||
@@ -586,6 +627,16 @@ export default function AccountExplorer() {
|
|||||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
<select
|
||||||
|
value={competitorFilter || ''}
|
||||||
|
onChange={e => setCompetitorFilter(e.target.value || null)}
|
||||||
|
className="text-xs border border-card-border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-brand-azure/30 bg-white"
|
||||||
|
>
|
||||||
|
<option value="">All Competitors</option>
|
||||||
|
{['Microsoft', 'Okta', 'Google', 'Sailpoint', 'AWS', 'Other'].map(c => (
|
||||||
|
<option key={c} value={c}>{c}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
{hasFilters && (
|
{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">
|
<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
|
Clear all
|
||||||
@@ -599,6 +650,7 @@ export default function AccountExplorer() {
|
|||||||
const stats = getAccountStats(account);
|
const stats = getAccountStats(account);
|
||||||
const suggestion = getSuggestedPriority(account);
|
const suggestion = getSuggestedPriority(account);
|
||||||
const mismatch = suggestion.level !== account.Priority;
|
const mismatch = suggestion.level !== account.Priority;
|
||||||
|
const health = scoreAccountHealth(account, activities, pipeline);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={account.Account_Name}
|
key={account.Account_Name}
|
||||||
@@ -632,12 +684,24 @@ export default function AccountExplorer() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{mismatch && (
|
{account.Competitors && (
|
||||||
<div className="flex items-center gap-1 mb-2 text-[10px]" style={{ color: PRIORITY_COLORS[suggestion.level] }}>
|
<div className="flex flex-wrap gap-1 mb-2">
|
||||||
<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>
|
{account.Competitors.split(',').map(c => c.trim()).filter(Boolean).map(comp => (
|
||||||
Suggested: {suggestion.level}
|
<span key={comp} className="px-1.5 py-0.5 rounded text-[8px] text-white font-medium" style={{ backgroundColor: COMPETITOR_COLORS[comp] || '#6B7280' }}>{comp}</span>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
{mismatch && (
|
||||||
|
<div className="flex items-center gap-1 text-[10px]" style={{ color: PRIORITY_COLORS[suggestion.level] }}>
|
||||||
|
<svg className="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 9v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||||
|
Suggested: {suggestion.level}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span className="ml-auto px-1.5 py-0.5 rounded text-[9px] font-bold" style={{ color: HEALTH_LEVEL_COLORS[health.health_level], backgroundColor: `${HEALTH_LEVEL_COLORS[health.health_level]}15` }}>
|
||||||
|
{health.health_level} ({health.health_score})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[10px] text-muted">ARR</div>
|
<div className="text-[10px] text-muted">ARR</div>
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import {
|
|||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import { parseISO, format, startOfWeek, differenceInDays, eachDayOfInterval } from 'date-fns';
|
import { parseISO, format, startOfWeek, differenceInDays, eachDayOfInterval } from 'date-fns';
|
||||||
import { ActivityRecord } from '@/types/data';
|
import { ActivityRecord } from '@/types/data';
|
||||||
|
import { ExportButton } from '@/components/ui/ExportButton';
|
||||||
|
import { AccountNotes } from '@/components/account/AccountNotes';
|
||||||
|
import { ContactMap } from '@/components/account/ContactMap';
|
||||||
|
|
||||||
const TYPE_COLORS: Record<string, string> = {
|
const TYPE_COLORS: Record<string, string> = {
|
||||||
'Launch Briefing': CHART_COLORS.navy,
|
'Launch Briefing': CHART_COLORS.navy,
|
||||||
@@ -310,16 +313,69 @@ export default function ActivityDeepDive() {
|
|||||||
</ChartCard>
|
</ChartCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Activity Effectiveness */}
|
||||||
|
<ChartCard title="Activity Effectiveness" subtitle="Which activities drive pipeline outcomes?">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-card-border">
|
||||||
|
<th className="text-left py-2 px-2 text-muted font-medium">Activity Type</th>
|
||||||
|
<th className="text-right py-2 px-2 text-muted font-medium">Count</th>
|
||||||
|
<th className="text-right py-2 px-2 text-muted font-medium">Unique Accts</th>
|
||||||
|
<th className="text-right py-2 px-2 text-muted font-medium">Accts w/ Pipeline</th>
|
||||||
|
<th className="text-right py-2 px-2 text-muted font-medium">Conversion</th>
|
||||||
|
<th className="text-right py-2 px-2 text-muted font-medium">Pipeline $</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(() => {
|
||||||
|
const types = Array.from(new Set(activities.map(a => a.Activity_Type)));
|
||||||
|
const pipelineAccts = new Set(pipeline.filter(p => p.Stage !== '07-Closed Lost').map(p => p.Account_Name));
|
||||||
|
return types.map(type => {
|
||||||
|
const typeActivities = activities.filter(a => a.Activity_Type === type);
|
||||||
|
const uniqueAccts = new Set(typeActivities.map(a => a.Account_Name));
|
||||||
|
const acctsWithPipeline = Array.from(uniqueAccts).filter(a => pipelineAccts.has(a));
|
||||||
|
const convRate = uniqueAccts.size > 0 ? (acctsWithPipeline.length / uniqueAccts.size) * 100 : 0;
|
||||||
|
const pipelineVal = pipeline.filter(p => acctsWithPipeline.includes(p.Account_Name) && p.Stage !== '06-Closed Won' && p.Stage !== '07-Closed Lost').reduce((s, p) => s + p.Amount_USD, 0);
|
||||||
|
return { type, count: typeActivities.length, uniqueAccts: uniqueAccts.size, acctsWithPipeline: acctsWithPipeline.length, convRate, pipelineVal };
|
||||||
|
}).sort((a, b) => b.convRate - a.convRate);
|
||||||
|
})().map(row => (
|
||||||
|
<tr key={row.type} className="border-b border-card-border/50">
|
||||||
|
<td className="py-2 px-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: TYPE_COLORS[row.type] || '#94A3B8' }} />
|
||||||
|
<span className="font-medium">{row.type}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-2 text-right">{row.count}</td>
|
||||||
|
<td className="py-2 px-2 text-right">{row.uniqueAccts}</td>
|
||||||
|
<td className="py-2 px-2 text-right">{row.acctsWithPipeline}</td>
|
||||||
|
<td className="py-2 px-2 text-right">
|
||||||
|
<span className={`font-medium ${row.convRate >= 50 ? 'text-green-600' : row.convRate >= 25 ? 'text-amber-600' : 'text-muted'}`}>
|
||||||
|
{row.convRate.toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-2 text-right font-medium">{row.pipelineVal > 0 ? `$${Math.round(row.pipelineVal / 1000)}K` : '—'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</ChartCard>
|
||||||
|
|
||||||
{/* Activity Detail Table */}
|
{/* Activity Detail Table */}
|
||||||
<ChartCard title="Activity Detail" subtitle={`${filteredActivities.length} activities`}
|
<ChartCard title="Activity Detail" subtitle={`${filteredActivities.length} activities`}
|
||||||
action={
|
action={
|
||||||
<input
|
<div className="flex items-center gap-2">
|
||||||
type="text"
|
<input
|
||||||
placeholder="Search activities..."
|
type="text"
|
||||||
value={searchQuery}
|
placeholder="Search activities..."
|
||||||
onChange={e => { setSearchQuery(e.target.value); setPage(0); }}
|
value={searchQuery}
|
||||||
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"
|
onChange={e => { setSearchQuery(e.target.value); setPage(0); }}
|
||||||
/>
|
className="text-xs border border-card-border rounded-lg px-3 py-1.5 w-48 focus:outline-none focus:ring-2 focus:ring-brand-azure/30"
|
||||||
|
/>
|
||||||
|
<ExportButton table="activities" />
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
@@ -524,6 +580,12 @@ export default function ActivityDeepDive() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Account Notes */}
|
||||||
|
<AccountNotes accountName={selectedActivity.Account_Name} />
|
||||||
|
|
||||||
|
{/* Stakeholder Map */}
|
||||||
|
<ContactMap accountName={selectedActivity.Account_Name} />
|
||||||
|
|
||||||
{/* Open Opportunities */}
|
{/* Open Opportunities */}
|
||||||
{overlayData.openPipeline.length > 0 && (
|
{overlayData.openPipeline.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
293
src/app/(dashboard)/goals/page.tsx
Normal file
293
src/app/(dashboard)/goals/page.tsx
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
|
import { useData } from '@/lib/data-context';
|
||||||
|
import { PageHeader } from '@/components/ui/PageHeader';
|
||||||
|
import { ChartCard } from '@/components/ui/ChartCard';
|
||||||
|
import { DISTRICTS } from '@/types/data';
|
||||||
|
import { subDays, parseISO, startOfWeek, endOfWeek, format } from 'date-fns';
|
||||||
|
|
||||||
|
interface DistrictTarget {
|
||||||
|
id?: number;
|
||||||
|
District_Name: string;
|
||||||
|
quarter: string;
|
||||||
|
activities_per_week: number;
|
||||||
|
accounts_touched: number;
|
||||||
|
pipeline_generated: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCurrentQuarter(): string {
|
||||||
|
const now = new Date();
|
||||||
|
const q = Math.ceil((now.getMonth() + 1) / 3);
|
||||||
|
return `FY${now.getFullYear().toString().slice(-2)}Q${q}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getQuarterOptions(): string[] {
|
||||||
|
const now = new Date();
|
||||||
|
const quarters: string[] = [];
|
||||||
|
for (let offset = -1; offset <= 3; offset++) {
|
||||||
|
const d = new Date(now.getFullYear(), now.getMonth() + offset * 3, 1);
|
||||||
|
const q = Math.ceil((d.getMonth() + 1) / 3);
|
||||||
|
const label = `FY${d.getFullYear().toString().slice(-2)}Q${q}`;
|
||||||
|
if (!quarters.includes(label)) quarters.push(label);
|
||||||
|
}
|
||||||
|
return quarters;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DISTRICT_SHORT: Record<string, string> = {
|
||||||
|
'SE-SUNSHINE': 'Sunshine',
|
||||||
|
'SE-PEACHTREE': 'Peachtree',
|
||||||
|
'SE-MISS-VALLEY': 'Miss Valley',
|
||||||
|
'SE-MID-ATL': 'Mid-Atlantic',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function GoalsPage() {
|
||||||
|
const { filtered } = useData();
|
||||||
|
const { activities, accounts, pipeline } = filtered;
|
||||||
|
const [quarter, setQuarter] = useState(getCurrentQuarter());
|
||||||
|
const [targets, setTargets] = useState<DistrictTarget[]>([]);
|
||||||
|
const [editing, setEditing] = useState<string | null>(null);
|
||||||
|
const [editForm, setEditForm] = useState<DistrictTarget | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const fetchTargets = useCallback(async () => {
|
||||||
|
const res = await fetch('/api/phase2', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'targets.list', quarter }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
setTargets(data.data || []);
|
||||||
|
}, [quarter]);
|
||||||
|
|
||||||
|
useEffect(() => { fetchTargets(); }, [fetchTargets]);
|
||||||
|
|
||||||
|
const actuals = useMemo(() => {
|
||||||
|
const now = new Date();
|
||||||
|
const weekStart = startOfWeek(now, { weekStartsOn: 1 });
|
||||||
|
const fourWeeksAgo = subDays(weekStart, 28);
|
||||||
|
|
||||||
|
const byDistrict: Record<string, { activitiesThisWeek: number; activitiesLast4Weeks: number; accountsTouched: number; pipelineGenerated: number }> = {};
|
||||||
|
for (const d of DISTRICTS) {
|
||||||
|
const dActivities = activities.filter(a => a.District_Name === d);
|
||||||
|
const thisWeek = dActivities.filter(a => a.Activity_Date >= format(weekStart, 'yyyy-MM-dd'));
|
||||||
|
const last4 = dActivities.filter(a => a.Activity_Date >= format(fourWeeksAgo, 'yyyy-MM-dd'));
|
||||||
|
const touched = new Set(dActivities.map(a => a.Account_Name)).size;
|
||||||
|
const totalAccts = accounts.filter(a => a.District_Name === d).length;
|
||||||
|
const pipelineVal = pipeline
|
||||||
|
.filter(p => p.District_Name === d && p.Stage !== '06-Closed Won' && p.Stage !== '07-Closed Lost')
|
||||||
|
.reduce((s, p) => s + p.Amount_USD, 0);
|
||||||
|
|
||||||
|
byDistrict[d] = {
|
||||||
|
activitiesThisWeek: thisWeek.length,
|
||||||
|
activitiesLast4Weeks: last4.length / 4,
|
||||||
|
accountsTouched: touched,
|
||||||
|
pipelineGenerated: pipelineVal,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return byDistrict;
|
||||||
|
}, [activities, accounts, pipeline]);
|
||||||
|
|
||||||
|
const handleEdit = (district: string) => {
|
||||||
|
const existing = targets.find(t => t.District_Name === district);
|
||||||
|
setEditForm(existing || { District_Name: district, quarter, activities_per_week: 10, accounts_touched: 5, pipeline_generated: 500000 });
|
||||||
|
setEditing(district);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!editForm) return;
|
||||||
|
setSaving(true);
|
||||||
|
await fetch('/api/phase2', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'targets.upsert', ...editForm }),
|
||||||
|
});
|
||||||
|
setSaving(false);
|
||||||
|
setEditing(null);
|
||||||
|
fetchTargets();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getProgress = (actual: number, target: number) => {
|
||||||
|
if (target <= 0) return 0;
|
||||||
|
return Math.min(Math.round((actual / target) * 100), 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getProgressColor = (pct: number) => {
|
||||||
|
if (pct >= 80) return '#16A34A';
|
||||||
|
if (pct >= 50) return '#F59E0B';
|
||||||
|
return '#DC2626';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader title="Goals & Targets" subtitle="Set and track district performance targets" />
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label className="text-sm font-medium text-muted">Quarter</label>
|
||||||
|
<select
|
||||||
|
value={quarter}
|
||||||
|
onChange={e => setQuarter(e.target.value)}
|
||||||
|
className="px-3 py-1.5 rounded-lg border border-card-border bg-card-bg text-sm text-foreground"
|
||||||
|
>
|
||||||
|
{getQuarterOptions().map(q => <option key={q} value={q}>{q}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* District Cards */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
{DISTRICTS.map(district => {
|
||||||
|
const target = targets.find(t => t.District_Name === district);
|
||||||
|
const actual = actuals[district] || { activitiesThisWeek: 0, activitiesLast4Weeks: 0, accountsTouched: 0, pipelineGenerated: 0 };
|
||||||
|
const isEditing = editing === district;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartCard key={district} title={DISTRICT_SHORT[district] || district}>
|
||||||
|
{isEditing && editForm ? (
|
||||||
|
<div className="space-y-3 py-2">
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-[10px] font-semibold text-muted uppercase mb-1">Activities/Week</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={editForm.activities_per_week}
|
||||||
|
onChange={e => setEditForm({ ...editForm, activities_per_week: Number(e.target.value) })}
|
||||||
|
className="w-full px-2 py-1.5 border border-card-border rounded-lg text-sm bg-card-bg text-foreground"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[10px] font-semibold text-muted uppercase mb-1">Accounts Touched</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={editForm.accounts_touched}
|
||||||
|
onChange={e => setEditForm({ ...editForm, accounts_touched: Number(e.target.value) })}
|
||||||
|
className="w-full px-2 py-1.5 border border-card-border rounded-lg text-sm bg-card-bg text-foreground"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[10px] font-semibold text-muted uppercase mb-1">Pipeline ($)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={editForm.pipeline_generated}
|
||||||
|
onChange={e => setEditForm({ ...editForm, pipeline_generated: Number(e.target.value) })}
|
||||||
|
className="w-full px-2 py-1.5 border border-card-border rounded-lg text-sm bg-card-bg text-foreground"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<button onClick={() => setEditing(null)} className="px-3 py-1.5 text-xs text-muted hover:text-foreground">Cancel</button>
|
||||||
|
<button onClick={handleSave} disabled={saving} className="px-4 py-1.5 bg-[#0098C7] text-white text-xs font-semibold rounded-lg hover:bg-[#007ba3] disabled:opacity-50">
|
||||||
|
{saving ? 'Saving...' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4 py-1">
|
||||||
|
{/* Metric rows */}
|
||||||
|
{[
|
||||||
|
{ label: 'Activities/Week', actual: Math.round(actual.activitiesLast4Weeks * 10) / 10, target: target?.activities_per_week || 0, format: (v: number) => v.toString() },
|
||||||
|
{ label: 'Accounts Touched', actual: actual.accountsTouched, target: target?.accounts_touched || 0, format: (v: number) => v.toString() },
|
||||||
|
{ label: 'Pipeline Generated', actual: actual.pipelineGenerated, target: target?.pipeline_generated || 0, format: (v: number) => `$${Math.round(v / 1000)}K` },
|
||||||
|
].map(metric => {
|
||||||
|
const pct = target ? getProgress(metric.actual, metric.target) : 0;
|
||||||
|
const color = getProgressColor(pct);
|
||||||
|
return (
|
||||||
|
<div key={metric.label}>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<span className="text-xs font-medium text-foreground">{metric.label}</span>
|
||||||
|
<span className="text-xs text-muted">
|
||||||
|
{metric.format(metric.actual)} / {target ? metric.format(metric.target) : '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full transition-all duration-500"
|
||||||
|
style={{ width: target ? `${pct}%` : '0%', backgroundColor: color }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{target && (
|
||||||
|
<div className="text-right mt-0.5">
|
||||||
|
<span className="text-[10px] font-bold" style={{ color }}>{pct}%</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<div className="pt-2 border-t border-card-border">
|
||||||
|
<button
|
||||||
|
onClick={() => handleEdit(district)}
|
||||||
|
className="text-xs text-[#0098C7] hover:text-[#007ba3] font-medium"
|
||||||
|
>
|
||||||
|
{target ? 'Edit Targets' : 'Set Targets'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ChartCard>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Territory Summary */}
|
||||||
|
<ChartCard title="Territory Summary">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-card-border">
|
||||||
|
<th className="text-left py-2 font-semibold text-muted">District</th>
|
||||||
|
<th className="text-center py-2 font-semibold text-muted">Activities/Wk</th>
|
||||||
|
<th className="text-center py-2 font-semibold text-muted">Accts Touched</th>
|
||||||
|
<th className="text-center py-2 font-semibold text-muted">Pipeline</th>
|
||||||
|
<th className="text-center py-2 font-semibold text-muted">Overall</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{DISTRICTS.map(d => {
|
||||||
|
const target = targets.find(t => t.District_Name === d);
|
||||||
|
const actual = actuals[d] || { activitiesThisWeek: 0, activitiesLast4Weeks: 0, accountsTouched: 0, pipelineGenerated: 0 };
|
||||||
|
const metrics = target ? [
|
||||||
|
getProgress(actual.activitiesLast4Weeks, target.activities_per_week),
|
||||||
|
getProgress(actual.accountsTouched, target.accounts_touched),
|
||||||
|
getProgress(actual.pipelineGenerated, target.pipeline_generated),
|
||||||
|
] : [];
|
||||||
|
const overall = metrics.length > 0 ? Math.round(metrics.reduce((a, b) => a + b, 0) / metrics.length) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={d} className="border-b border-card-border/50">
|
||||||
|
<td className="py-2.5 font-medium text-foreground">{DISTRICT_SHORT[d] || d}</td>
|
||||||
|
{target ? (
|
||||||
|
<>
|
||||||
|
<td className="text-center py-2.5">
|
||||||
|
<span className="px-2 py-0.5 rounded-full text-[10px] font-bold" style={{ color: getProgressColor(metrics[0]), backgroundColor: `${getProgressColor(metrics[0])}15` }}>
|
||||||
|
{metrics[0]}%
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="text-center py-2.5">
|
||||||
|
<span className="px-2 py-0.5 rounded-full text-[10px] font-bold" style={{ color: getProgressColor(metrics[1]), backgroundColor: `${getProgressColor(metrics[1])}15` }}>
|
||||||
|
{metrics[1]}%
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="text-center py-2.5">
|
||||||
|
<span className="px-2 py-0.5 rounded-full text-[10px] font-bold" style={{ color: getProgressColor(metrics[2]), backgroundColor: `${getProgressColor(metrics[2])}15` }}>
|
||||||
|
{metrics[2]}%
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="text-center py-2.5">
|
||||||
|
<span className="px-2 py-0.5 rounded-full text-[10px] font-bold" style={{ color: getProgressColor(overall), backgroundColor: `${getProgressColor(overall)}15` }}>
|
||||||
|
{overall}%
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<td colSpan={4} className="text-center py-2.5 text-muted italic">No targets set</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</ChartCard>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,7 +5,8 @@ import { Scorecard } from '@/components/ui/Scorecard';
|
|||||||
import { ChartCard } from '@/components/ui/ChartCard';
|
import { ChartCard } from '@/components/ui/ChartCard';
|
||||||
import { PageHeader } from '@/components/ui/PageHeader';
|
import { PageHeader } from '@/components/ui/PageHeader';
|
||||||
import { formatCurrency, formatPercent, formatRatio, CHART_COLORS, STATUS_COLORS, FORECAST_COLORS, DISTRICT_SHORT } from '@/lib/formatters';
|
import { formatCurrency, formatPercent, formatRatio, CHART_COLORS, STATUS_COLORS, FORECAST_COLORS, DISTRICT_SHORT } from '@/lib/formatters';
|
||||||
import { useMemo } from 'react';
|
import { NextBestAction } from '@/components/ui/NextBestAction';
|
||||||
|
import { useMemo, useState, useEffect, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend,
|
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend,
|
||||||
AreaChart, Area,
|
AreaChart, Area,
|
||||||
@@ -163,7 +164,7 @@ export default function ExecutiveOverview() {
|
|||||||
</ChartCard>
|
</ChartCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4">
|
||||||
<ChartCard title="Campaign Funnel" subtitle="Conversion through the pipeline">
|
<ChartCard title="Campaign Funnel" subtitle="Conversion through the pipeline">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{funnelData.map((stage, i) => {
|
{funnelData.map((stage, i) => {
|
||||||
@@ -204,6 +205,11 @@ export default function ExecutiveOverview() {
|
|||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Next Best Action Engine */}
|
||||||
|
<ChartCard title="Next Best Actions" subtitle="AI-prioritized actions for your territory">
|
||||||
|
<NextBestAction accounts={accounts} activities={activities} pipeline={pipeline} limit={6} />
|
||||||
|
</ChartCard>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,9 +11,12 @@ import {
|
|||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import { parseISO, differenceInDays, format } from 'date-fns';
|
import { parseISO, differenceInDays, format } from 'date-fns';
|
||||||
import { PipelineRecord } from '@/types/data';
|
import { PipelineRecord } from '@/types/data';
|
||||||
|
import { ExportButton } from '@/components/ui/ExportButton';
|
||||||
|
import { scoreDealRisk, RISK_LEVEL_COLORS } from '@/lib/scoring';
|
||||||
|
|
||||||
|
|
||||||
function DealDetailPanel({ deal, onClose }: { deal: PipelineRecord; onClose: () => void }) {
|
function DealDetailPanel({ deal, onClose, activities, allDeals }: { deal: PipelineRecord; onClose: () => void; activities: { Account_Name: string; Activity_Date: string }[]; allDeals: PipelineRecord[] }) {
|
||||||
|
const risk = scoreDealRisk(deal, activities as Parameters<typeof scoreDealRisk>[1], allDeals);
|
||||||
return (
|
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="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">
|
<div className="sticky top-0 bg-white border-b border-card-border px-5 py-4 flex items-center justify-between">
|
||||||
@@ -23,6 +26,23 @@ function DealDetailPanel({ deal, onClose }: { deal: PipelineRecord; onClose: ()
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-5 space-y-5">
|
<div className="p-5 space-y-5">
|
||||||
|
{/* Deal Risk Score */}
|
||||||
|
{risk.risk_score > 0 && (
|
||||||
|
<div className="rounded-xl p-3 border" style={{ borderColor: RISK_LEVEL_COLORS[risk.risk_level], backgroundColor: `${RISK_LEVEL_COLORS[risk.risk_level]}10` }}>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-xs font-semibold" style={{ color: RISK_LEVEL_COLORS[risk.risk_level] }}>Deal Risk: {risk.risk_level}</span>
|
||||||
|
<span className="text-lg font-bold" style={{ color: RISK_LEVEL_COLORS[risk.risk_level] }}>{risk.risk_score}</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 bg-gray-200 rounded-full overflow-hidden mb-2">
|
||||||
|
<div className="h-full rounded-full" style={{ width: `${risk.risk_score}%`, backgroundColor: RISK_LEVEL_COLORS[risk.risk_level] }} />
|
||||||
|
</div>
|
||||||
|
{risk.risk_factors.map((f, i) => (
|
||||||
|
<div key={i} className="text-[11px] text-muted flex items-center gap-1.5">
|
||||||
|
<span style={{ color: RISK_LEVEL_COLORS[risk.risk_level] }}>!</span> {f}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[10px] text-muted uppercase tracking-wider">Amount</div>
|
<div className="text-[10px] text-muted uppercase tracking-wider">Amount</div>
|
||||||
@@ -80,7 +100,7 @@ function DealDetailPanel({ deal, onClose }: { deal: PipelineRecord; onClose: ()
|
|||||||
|
|
||||||
export default function PipelineDeepDive() {
|
export default function PipelineDeepDive() {
|
||||||
const { filtered, setCrossFilter } = useData();
|
const { filtered, setCrossFilter } = useData();
|
||||||
const { pipeline } = filtered;
|
const { pipeline, activities } = filtered;
|
||||||
const [selectedDeal, setSelectedDeal] = useState<PipelineRecord | null>(null);
|
const [selectedDeal, setSelectedDeal] = useState<PipelineRecord | null>(null);
|
||||||
const [stageFilter, setStageFilter] = useState<string | null>(null);
|
const [stageFilter, setStageFilter] = useState<string | null>(null);
|
||||||
const [forecastQuickFilter, setForecastQuickFilter] = useState(false);
|
const [forecastQuickFilter, setForecastQuickFilter] = useState(false);
|
||||||
@@ -162,6 +182,10 @@ export default function PipelineDeepDive() {
|
|||||||
<div>
|
<div>
|
||||||
<PageHeader title="Pipeline Deep Dive" subtitle="Where is the money and will it close?" />
|
<PageHeader title="Pipeline Deep Dive" subtitle="Where is the money and will it close?" />
|
||||||
|
|
||||||
|
<div className="flex justify-end mb-2">
|
||||||
|
<ExportButton table="pipeline" />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Stage filter pills */}
|
{/* Stage filter pills */}
|
||||||
<div className="flex flex-wrap gap-2 mb-4">
|
<div className="flex flex-wrap gap-2 mb-4">
|
||||||
<button
|
<button
|
||||||
@@ -276,11 +300,13 @@ export default function PipelineDeepDive() {
|
|||||||
<th className="text-left py-2 px-2 text-muted font-medium hidden lg:table-cell">Next Step</th>
|
<th className="text-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-right py-2 px-2 text-muted font-medium hidden md:table-cell">Days</th>
|
||||||
<th className="text-left py-2 px-2 text-muted font-medium hidden lg:table-cell">Close Date</th>
|
<th className="text-left py-2 px-2 text-muted font-medium hidden lg:table-cell">Close Date</th>
|
||||||
|
<th className="text-center py-2 px-2 text-muted font-medium hidden md:table-cell">Risk</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{topDeals.map(deal => {
|
{topDeals.map(deal => {
|
||||||
const days = differenceInDays(new Date(), parseISO(deal.Created_Date));
|
const days = differenceInDays(new Date(), parseISO(deal.Created_Date));
|
||||||
|
const risk = scoreDealRisk(deal, activities as Parameters<typeof scoreDealRisk>[1], pipeline);
|
||||||
return (
|
return (
|
||||||
<tr key={deal.Opportunity_ID} className="border-b border-card-border/50 hover:bg-gray-50 cursor-pointer transition" onClick={() => setSelectedDeal(deal)}>
|
<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 font-medium">{deal.Account_Name}</td>
|
||||||
@@ -295,6 +321,11 @@ export default function PipelineDeepDive() {
|
|||||||
<td className="py-2 px-2 text-muted hidden lg:table-cell max-w-[160px] truncate">{deal.Next_Step || '—'}</td>
|
<td className="py-2 px-2 text-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-right hidden md:table-cell ${days > 90 ? 'text-danger font-medium' : 'text-muted'}`}>{days}</td>
|
||||||
<td className="py-2 px-2 text-muted hidden lg:table-cell">{format(parseISO(deal.Expected_Close_Date), 'MMM d')}</td>
|
<td className="py-2 px-2 text-muted hidden lg:table-cell">{format(parseISO(deal.Expected_Close_Date), 'MMM d')}</td>
|
||||||
|
<td className="py-2 px-2 text-center hidden md:table-cell">
|
||||||
|
<span className="inline-block px-1.5 py-0.5 rounded text-[9px] font-bold text-white" style={{ backgroundColor: RISK_LEVEL_COLORS[risk.risk_level] }}>
|
||||||
|
{risk.risk_score}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -307,7 +338,7 @@ export default function PipelineDeepDive() {
|
|||||||
{selectedDeal && (
|
{selectedDeal && (
|
||||||
<>
|
<>
|
||||||
<div className="fixed inset-0 bg-black/20 z-40" onClick={() => setSelectedDeal(null)} />
|
<div className="fixed inset-0 bg-black/20 z-40" onClick={() => setSelectedDeal(null)} />
|
||||||
<DealDetailPanel deal={selectedDeal} onClose={() => setSelectedDeal(null)} />
|
<DealDetailPanel deal={selectedDeal} onClose={() => setSelectedDeal(null)} activities={activities} allDeals={pipeline} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
252
src/app/(dashboard)/playbooks/page.tsx
Normal file
252
src/app/(dashboard)/playbooks/page.tsx
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { PageHeader } from '@/components/ui/PageHeader';
|
||||||
|
import { ChartCard } from '@/components/ui/ChartCard';
|
||||||
|
|
||||||
|
interface PlaybookStep {
|
||||||
|
step: number;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
duration: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Playbook {
|
||||||
|
id: number;
|
||||||
|
play_name: string;
|
||||||
|
description: string | null;
|
||||||
|
steps_json: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlaybookProgress {
|
||||||
|
id: number;
|
||||||
|
Account_Name: string;
|
||||||
|
playbook_id: number;
|
||||||
|
current_step: number;
|
||||||
|
status: string;
|
||||||
|
started_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STEP_STATUS_COLORS: Record<string, string> = {
|
||||||
|
'in_progress': '#0098C7',
|
||||||
|
'completed': '#16A34A',
|
||||||
|
'paused': '#F59E0B',
|
||||||
|
'not_started': '#94A3B8',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PlaybooksPage() {
|
||||||
|
const [playbooks, setPlaybooks] = useState<Playbook[]>([]);
|
||||||
|
const [selectedPlaybook, setSelectedPlaybook] = useState<Playbook | null>(null);
|
||||||
|
const [progress, setProgress] = useState<PlaybookProgress[]>([]);
|
||||||
|
const [assignAccount, setAssignAccount] = useState('');
|
||||||
|
const [accounts, setAccounts] = useState<string[]>([]);
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [editForm, setEditForm] = useState({ play_name: '', description: '', steps_json: '' });
|
||||||
|
|
||||||
|
const fetchPlaybooks = useCallback(async () => {
|
||||||
|
const res = await fetch('/api/phase2', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'playbooks.list' }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
setPlaybooks(data.data || []);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { fetchPlaybooks(); }, [fetchPlaybooks]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/admin', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'list', table: 'accounts' }) })
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => setAccounts((d.data || []).map((a: { Account_Name: string }) => a.Account_Name).sort()));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const selectPlaybook = async (pb: Playbook) => {
|
||||||
|
setSelectedPlaybook(pb);
|
||||||
|
setEditing(false);
|
||||||
|
const res = await fetch('/api/phase2', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'playbooks.progress.list', Account_Name: '__all__' }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
setProgress((data.data || []).filter((p: PlaybookProgress) => p.playbook_id === pb.id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const assignPlaybook = async () => {
|
||||||
|
if (!assignAccount || !selectedPlaybook) return;
|
||||||
|
await fetch('/api/phase2', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'playbooks.progress.upsert', Account_Name: assignAccount, playbook_id: selectedPlaybook.id, current_step: 1, status: 'in_progress' }),
|
||||||
|
});
|
||||||
|
setAssignAccount('');
|
||||||
|
selectPlaybook(selectedPlaybook);
|
||||||
|
};
|
||||||
|
|
||||||
|
const advanceStep = async (p: PlaybookProgress) => {
|
||||||
|
const steps: PlaybookStep[] = selectedPlaybook ? JSON.parse(selectedPlaybook.steps_json) : [];
|
||||||
|
const nextStep = p.current_step + 1;
|
||||||
|
const isComplete = nextStep > steps.length;
|
||||||
|
await fetch('/api/phase2', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: 'playbooks.progress.upsert',
|
||||||
|
Account_Name: p.Account_Name,
|
||||||
|
playbook_id: p.playbook_id,
|
||||||
|
current_step: isComplete ? p.current_step : nextStep,
|
||||||
|
status: isComplete ? 'completed' : 'in_progress',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (selectedPlaybook) selectPlaybook(selectedPlaybook);
|
||||||
|
};
|
||||||
|
|
||||||
|
const startEdit = (pb: Playbook) => {
|
||||||
|
setEditForm({ play_name: pb.play_name, description: pb.description || '', steps_json: pb.steps_json });
|
||||||
|
setEditing(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const savePlaybook = async () => {
|
||||||
|
await fetch('/api/phase2', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'playbooks.upsert', id: selectedPlaybook?.id, ...editForm }),
|
||||||
|
});
|
||||||
|
setEditing(false);
|
||||||
|
fetchPlaybooks();
|
||||||
|
if (selectedPlaybook) {
|
||||||
|
const updated = { ...selectedPlaybook, ...editForm };
|
||||||
|
setSelectedPlaybook(updated);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (selectedPlaybook) {
|
||||||
|
const steps: PlaybookStep[] = (() => { try { return JSON.parse(selectedPlaybook.steps_json); } catch { return []; } })();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button onClick={() => setSelectedPlaybook(null)} className="flex items-center gap-1.5 text-xs text-brand-azure hover:text-brand-navy transition">
|
||||||
|
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="15 18 9 12 15 6" /></svg>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<PageHeader title={selectedPlaybook.play_name} subtitle={selectedPlaybook.description || 'Campaign playbook'} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editing ? (
|
||||||
|
<ChartCard title="Edit Playbook">
|
||||||
|
<div className="space-y-3 py-2">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-muted mb-1">Play Name</label>
|
||||||
|
<input value={editForm.play_name} onChange={e => setEditForm({ ...editForm, play_name: e.target.value })} className="w-full px-3 py-2 border border-card-border rounded-lg text-sm bg-card-bg text-foreground" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-muted mb-1">Description</label>
|
||||||
|
<input value={editForm.description} onChange={e => setEditForm({ ...editForm, description: e.target.value })} className="w-full px-3 py-2 border border-card-border rounded-lg text-sm bg-card-bg text-foreground" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-muted mb-1">Steps (JSON)</label>
|
||||||
|
<textarea value={editForm.steps_json} onChange={e => setEditForm({ ...editForm, steps_json: e.target.value })} rows={10} className="w-full px-3 py-2 border border-card-border rounded-lg text-xs font-mono bg-card-bg text-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<button onClick={() => setEditing(false)} className="px-3 py-1.5 text-xs text-muted hover:text-foreground">Cancel</button>
|
||||||
|
<button onClick={savePlaybook} className="px-4 py-1.5 bg-[#0098C7] text-white text-xs font-semibold rounded-lg hover:bg-[#007ba3]">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ChartCard>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Steps Overview */}
|
||||||
|
<ChartCard title="Playbook Steps" action={<button onClick={() => startEdit(selectedPlaybook)} className="text-xs text-[#0098C7] hover:text-[#007ba3] font-medium">Edit</button>}>
|
||||||
|
<div className="space-y-3 py-1">
|
||||||
|
{steps.map((step, i) => (
|
||||||
|
<div key={i} className="flex items-start gap-3">
|
||||||
|
<div className="w-7 h-7 rounded-full bg-[#1B1D36] text-white flex items-center justify-center text-xs font-bold flex-shrink-0">{step.step}</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-semibold text-foreground">{step.title}</div>
|
||||||
|
<div className="text-xs text-muted mt-0.5">{step.description}</div>
|
||||||
|
<div className="text-[10px] text-muted mt-1">{step.duration}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ChartCard>
|
||||||
|
|
||||||
|
{/* Account Progress */}
|
||||||
|
<ChartCard title="Account Progress" subtitle={`${progress.length} accounts running this play`}>
|
||||||
|
<div className="space-y-2 py-1">
|
||||||
|
{progress.map(p => {
|
||||||
|
const pct = steps.length > 0 ? Math.round(((p.status === 'completed' ? steps.length : p.current_step - 1) / steps.length) * 100) : 0;
|
||||||
|
return (
|
||||||
|
<div key={p.id} className="flex items-center gap-3 py-1.5 border-b border-card-border/50 last:border-0">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-xs font-semibold text-foreground">{p.Account_Name}</div>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<div className="flex-1 h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||||
|
<div className="h-full rounded-full transition-all" style={{ width: `${pct}%`, backgroundColor: STEP_STATUS_COLORS[p.status] || '#94A3B8' }} />
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] font-medium" style={{ color: STEP_STATUS_COLORS[p.status] }}>
|
||||||
|
{p.status === 'completed' ? 'Done' : `Step ${p.current_step}/${steps.length}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{p.status !== 'completed' && (
|
||||||
|
<button onClick={() => advanceStep(p)} className="px-2 py-1 text-[10px] font-semibold text-[#0098C7] border border-[#0098C7] rounded hover:bg-[#0098C7]/10">
|
||||||
|
Advance
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Assign to account */}
|
||||||
|
<div className="flex items-center gap-2 pt-2 border-t border-card-border">
|
||||||
|
<select value={assignAccount} onChange={e => setAssignAccount(e.target.value)} className="flex-1 px-2 py-1.5 border border-card-border rounded-lg text-xs bg-card-bg text-foreground">
|
||||||
|
<option value="">Assign to account...</option>
|
||||||
|
{accounts.filter(a => !progress.some(p => p.Account_Name === a)).map(a => <option key={a} value={a}>{a}</option>)}
|
||||||
|
</select>
|
||||||
|
<button onClick={assignPlaybook} disabled={!assignAccount} className="px-3 py-1.5 bg-[#0098C7] text-white text-xs font-semibold rounded-lg hover:bg-[#007ba3] disabled:opacity-40">
|
||||||
|
Assign
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ChartCard>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader title="Campaign Playbooks" subtitle="Standardized sales plays and account progress tracking" />
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{playbooks.map(pb => {
|
||||||
|
const steps: PlaybookStep[] = (() => { try { return JSON.parse(pb.steps_json); } catch { return []; } })();
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={pb.id}
|
||||||
|
onClick={() => selectPlaybook(pb)}
|
||||||
|
className="bg-card-bg rounded-xl border border-card-border p-5 cursor-pointer hover:shadow-md hover:border-brand-azure/30 transition-all"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-[#1B1D36] text-white flex items-center justify-center">
|
||||||
|
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" /><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" /></svg>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-sm font-bold text-foreground">{pb.play_name}</h3>
|
||||||
|
</div>
|
||||||
|
{pb.description && <p className="text-xs text-muted mb-3">{pb.description}</p>}
|
||||||
|
<div className="flex items-center gap-3 text-[10px] text-muted">
|
||||||
|
<span>{steps.length} steps</span>
|
||||||
|
<span className="px-1.5 py-0.5 rounded bg-brand-azure/10 text-brand-azure font-semibold">View Play</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
382
src/app/(dashboard)/reports/executive/page.tsx
Normal file
382
src/app/(dashboard)/reports/executive/page.tsx
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useData } from '@/lib/data-context';
|
||||||
|
import { formatCurrency, formatPercent, DISTRICT_SHORT, STAGE_COLORS, CHART_COLORS } from '@/lib/formatters';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { format, subDays, parseISO, differenceInDays } from 'date-fns';
|
||||||
|
|
||||||
|
const STAGE_ORDER = [
|
||||||
|
'01-Qualified', '02-Discovery', '03-Evaluation',
|
||||||
|
'04-Business Case', '05-Negotiation',
|
||||||
|
];
|
||||||
|
|
||||||
|
const STAGE_LABELS: Record<string, string> = {
|
||||||
|
'01-Qualified': 'Qualified',
|
||||||
|
'02-Discovery': 'Discovery',
|
||||||
|
'03-Evaluation': 'Evaluation',
|
||||||
|
'04-Business Case': 'Biz Case',
|
||||||
|
'05-Negotiation': 'Negotiation',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ExecutiveSummary() {
|
||||||
|
const { filtered, config } = useData();
|
||||||
|
const { pipeline, accounts, activities } = filtered;
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const scorecard = useMemo(() => {
|
||||||
|
const totalPipeline = pipeline
|
||||||
|
.filter(p => p.Stage !== '06-Closed Won' && p.Stage !== '07-Closed Lost')
|
||||||
|
.reduce((sum, p) => sum + p.Amount_USD, 0);
|
||||||
|
|
||||||
|
const totalAccounts = accounts.length;
|
||||||
|
const touchedAccounts = accounts.filter(a => (a.Touch_Count || 0) > 0).length;
|
||||||
|
const touchRate = totalAccounts > 0 ? (touchedAccounts / totalAccounts) * 100 : 0;
|
||||||
|
|
||||||
|
const closedWon = pipeline
|
||||||
|
.filter(p => p.Stage === '06-Closed Won')
|
||||||
|
.reduce((sum, p) => sum + (p.Closed_Amount_USD || 0), 0);
|
||||||
|
|
||||||
|
const thirtyDaysAgo = format(subDays(now, 30), 'yyyy-MM-dd');
|
||||||
|
const recentActivities = activities.filter(a => a.Activity_Date >= thirtyDaysAgo).length;
|
||||||
|
const weeklyPace = recentActivities / 4.3;
|
||||||
|
const paceVsTarget = config.weeklyActivityTarget > 0
|
||||||
|
? (weeklyPace / config.weeklyActivityTarget) * 100
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return { totalPipeline, touchRate, closedWon, paceVsTarget };
|
||||||
|
}, [pipeline, accounts, activities, config, now]);
|
||||||
|
|
||||||
|
const pipelineByStage = useMemo(() => {
|
||||||
|
const open = pipeline.filter(p => p.Stage !== '06-Closed Won' && p.Stage !== '07-Closed Lost');
|
||||||
|
const total = open.reduce((sum, p) => sum + p.Amount_USD, 0);
|
||||||
|
const stageMap = new Map<string, number>();
|
||||||
|
open.forEach(p => {
|
||||||
|
stageMap.set(p.Stage, (stageMap.get(p.Stage) || 0) + p.Amount_USD);
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
stages: STAGE_ORDER
|
||||||
|
.filter(s => stageMap.has(s))
|
||||||
|
.map(s => ({
|
||||||
|
stage: s,
|
||||||
|
label: STAGE_LABELS[s] || s,
|
||||||
|
amount: stageMap.get(s) || 0,
|
||||||
|
pct: total > 0 ? ((stageMap.get(s) || 0) / total) * 100 : 0,
|
||||||
|
color: STAGE_COLORS[s] || CHART_COLORS.navy,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}, [pipeline]);
|
||||||
|
|
||||||
|
const campaignProgress = useMemo(() => {
|
||||||
|
const byPriority = { High: 0, Medium: 0, Low: 0 };
|
||||||
|
accounts.forEach(a => {
|
||||||
|
if (a.Priority in byPriority) {
|
||||||
|
byPriority[a.Priority as keyof typeof byPriority]++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const totalAccounts = accounts.length;
|
||||||
|
const touchedAccounts = accounts.filter(a => (a.Touch_Count || 0) > 0).length;
|
||||||
|
return { byPriority, totalAccounts, touchedAccounts };
|
||||||
|
}, [accounts]);
|
||||||
|
|
||||||
|
const districtPerformance = useMemo(() => {
|
||||||
|
const districts = new Map<string, { pipeline: number; activities: number; accountsTouched: Set<string>; totalAccounts: number }>();
|
||||||
|
|
||||||
|
accounts.forEach(a => {
|
||||||
|
const d = DISTRICT_SHORT[a.District_Name] || a.District_Name;
|
||||||
|
if (!districts.has(d)) districts.set(d, { pipeline: 0, activities: 0, accountsTouched: new Set(), totalAccounts: 0 });
|
||||||
|
const entry = districts.get(d)!;
|
||||||
|
entry.totalAccounts++;
|
||||||
|
if ((a.Touch_Count || 0) > 0) entry.accountsTouched.add(a.Account_Name);
|
||||||
|
});
|
||||||
|
|
||||||
|
pipeline.filter(p => p.Stage !== '06-Closed Won' && p.Stage !== '07-Closed Lost').forEach(p => {
|
||||||
|
const d = DISTRICT_SHORT[p.District_Name] || p.District_Name;
|
||||||
|
if (!districts.has(d)) districts.set(d, { pipeline: 0, activities: 0, accountsTouched: new Set(), totalAccounts: 0 });
|
||||||
|
districts.get(d)!.pipeline += p.Amount_USD;
|
||||||
|
});
|
||||||
|
|
||||||
|
activities.forEach(a => {
|
||||||
|
const d = DISTRICT_SHORT[a.District_Name] || a.District_Name;
|
||||||
|
if (!districts.has(d)) districts.set(d, { pipeline: 0, activities: 0, accountsTouched: new Set(), totalAccounts: 0 });
|
||||||
|
districts.get(d)!.activities++;
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(districts.entries())
|
||||||
|
.map(([name, data]) => ({
|
||||||
|
name,
|
||||||
|
pipeline: data.pipeline,
|
||||||
|
activities: data.activities,
|
||||||
|
accountsTouched: data.accountsTouched.size,
|
||||||
|
totalAccounts: data.totalAccounts,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.pipeline - a.pipeline);
|
||||||
|
}, [pipeline, accounts, activities]);
|
||||||
|
|
||||||
|
const keyWins = useMemo(() => {
|
||||||
|
return pipeline
|
||||||
|
.filter(p => p.Stage === '06-Closed Won')
|
||||||
|
.sort((a, b) => (b.Closed_Amount_USD || 0) - (a.Closed_Amount_USD || 0))
|
||||||
|
.slice(0, 8);
|
||||||
|
}, [pipeline]);
|
||||||
|
|
||||||
|
const watchList = useMemo(() => {
|
||||||
|
const thirtyDaysAgo = format(subDays(now, 30), 'yyyy-MM-dd');
|
||||||
|
|
||||||
|
const staleAccounts = accounts
|
||||||
|
.filter(a => a.Priority === 'High' && (!a.Date_Last_Touched || a.Date_Last_Touched < thirtyDaysAgo))
|
||||||
|
.slice(0, 5);
|
||||||
|
|
||||||
|
const agingDeals = pipeline
|
||||||
|
.filter(p => {
|
||||||
|
if (p.Stage === '06-Closed Won' || p.Stage === '07-Closed Lost') return false;
|
||||||
|
if (!p.Stage_Entered_Date) return false;
|
||||||
|
const daysInStage = differenceInDays(now, parseISO(p.Stage_Entered_Date));
|
||||||
|
return daysInStage > 30;
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
const dA = differenceInDays(now, parseISO(a.Stage_Entered_Date!));
|
||||||
|
const dB = differenceInDays(now, parseISO(b.Stage_Entered_Date!));
|
||||||
|
return dB - dA;
|
||||||
|
})
|
||||||
|
.slice(0, 5);
|
||||||
|
|
||||||
|
return { staleAccounts, agingDeals };
|
||||||
|
}, [accounts, pipeline, now]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<style>{`
|
||||||
|
@media print {
|
||||||
|
nav, aside, header, [data-sidebar], [data-topbar], .no-print {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
main {
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
background: white !important;
|
||||||
|
-webkit-print-color-adjust: exact;
|
||||||
|
print-color-adjust: exact;
|
||||||
|
}
|
||||||
|
.report-container {
|
||||||
|
max-width: 100% !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
|
||||||
|
<div className="no-print mb-4 flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => window.print()}
|
||||||
|
className="px-4 py-2 bg-[#005C8A] text-white rounded-lg text-sm font-medium hover:bg-[#004a6e] transition-colors"
|
||||||
|
>
|
||||||
|
Print / Save as PDF
|
||||||
|
</button>
|
||||||
|
<span className="text-xs text-muted">Use your browser's print dialog to save as PDF</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="report-container max-w-4xl mx-auto bg-white p-8 rounded-lg shadow-sm border border-card-border">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="border-b-2 border-[#005C8A] pb-4 mb-6">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-[#005C8A]">AgentMinder — Executive Summary</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">SouthEast Territory</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-400">{format(now, 'MMMM d, yyyy')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Territory Scorecard */}
|
||||||
|
<section className="mb-6">
|
||||||
|
<h2 className="text-xs font-bold text-gray-500 uppercase tracking-wider mb-3">Territory Scorecard</h2>
|
||||||
|
<div className="grid grid-cols-4 gap-4">
|
||||||
|
<div className="bg-[#005C8A] rounded-lg p-4 text-center text-white">
|
||||||
|
<div className="text-2xl font-bold">{formatCurrency(scorecard.totalPipeline, true)}</div>
|
||||||
|
<div className="text-xs opacity-80 mt-1">Total Pipeline</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-[#0098C7] rounded-lg p-4 text-center text-white">
|
||||||
|
<div className="text-2xl font-bold">{formatPercent(scorecard.touchRate)}</div>
|
||||||
|
<div className="text-xs opacity-80 mt-1">Accounts Touched</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-[#61A60E] rounded-lg p-4 text-center text-white">
|
||||||
|
<div className="text-2xl font-bold">{formatCurrency(scorecard.closedWon, true)}</div>
|
||||||
|
<div className="text-xs opacity-80 mt-1">Closed Won YTD</div>
|
||||||
|
</div>
|
||||||
|
<div className={`rounded-lg p-4 text-center text-white ${scorecard.paceVsTarget >= 80 ? 'bg-[#61A60E]' : scorecard.paceVsTarget >= 50 ? 'bg-amber-500' : 'bg-red-500'}`}>
|
||||||
|
<div className="text-2xl font-bold">{formatPercent(scorecard.paceVsTarget)}</div>
|
||||||
|
<div className="text-xs opacity-80 mt-1">Activity Pace vs Target</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Pipeline Health */}
|
||||||
|
<section className="mb-6">
|
||||||
|
<h2 className="text-xs font-bold text-gray-500 uppercase tracking-wider mb-3">Pipeline Health</h2>
|
||||||
|
<div className="mb-2">
|
||||||
|
<div className="flex rounded-lg overflow-hidden h-10">
|
||||||
|
{pipelineByStage.stages.map(s => (
|
||||||
|
<div
|
||||||
|
key={s.stage}
|
||||||
|
style={{ width: `${s.pct}%`, backgroundColor: s.color, minWidth: s.pct > 0 ? '2px' : '0' }}
|
||||||
|
className="flex items-center justify-center text-white text-xs font-medium transition-all"
|
||||||
|
title={`${s.label}: ${formatCurrency(s.amount, true)}`}
|
||||||
|
>
|
||||||
|
{s.pct >= 10 && formatCurrency(s.amount, true)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-4 text-xs">
|
||||||
|
{pipelineByStage.stages.map(s => (
|
||||||
|
<div key={s.stage} className="flex items-center gap-1.5">
|
||||||
|
<div className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: s.color }} />
|
||||||
|
<span className="text-gray-600">{s.label}</span>
|
||||||
|
<span className="font-medium text-gray-800">{formatCurrency(s.amount, true)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Campaign Progress + District Performance side by side */}
|
||||||
|
<div className="grid grid-cols-2 gap-6 mb-6">
|
||||||
|
{/* Campaign Progress */}
|
||||||
|
<section>
|
||||||
|
<h2 className="text-xs font-bold text-gray-500 uppercase tracking-wider mb-3">Campaign Progress</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{(['High', 'Medium', 'Low'] as const).map(priority => {
|
||||||
|
const count = campaignProgress.byPriority[priority];
|
||||||
|
const total = campaignProgress.totalAccounts;
|
||||||
|
const pct = total > 0 ? (count / total) * 100 : 0;
|
||||||
|
const colors = { High: CHART_COLORS.navy, Medium: CHART_COLORS.azure, Low: '#94A3B8' };
|
||||||
|
return (
|
||||||
|
<div key={priority}>
|
||||||
|
<div className="flex justify-between text-xs mb-0.5">
|
||||||
|
<span className="text-gray-600">{priority} Priority</span>
|
||||||
|
<span className="font-medium text-gray-800">{count} accounts</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-4 bg-gray-100 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full transition-all"
|
||||||
|
style={{ width: `${pct}%`, backgroundColor: colors[priority] }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 p-3 bg-gray-50 rounded-lg">
|
||||||
|
<div className="text-xs text-gray-500">Touch Rate</div>
|
||||||
|
<div className="text-lg font-bold text-[#005C8A]">
|
||||||
|
{campaignProgress.touchedAccounts} / {campaignProgress.totalAccounts}
|
||||||
|
<span className="text-sm font-normal text-gray-500 ml-1">
|
||||||
|
({formatPercent(campaignProgress.totalAccounts > 0 ? (campaignProgress.touchedAccounts / campaignProgress.totalAccounts) * 100 : 0)})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* District Performance */}
|
||||||
|
<section>
|
||||||
|
<h2 className="text-xs font-bold text-gray-500 uppercase tracking-wider mb-3">District Performance</h2>
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200">
|
||||||
|
<th className="text-left py-1.5 font-medium text-gray-600 text-xs">District</th>
|
||||||
|
<th className="text-right py-1.5 font-medium text-gray-600 text-xs">Pipeline</th>
|
||||||
|
<th className="text-right py-1.5 font-medium text-gray-600 text-xs">Activities</th>
|
||||||
|
<th className="text-right py-1.5 font-medium text-gray-600 text-xs">Touched</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{districtPerformance.map(d => (
|
||||||
|
<tr key={d.name} className="border-b border-gray-100">
|
||||||
|
<td className="py-1.5 font-medium text-gray-700">{d.name}</td>
|
||||||
|
<td className="py-1.5 text-right text-gray-700">{formatCurrency(d.pipeline, true)}</td>
|
||||||
|
<td className="py-1.5 text-right text-gray-700">{d.activities}</td>
|
||||||
|
<td className="py-1.5 text-right text-gray-700">
|
||||||
|
{d.accountsTouched}/{d.totalAccounts}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Key Wins + Watch List side by side */}
|
||||||
|
<div className="grid grid-cols-2 gap-6 mb-4">
|
||||||
|
{/* Key Wins */}
|
||||||
|
<section>
|
||||||
|
<h2 className="text-xs font-bold text-gray-500 uppercase tracking-wider mb-3">Key Wins</h2>
|
||||||
|
{keyWins.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{keyWins.map(p => (
|
||||||
|
<div key={p.Opportunity_ID} className="flex items-center justify-between bg-green-50 rounded-lg px-3 py-2">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-gray-800">{p.Account_Name}</div>
|
||||||
|
<div className="text-xs text-gray-500">{p.Product}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-bold text-green-700">
|
||||||
|
{formatCurrency(p.Closed_Amount_USD || 0, true)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-gray-400 italic">No closed-won deals in this period</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Watch List */}
|
||||||
|
<section>
|
||||||
|
<h2 className="text-xs font-bold text-gray-500 uppercase tracking-wider mb-3">Watch List</h2>
|
||||||
|
{watchList.staleAccounts.length > 0 && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<h3 className="text-xs font-semibold text-red-600 mb-1">No Recent Activity</h3>
|
||||||
|
{watchList.staleAccounts.map(a => (
|
||||||
|
<div key={a.Account_Name} className="flex justify-between text-sm py-1 border-b border-gray-100">
|
||||||
|
<span className="text-gray-700">{a.Account_Name}</span>
|
||||||
|
<span className="text-xs text-red-500">
|
||||||
|
{a.Date_Last_Touched
|
||||||
|
? `${differenceInDays(now, parseISO(a.Date_Last_Touched))}d ago`
|
||||||
|
: 'Never'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{watchList.agingDeals.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xs font-semibold text-amber-600 mb-1">Aging in Stage</h3>
|
||||||
|
{watchList.agingDeals.map(p => (
|
||||||
|
<div key={p.Opportunity_ID} className="flex justify-between text-sm py-1 border-b border-gray-100">
|
||||||
|
<span className="text-gray-700">{p.Account_Name}</span>
|
||||||
|
<span className="text-xs text-amber-600">
|
||||||
|
{p.Stage_Entered_Date
|
||||||
|
? `${differenceInDays(now, parseISO(p.Stage_Entered_Date))}d in ${STAGE_LABELS[p.Stage] || p.Stage}`
|
||||||
|
: p.Stage}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{watchList.staleAccounts.length === 0 && watchList.agingDeals.length === 0 && (
|
||||||
|
<p className="text-sm text-green-600">No items require attention.</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="border-t border-gray-200 pt-3 mt-6 text-xs text-gray-400 text-center">
|
||||||
|
AgentMinder Campaign Command Center • Confidential
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
423
src/app/(dashboard)/reports/status/page.tsx
Normal file
423
src/app/(dashboard)/reports/status/page.tsx
Normal file
@@ -0,0 +1,423 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useData } from '@/lib/data-context';
|
||||||
|
import { formatCurrency, DISTRICT_SHORT } from '@/lib/formatters';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { parseISO, format, startOfWeek, endOfWeek, differenceInDays } from 'date-fns';
|
||||||
|
|
||||||
|
export default function WeeklyStatusReport() {
|
||||||
|
const { filtered, raw } = useData();
|
||||||
|
const { pipeline, accounts, activities } = filtered;
|
||||||
|
const [nextWeekFocus, setNextWeekFocus] = useState('');
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const weekStart = startOfWeek(now, { weekStartsOn: 1 });
|
||||||
|
const weekEnd = endOfWeek(now, { weekStartsOn: 1 });
|
||||||
|
const weekStartStr = format(weekStart, 'yyyy-MM-dd');
|
||||||
|
const weekEndStr = format(weekEnd, 'yyyy-MM-dd');
|
||||||
|
|
||||||
|
const thisWeekActivities = useMemo(() => {
|
||||||
|
return activities.filter(a => {
|
||||||
|
try {
|
||||||
|
const d = a.Activity_Date;
|
||||||
|
return d >= weekStartStr && d <= weekEndStr;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [activities, weekStartStr, weekEndStr]);
|
||||||
|
|
||||||
|
const highlights = useMemo(() => {
|
||||||
|
const activitiesCount = thisWeekActivities.length;
|
||||||
|
const accountsTouched = new Set(thisWeekActivities.map(a => a.Account_Name)).size;
|
||||||
|
const newPipeline = pipeline.filter(p => {
|
||||||
|
try {
|
||||||
|
return p.Created_Date >= weekStartStr && p.Created_Date <= weekEndStr;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}).reduce((sum, p) => sum + p.Amount_USD, 0);
|
||||||
|
|
||||||
|
return { activitiesCount, accountsTouched, newPipeline };
|
||||||
|
}, [thisWeekActivities, pipeline, weekStartStr, weekEndStr]);
|
||||||
|
|
||||||
|
const pipelineSummary = useMemo(() => {
|
||||||
|
const open = pipeline.filter(p => p.Stage !== '06-Closed Won' && p.Stage !== '07-Closed Lost');
|
||||||
|
const totalOpen = open.reduce((sum, p) => sum + p.Amount_USD, 0);
|
||||||
|
|
||||||
|
const byStage = new Map<string, { count: number; amount: number }>();
|
||||||
|
open.forEach(p => {
|
||||||
|
const entry = byStage.get(p.Stage) || { count: 0, amount: 0 };
|
||||||
|
entry.count++;
|
||||||
|
entry.amount += p.Amount_USD;
|
||||||
|
byStage.set(p.Stage, entry);
|
||||||
|
});
|
||||||
|
|
||||||
|
const byForecast = new Map<string, { count: number; amount: number }>();
|
||||||
|
open.forEach(p => {
|
||||||
|
const entry = byForecast.get(p.Forecast_Category) || { count: 0, amount: 0 };
|
||||||
|
entry.count++;
|
||||||
|
entry.amount += p.Amount_USD;
|
||||||
|
byForecast.set(p.Forecast_Category, entry);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalOpen,
|
||||||
|
byStage: Array.from(byStage.entries()).sort(([a], [b]) => a.localeCompare(b)),
|
||||||
|
byForecast: Array.from(byForecast.entries()).sort(([a], [b]) => a.localeCompare(b)),
|
||||||
|
};
|
||||||
|
}, [pipeline]);
|
||||||
|
|
||||||
|
const activitySummary = useMemo(() => {
|
||||||
|
const byType = new Map<string, number>();
|
||||||
|
const byDistrict = new Map<string, number>();
|
||||||
|
thisWeekActivities.forEach(a => {
|
||||||
|
byType.set(a.Activity_Type, (byType.get(a.Activity_Type) || 0) + 1);
|
||||||
|
const d = DISTRICT_SHORT[a.District_Name] || a.District_Name;
|
||||||
|
byDistrict.set(d, (byDistrict.get(d) || 0) + 1);
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
byType: Array.from(byType.entries()).sort(([, a], [, b]) => b - a),
|
||||||
|
byDistrict: Array.from(byDistrict.entries()).sort(([, a], [, b]) => b - a),
|
||||||
|
};
|
||||||
|
}, [thisWeekActivities]);
|
||||||
|
|
||||||
|
const topAccountsTouched = useMemo(() => {
|
||||||
|
const accountMap = new Map<string, { type: string; date: string; contact: string | null }[]>();
|
||||||
|
thisWeekActivities.forEach(a => {
|
||||||
|
if (!accountMap.has(a.Account_Name)) accountMap.set(a.Account_Name, []);
|
||||||
|
accountMap.get(a.Account_Name)!.push({
|
||||||
|
type: a.Activity_Type,
|
||||||
|
date: a.Activity_Date,
|
||||||
|
contact: a.Contact_Name,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return Array.from(accountMap.entries())
|
||||||
|
.sort(([, a], [, b]) => b.length - a.length)
|
||||||
|
.slice(0, 15);
|
||||||
|
}, [thisWeekActivities]);
|
||||||
|
|
||||||
|
const risks = useMemo(() => {
|
||||||
|
const thirtyDaysAgo = format(new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000), 'yyyy-MM-dd');
|
||||||
|
const today = format(now, 'yyyy-MM-dd');
|
||||||
|
|
||||||
|
const staleHighPriority = accounts.filter(a =>
|
||||||
|
a.Priority === 'High' &&
|
||||||
|
(!a.Date_Last_Touched || a.Date_Last_Touched < thirtyDaysAgo)
|
||||||
|
);
|
||||||
|
|
||||||
|
const pastDueDeals = pipeline
|
||||||
|
.filter(p =>
|
||||||
|
p.Stage !== '06-Closed Won' &&
|
||||||
|
p.Stage !== '07-Closed Lost' &&
|
||||||
|
p.Expected_Close_Date < today
|
||||||
|
)
|
||||||
|
.sort((a, b) => a.Expected_Close_Date.localeCompare(b.Expected_Close_Date));
|
||||||
|
|
||||||
|
return { staleHighPriority, pastDueDeals };
|
||||||
|
}, [accounts, pipeline, now]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<style>{`
|
||||||
|
@media print {
|
||||||
|
nav, aside, header, [data-sidebar], [data-topbar], .no-print {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
main {
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
background: white !important;
|
||||||
|
-webkit-print-color-adjust: exact;
|
||||||
|
print-color-adjust: exact;
|
||||||
|
}
|
||||||
|
.report-container {
|
||||||
|
max-width: 100% !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
|
||||||
|
<div className="no-print mb-4 flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => window.print()}
|
||||||
|
className="px-4 py-2 bg-[#005C8A] text-white rounded-lg text-sm font-medium hover:bg-[#004a6e] transition-colors"
|
||||||
|
>
|
||||||
|
Print / Save as PDF
|
||||||
|
</button>
|
||||||
|
<span className="text-xs text-muted">Use your browser's print dialog to save as PDF</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="report-container max-w-4xl mx-auto bg-white p-8 rounded-lg shadow-sm border border-card-border">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="border-b-2 border-[#005C8A] pb-4 mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-[#005C8A]">AgentMinder Campaign Status Report</h1>
|
||||||
|
<div className="flex justify-between items-end mt-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600">Territory: <span className="font-semibold text-gray-900">SouthEast</span></p>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Week of {format(weekStart, 'MMMM d')} – {format(weekEnd, 'MMMM d, yyyy')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-400">Generated {format(now, 'MMMM d, yyyy h:mm a')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* This Week's Highlights */}
|
||||||
|
<section className="mb-6">
|
||||||
|
<h2 className="text-base font-bold text-gray-900 uppercase tracking-wide border-b border-gray-200 pb-1 mb-3">
|
||||||
|
This Week's Highlights
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="bg-gray-50 rounded-lg p-4 text-center">
|
||||||
|
<div className="text-2xl font-bold text-[#005C8A]">{highlights.activitiesCount}</div>
|
||||||
|
<div className="text-xs text-gray-500 mt-1">Activities Logged</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-50 rounded-lg p-4 text-center">
|
||||||
|
<div className="text-2xl font-bold text-[#005C8A]">{highlights.accountsTouched}</div>
|
||||||
|
<div className="text-xs text-gray-500 mt-1">Accounts Touched</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-50 rounded-lg p-4 text-center">
|
||||||
|
<div className="text-2xl font-bold text-[#005C8A]">{formatCurrency(highlights.newPipeline, true)}</div>
|
||||||
|
<div className="text-xs text-gray-500 mt-1">New Pipeline Added</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Pipeline Summary */}
|
||||||
|
<section className="mb-6">
|
||||||
|
<h2 className="text-base font-bold text-gray-900 uppercase tracking-wide border-b border-gray-200 pb-1 mb-3">
|
||||||
|
Pipeline Summary
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-700 mb-3">
|
||||||
|
Total Open Pipeline: <span className="font-bold text-[#005C8A]">{formatCurrency(pipelineSummary.totalOpen)}</span>
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xs font-semibold text-gray-500 uppercase mb-2">By Stage</h3>
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200">
|
||||||
|
<th className="text-left py-1 font-medium text-gray-600">Stage</th>
|
||||||
|
<th className="text-right py-1 font-medium text-gray-600">Opps</th>
|
||||||
|
<th className="text-right py-1 font-medium text-gray-600">Amount</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{pipelineSummary.byStage.map(([stage, data]) => (
|
||||||
|
<tr key={stage} className="border-b border-gray-100">
|
||||||
|
<td className="py-1 text-gray-700">{stage}</td>
|
||||||
|
<td className="py-1 text-right text-gray-700">{data.count}</td>
|
||||||
|
<td className="py-1 text-right text-gray-700">{formatCurrency(data.amount, true)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xs font-semibold text-gray-500 uppercase mb-2">By Forecast Category</h3>
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200">
|
||||||
|
<th className="text-left py-1 font-medium text-gray-600">Category</th>
|
||||||
|
<th className="text-right py-1 font-medium text-gray-600">Opps</th>
|
||||||
|
<th className="text-right py-1 font-medium text-gray-600">Amount</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{pipelineSummary.byForecast.map(([cat, data]) => (
|
||||||
|
<tr key={cat} className="border-b border-gray-100">
|
||||||
|
<td className="py-1 text-gray-700">{cat}</td>
|
||||||
|
<td className="py-1 text-right text-gray-700">{data.count}</td>
|
||||||
|
<td className="py-1 text-right text-gray-700">{formatCurrency(data.amount, true)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Activity Summary */}
|
||||||
|
<section className="mb-6">
|
||||||
|
<h2 className="text-base font-bold text-gray-900 uppercase tracking-wide border-b border-gray-200 pb-1 mb-3">
|
||||||
|
Activity Summary
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xs font-semibold text-gray-500 uppercase mb-2">By Type</h3>
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<tbody>
|
||||||
|
{activitySummary.byType.map(([type, count]) => (
|
||||||
|
<tr key={type} className="border-b border-gray-100">
|
||||||
|
<td className="py-1 text-gray-700">{type}</td>
|
||||||
|
<td className="py-1 text-right font-medium text-gray-900">{count}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{activitySummary.byType.length === 0 && (
|
||||||
|
<tr><td colSpan={2} className="py-2 text-gray-400 text-center italic">No activities this week</td></tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xs font-semibold text-gray-500 uppercase mb-2">By District</h3>
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<tbody>
|
||||||
|
{activitySummary.byDistrict.map(([district, count]) => (
|
||||||
|
<tr key={district} className="border-b border-gray-100">
|
||||||
|
<td className="py-1 text-gray-700">{district}</td>
|
||||||
|
<td className="py-1 text-right font-medium text-gray-900">{count}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{activitySummary.byDistrict.length === 0 && (
|
||||||
|
<tr><td colSpan={2} className="py-2 text-gray-400 text-center italic">No activities this week</td></tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Top Accounts Touched This Week */}
|
||||||
|
<section className="mb-6">
|
||||||
|
<h2 className="text-base font-bold text-gray-900 uppercase tracking-wide border-b border-gray-200 pb-1 mb-3">
|
||||||
|
Top Accounts Touched This Week
|
||||||
|
</h2>
|
||||||
|
{topAccountsTouched.length > 0 ? (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200">
|
||||||
|
<th className="text-left py-1 font-medium text-gray-600">Account</th>
|
||||||
|
<th className="text-left py-1 font-medium text-gray-600">Activity Type</th>
|
||||||
|
<th className="text-left py-1 font-medium text-gray-600">Contact</th>
|
||||||
|
<th className="text-right py-1 font-medium text-gray-600">Date</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{topAccountsTouched.map(([account, touches]) =>
|
||||||
|
touches.map((t, i) => (
|
||||||
|
<tr key={`${account}-${i}`} className="border-b border-gray-100">
|
||||||
|
<td className="py-1 text-gray-700 font-medium">{i === 0 ? account : ''}</td>
|
||||||
|
<td className="py-1 text-gray-700">{t.type}</td>
|
||||||
|
<td className="py-1 text-gray-700">{t.contact || '—'}</td>
|
||||||
|
<td className="py-1 text-right text-gray-500">{format(parseISO(t.date), 'MMM d')}</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-gray-400 italic">No accounts touched this week</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Risks & Attention Items */}
|
||||||
|
<section className="mb-6">
|
||||||
|
<h2 className="text-base font-bold text-gray-900 uppercase tracking-wide border-b border-gray-200 pb-1 mb-3">
|
||||||
|
Risks & Attention Items
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{risks.staleHighPriority.length > 0 && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<h3 className="text-xs font-semibold text-red-600 uppercase mb-2">
|
||||||
|
High-Priority Accounts — No Touch in 30+ Days ({risks.staleHighPriority.length})
|
||||||
|
</h3>
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200">
|
||||||
|
<th className="text-left py-1 font-medium text-gray-600">Account</th>
|
||||||
|
<th className="text-left py-1 font-medium text-gray-600">Tier</th>
|
||||||
|
<th className="text-right py-1 font-medium text-gray-600">Last Touched</th>
|
||||||
|
<th className="text-right py-1 font-medium text-gray-600">Days Ago</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{risks.staleHighPriority.slice(0, 10).map(a => {
|
||||||
|
const daysAgo = a.Date_Last_Touched
|
||||||
|
? differenceInDays(now, parseISO(a.Date_Last_Touched))
|
||||||
|
: null;
|
||||||
|
return (
|
||||||
|
<tr key={a.Account_Name} className="border-b border-gray-100">
|
||||||
|
<td className="py-1 text-gray-700">{a.Account_Name}</td>
|
||||||
|
<td className="py-1 text-gray-700">{a.Tier}</td>
|
||||||
|
<td className="py-1 text-right text-gray-500">
|
||||||
|
{a.Date_Last_Touched ? format(parseISO(a.Date_Last_Touched), 'MMM d, yyyy') : 'Never'}
|
||||||
|
</td>
|
||||||
|
<td className="py-1 text-right font-medium text-red-600">
|
||||||
|
{daysAgo !== null ? daysAgo : '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{risks.pastDueDeals.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xs font-semibold text-amber-600 uppercase mb-2">
|
||||||
|
Deals with Past-Due Close Dates ({risks.pastDueDeals.length})
|
||||||
|
</h3>
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200">
|
||||||
|
<th className="text-left py-1 font-medium text-gray-600">Account</th>
|
||||||
|
<th className="text-left py-1 font-medium text-gray-600">Stage</th>
|
||||||
|
<th className="text-right py-1 font-medium text-gray-600">Amount</th>
|
||||||
|
<th className="text-right py-1 font-medium text-gray-600">Expected Close</th>
|
||||||
|
<th className="text-right py-1 font-medium text-gray-600">Days Past</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{risks.pastDueDeals.slice(0, 10).map(p => {
|
||||||
|
const daysPast = differenceInDays(now, parseISO(p.Expected_Close_Date));
|
||||||
|
return (
|
||||||
|
<tr key={p.Opportunity_ID} className="border-b border-gray-100">
|
||||||
|
<td className="py-1 text-gray-700">{p.Account_Name}</td>
|
||||||
|
<td className="py-1 text-gray-700">{p.Stage}</td>
|
||||||
|
<td className="py-1 text-right text-gray-700">{formatCurrency(p.Amount_USD, true)}</td>
|
||||||
|
<td className="py-1 text-right text-gray-500">
|
||||||
|
{format(parseISO(p.Expected_Close_Date), 'MMM d, yyyy')}
|
||||||
|
</td>
|
||||||
|
<td className="py-1 text-right font-medium text-amber-600">{daysPast}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{risks.staleHighPriority.length === 0 && risks.pastDueDeals.length === 0 && (
|
||||||
|
<p className="text-sm text-green-600">No risk items identified this week.</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Next Week Focus */}
|
||||||
|
<section className="mb-4">
|
||||||
|
<h2 className="text-base font-bold text-gray-900 uppercase tracking-wide border-b border-gray-200 pb-1 mb-3">
|
||||||
|
Next Week Focus
|
||||||
|
</h2>
|
||||||
|
<textarea
|
||||||
|
value={nextWeekFocus}
|
||||||
|
onChange={(e) => setNextWeekFocus(e.target.value)}
|
||||||
|
placeholder="Enter your priorities and focus areas for next week..."
|
||||||
|
className="w-full border border-gray-300 rounded-lg p-3 text-sm text-gray-700 min-h-[100px] resize-y focus:outline-none focus:ring-2 focus:ring-[#005C8A] focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="border-t border-gray-200 pt-3 mt-6 text-xs text-gray-400 text-center">
|
||||||
|
AgentMinder Campaign Command Center • Confidential
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
type Tab = 'import' | 'accounts' | 'pipeline' | 'activities' | 'targets';
|
type Tab = 'import' | 'accounts' | 'pipeline' | 'activities' | 'targets' | 'quality';
|
||||||
|
|
||||||
interface Stats {
|
interface Stats {
|
||||||
accounts: number;
|
accounts: number;
|
||||||
@@ -42,11 +42,14 @@ export default function AdminPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
<h1 className="text-lg font-bold">Data Admin</h1>
|
<h1 className="text-lg font-bold">Data Admin</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-4 text-sm">
|
<div className="flex gap-4 text-sm items-center">
|
||||||
<span className="bg-white/10 px-3 py-1 rounded">{stats.accounts} accounts</span>
|
<span className="bg-white/10 px-3 py-1 rounded">{stats.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.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.activities} activities</span>
|
||||||
<span className="bg-white/10 px-3 py-1 rounded">{stats.targets} targets</span>
|
<span className="bg-white/10 px-3 py-1 rounded">{stats.targets} targets</span>
|
||||||
|
<Link href="/admin/goals" className="px-3 py-1 rounded bg-[#0098C7] hover:bg-[#007ba3] text-white font-medium transition">
|
||||||
|
Goals & Progress
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -57,7 +60,7 @@ export default function AdminPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="px-6 pt-4 flex gap-1 border-b border-gray-200 bg-white">
|
<div className="px-6 pt-4 flex gap-1 border-b border-gray-200 bg-white">
|
||||||
{(['import', 'accounts', 'pipeline', 'activities', 'targets'] as Tab[]).map(t => (
|
{(['import', 'accounts', 'pipeline', 'activities', 'targets', 'quality'] as Tab[]).map(t => (
|
||||||
<button
|
<button
|
||||||
key={t}
|
key={t}
|
||||||
onClick={() => setTab(t)}
|
onClick={() => setTab(t)}
|
||||||
@@ -67,7 +70,7 @@ export default function AdminPage() {
|
|||||||
: 'text-gray-500 hover:text-gray-800 hover:bg-gray-50'
|
: 'text-gray-500 hover:text-gray-800 hover:bg-gray-50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{t === 'import' ? 'Import Data' : t}
|
{t === 'import' ? 'Import Data' : t === 'quality' ? 'Data Quality' : t}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -78,6 +81,7 @@ export default function AdminPage() {
|
|||||||
{tab === 'pipeline' && <DataTable table="pipeline" onUpdate={refreshStats} showMessage={showMessage} />}
|
{tab === 'pipeline' && <DataTable table="pipeline" onUpdate={refreshStats} showMessage={showMessage} />}
|
||||||
{tab === 'activities' && <DataTable table="activities" onUpdate={refreshStats} showMessage={showMessage} />}
|
{tab === 'activities' && <DataTable table="activities" onUpdate={refreshStats} showMessage={showMessage} />}
|
||||||
{tab === 'targets' && <DataTable table="targets" onUpdate={refreshStats} showMessage={showMessage} />}
|
{tab === 'targets' && <DataTable table="targets" onUpdate={refreshStats} showMessage={showMessage} />}
|
||||||
|
{tab === 'quality' && <DataQualityPanel onNavigate={(t: Tab) => setTab(t)} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -246,6 +250,7 @@ const FIELD_OPTIONS: Record<string, string[]> = {
|
|||||||
Outcome: ['Advanced', 'Follow-up Scheduled', 'Opportunity Created', 'No Decision Yet', 'No Response', 'No Interest', 'Disqualified'],
|
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'],
|
Implementation_Stage: ['Not Started', 'In Progress', 'Live', 'At Risk', 'Stalled'],
|
||||||
Health_Status: ['Green', 'Yellow', 'Red'],
|
Health_Status: ['Green', 'Yellow', 'Red'],
|
||||||
|
Competitors: ['Microsoft', 'Okta', 'Google', 'Sailpoint', 'AWS', 'Other'],
|
||||||
};
|
};
|
||||||
|
|
||||||
interface FieldDef { key: string; label: string; required?: boolean; type?: string; options?: string[] }
|
interface FieldDef { key: string; label: string; required?: boolean; type?: string; options?: string[] }
|
||||||
@@ -271,6 +276,7 @@ const TABLE_FIELDS: Record<string, FieldDef[]> = {
|
|||||||
{ key: 'Anchor_Contract_EAR', label: 'Anchor Contract EAR', type: 'number' },
|
{ key: 'Anchor_Contract_EAR', label: 'Anchor Contract EAR', type: 'number' },
|
||||||
{ key: 'Google_Drive_URL', label: 'Google Drive Link' },
|
{ key: 'Google_Drive_URL', label: 'Google Drive Link' },
|
||||||
{ key: 'Campaign_Artifacts_URL', label: 'Campaign Artifacts Link' },
|
{ key: 'Campaign_Artifacts_URL', label: 'Campaign Artifacts Link' },
|
||||||
|
{ key: 'Competitors', label: 'Competitors', options: FIELD_OPTIONS.Competitors },
|
||||||
],
|
],
|
||||||
pipeline: [
|
pipeline: [
|
||||||
{ key: 'Opportunity_ID', label: 'Opp ID', required: true },
|
{ key: 'Opportunity_ID', label: 'Opp ID', required: true },
|
||||||
@@ -351,7 +357,28 @@ function RecordForm({ table, record, onSave, onCancel }: {
|
|||||||
<label className="block text-xs font-medium text-gray-600 mb-1">
|
<label className="block text-xs font-medium text-gray-600 mb-1">
|
||||||
{f.label} {f.required && <span className="text-red-500">*</span>}
|
{f.label} {f.required && <span className="text-red-500">*</span>}
|
||||||
</label>
|
</label>
|
||||||
{f.options ? (
|
{f.key === 'Competitors' && f.options ? (
|
||||||
|
<div className="flex flex-wrap gap-2 pt-1">
|
||||||
|
{f.options.map(opt => {
|
||||||
|
const selected = (form[f.key] || '').split(',').filter(Boolean);
|
||||||
|
const checked = selected.includes(opt);
|
||||||
|
return (
|
||||||
|
<label key={opt} className="flex items-center gap-1.5 text-sm cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={() => {
|
||||||
|
const next = checked ? selected.filter(v => v !== opt) : [...selected, opt];
|
||||||
|
setForm(prev => ({ ...prev, [f.key]: next.join(',') }));
|
||||||
|
}}
|
||||||
|
className="rounded border-gray-300 text-[#0098C7] focus:ring-[#0098C7]/30"
|
||||||
|
/>
|
||||||
|
{opt}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : f.options ? (
|
||||||
<select
|
<select
|
||||||
value={form[f.key] || ''}
|
value={form[f.key] || ''}
|
||||||
onChange={e => setForm(prev => ({ ...prev, [f.key]: e.target.value }))}
|
onChange={e => setForm(prev => ({ ...prev, [f.key]: e.target.value }))}
|
||||||
@@ -451,7 +478,7 @@ function DataTable({ table, onUpdate, showMessage }: {
|
|||||||
|
|
||||||
const allKeys = data.length > 0 ? Object.keys(data[0]) : [];
|
const allKeys = data.length > 0 ? Object.keys(data[0]) : [];
|
||||||
const isAccounts = table === 'accounts';
|
const isAccounts = table === 'accounts';
|
||||||
const displayKeys = allKeys.filter(k => k !== 'id' && !(isAccounts && (k === 'Logo_URL' || k === 'Google_Drive_URL' || k === 'Campaign_Artifacts_URL')));
|
const displayKeys = allKeys.filter(k => k !== 'id' && !(isAccounts && (k === 'Logo_URL' || k === 'Google_Drive_URL' || k === 'Campaign_Artifacts_URL' || k === 'Competitors')));
|
||||||
const filtered = data.filter(row =>
|
const filtered = data.filter(row =>
|
||||||
search === '' || Object.values(row).some(v => String(v ?? '').toLowerCase().includes(search.toLowerCase()))
|
search === '' || Object.values(row).some(v => String(v ?? '').toLowerCase().includes(search.toLowerCase()))
|
||||||
);
|
);
|
||||||
@@ -536,3 +563,168 @@ function DataTable({ table, onUpdate, showMessage }: {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface QualityIssue {
|
||||||
|
type: string;
|
||||||
|
severity: 'high' | 'medium' | 'low';
|
||||||
|
message: string;
|
||||||
|
table: string;
|
||||||
|
record_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QualityReport {
|
||||||
|
issues: QualityIssue[];
|
||||||
|
completeness: number;
|
||||||
|
totalAccounts: number;
|
||||||
|
totalPipeline: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DataQualityPanel({ onNavigate }: { onNavigate: (tab: Tab) => void }) {
|
||||||
|
const [data, setData] = useState<QualityReport | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/phase2', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'quality.report' }),
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => { setData(d.data); setLoading(false); })
|
||||||
|
.catch(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) return <div className="text-gray-500 text-sm">Analyzing data quality...</div>;
|
||||||
|
if (!data) return <div className="text-gray-500 text-sm">Unable to load quality report.</div>;
|
||||||
|
|
||||||
|
const highCount = data.issues.filter(i => i.severity === 'high').length;
|
||||||
|
const medCount = data.issues.filter(i => i.severity === 'medium').length;
|
||||||
|
const lowCount = data.issues.filter(i => i.severity === 'low').length;
|
||||||
|
|
||||||
|
const circumference = 2 * Math.PI * 70;
|
||||||
|
const progress = (data.completeness / 100) * circumference;
|
||||||
|
const scoreColor = data.completeness >= 80 ? '#16A34A' : data.completeness >= 60 ? '#F59E0B' : '#EF4444';
|
||||||
|
|
||||||
|
const tableToTab = (table: string): Tab => {
|
||||||
|
if (table === 'accounts') return 'accounts';
|
||||||
|
if (table === 'pipeline') return 'pipeline';
|
||||||
|
if (table === 'activities') return 'activities';
|
||||||
|
return 'accounts';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-5xl space-y-6">
|
||||||
|
{/* Summary Row */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
{/* Completeness Score */}
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-6 flex flex-col items-center justify-center md:col-span-1">
|
||||||
|
<div className="relative w-40 h-40">
|
||||||
|
<svg viewBox="0 0 160 160" className="w-full h-full -rotate-90">
|
||||||
|
<circle cx="80" cy="80" r="70" fill="none" stroke="#E2E8F0" strokeWidth="10" />
|
||||||
|
<circle
|
||||||
|
cx="80" cy="80" r="70"
|
||||||
|
fill="none"
|
||||||
|
stroke={scoreColor}
|
||||||
|
strokeWidth="10"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeDasharray={`${progress} ${circumference}`}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||||
|
<span className="text-3xl font-bold" style={{ color: scoreColor }}>{Math.round(data.completeness)}%</span>
|
||||||
|
<span className="text-xs text-gray-500">Completeness</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Issue Counts */}
|
||||||
|
<div className="md:col-span-3 grid grid-cols-3 gap-4">
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<div className="w-3 h-3 rounded-full bg-red-500" />
|
||||||
|
<span className="text-xs font-medium text-gray-500 uppercase tracking-wider">High Severity</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl font-bold text-red-600">{highCount}</div>
|
||||||
|
<div className="text-xs text-gray-400 mt-1">issues need attention</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<div className="w-3 h-3 rounded-full bg-amber-500" />
|
||||||
|
<span className="text-xs font-medium text-gray-500 uppercase tracking-wider">Medium Severity</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl font-bold text-amber-600">{medCount}</div>
|
||||||
|
<div className="text-xs text-gray-400 mt-1">issues to review</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<div className="w-3 h-3 rounded-full bg-gray-400" />
|
||||||
|
<span className="text-xs font-medium text-gray-500 uppercase tracking-wider">Low Severity</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl font-bold text-gray-500">{lowCount}</div>
|
||||||
|
<div className="text-xs text-gray-400 mt-1">minor improvements</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Coverage Info */}
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-4 flex items-center gap-6 text-sm">
|
||||||
|
<span className="text-gray-500">Scanned:</span>
|
||||||
|
<span className="font-semibold">{data.totalAccounts} accounts</span>
|
||||||
|
<span className="text-gray-300">|</span>
|
||||||
|
<span className="font-semibold">{data.totalPipeline} pipeline records</span>
|
||||||
|
<span className="text-gray-300">|</span>
|
||||||
|
<span className="font-semibold">{data.issues.length} total issues</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Issues Table */}
|
||||||
|
{data.issues.length > 0 && (
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||||
|
<div className="px-5 py-3 border-b border-gray-200 bg-gray-50">
|
||||||
|
<h3 className="text-sm font-bold text-[#1B1D36]">Issues</h3>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200 bg-gray-50">
|
||||||
|
<th className="text-left px-4 py-2 font-semibold text-gray-600 w-24">Severity</th>
|
||||||
|
<th className="text-left px-4 py-2 font-semibold text-gray-600 w-24">Table</th>
|
||||||
|
<th className="text-left px-4 py-2 font-semibold text-gray-600 w-40">Record</th>
|
||||||
|
<th className="text-left px-4 py-2 font-semibold text-gray-600">Issue</th>
|
||||||
|
<th className="px-4 py-2 w-20"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(['high', 'medium', 'low'] as const).map(severity =>
|
||||||
|
data.issues
|
||||||
|
.filter(i => i.severity === severity)
|
||||||
|
.map((issue, idx) => (
|
||||||
|
<tr key={`${severity}-${idx}`} className="border-b border-gray-100 hover:bg-gray-50">
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<span className={`inline-block px-2 py-0.5 rounded-full text-[10px] font-semibold text-white ${
|
||||||
|
severity === 'high' ? 'bg-red-500' : severity === 'medium' ? 'bg-amber-500' : 'bg-gray-400'
|
||||||
|
}`}>
|
||||||
|
{severity}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-gray-600 capitalize">{issue.table}</td>
|
||||||
|
<td className="px-4 py-2 text-gray-700 font-medium truncate max-w-[160px]" title={issue.record_id}>{issue.record_id}</td>
|
||||||
|
<td className="px-4 py-2 text-gray-600">{issue.message}</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<button
|
||||||
|
onClick={() => onNavigate(tableToTab(issue.table))}
|
||||||
|
className="px-2.5 py-1 text-xs font-medium text-[#0098C7] hover:text-[#007ba3] border border-[#0098C7]/30 rounded-lg hover:bg-[#0098C7]/5 transition"
|
||||||
|
>
|
||||||
|
Fix
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
3
src/app/api/auth/[...nextauth]/route.ts
Normal file
3
src/app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { handlers } from '@/auth';
|
||||||
|
|
||||||
|
export const { GET, POST } = handlers;
|
||||||
101
src/app/api/phase2/route.ts
Normal file
101
src/app/api/phase2/route.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import {
|
||||||
|
getAccountNotes, addAccountNote, deleteAccountNote, updateAccountNote,
|
||||||
|
getContacts, getAllContacts, upsertContact, deleteContact,
|
||||||
|
getSnapshots, captureSnapshot,
|
||||||
|
getPipelineSnapshots,
|
||||||
|
getPlaybooks, upsertPlaybook, getPlaybookProgress, upsertPlaybookProgress,
|
||||||
|
getDistrictTargets, upsertDistrictTarget,
|
||||||
|
getDataQualityReport,
|
||||||
|
getDashboardData,
|
||||||
|
} from '@/lib/db';
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
const { action } = body;
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
// Account Notes
|
||||||
|
case 'notes.list':
|
||||||
|
return NextResponse.json({ data: getAccountNotes(body.Account_Name) });
|
||||||
|
case 'notes.add':
|
||||||
|
return NextResponse.json({ data: addAccountNote(body) });
|
||||||
|
case 'notes.update':
|
||||||
|
updateAccountNote(body.id, body);
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
case 'notes.delete':
|
||||||
|
deleteAccountNote(body.id);
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
|
||||||
|
// Contacts
|
||||||
|
case 'contacts.list':
|
||||||
|
return NextResponse.json({ data: body.Account_Name ? getContacts(body.Account_Name) : getAllContacts() });
|
||||||
|
case 'contacts.upsert':
|
||||||
|
return NextResponse.json({ data: upsertContact(body) });
|
||||||
|
case 'contacts.delete':
|
||||||
|
deleteContact(body.id);
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
|
||||||
|
// Snapshots
|
||||||
|
case 'snapshots.list':
|
||||||
|
return NextResponse.json({ data: getSnapshots(body.period_type) });
|
||||||
|
case 'snapshots.capture':
|
||||||
|
return NextResponse.json({ data: captureSnapshot(body.period_type) });
|
||||||
|
case 'snapshots.pipeline':
|
||||||
|
return NextResponse.json({ data: getPipelineSnapshots(body.date) });
|
||||||
|
|
||||||
|
// Playbooks
|
||||||
|
case 'playbooks.list':
|
||||||
|
return NextResponse.json({ data: getPlaybooks() });
|
||||||
|
case 'playbooks.upsert':
|
||||||
|
upsertPlaybook(body);
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
case 'playbooks.progress.list':
|
||||||
|
return NextResponse.json({ data: getPlaybookProgress(body.Account_Name === '__all__' ? undefined : body.Account_Name) });
|
||||||
|
case 'playbooks.progress.upsert':
|
||||||
|
upsertPlaybookProgress(body);
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
|
||||||
|
// District Targets
|
||||||
|
case 'targets.list':
|
||||||
|
return NextResponse.json({ data: getDistrictTargets(body.quarter) });
|
||||||
|
case 'targets.upsert':
|
||||||
|
upsertDistrictTarget(body);
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
|
||||||
|
// Data Quality
|
||||||
|
case 'quality.report':
|
||||||
|
return NextResponse.json({ data: getDataQualityReport() });
|
||||||
|
|
||||||
|
// Export
|
||||||
|
case 'export': {
|
||||||
|
const data = getDashboardData();
|
||||||
|
const tableData = {
|
||||||
|
accounts: data.accounts,
|
||||||
|
pipeline: data.pipeline,
|
||||||
|
activities: data.activities,
|
||||||
|
}[body.table as string];
|
||||||
|
if (!tableData || !Array.isArray(tableData) || tableData.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'No data' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const headers = Object.keys(tableData[0] as object);
|
||||||
|
const csv = [headers.join(','), ...(tableData as unknown as Record<string, unknown>[]).map((row) =>
|
||||||
|
headers.map(h => {
|
||||||
|
const v = String(row[h] ?? '');
|
||||||
|
return v.includes(',') || v.includes('"') || v.includes('\n') ? `"${v.replace(/"/g, '""')}"` : v;
|
||||||
|
}).join(',')
|
||||||
|
)].join('\n');
|
||||||
|
return new NextResponse(csv, {
|
||||||
|
headers: { 'Content-Type': 'text/csv', 'Content-Disposition': `attachment; filename="${body.table}-export.csv"` },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return NextResponse.json({ error: 'Unknown action' }, { status: 400 });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Phase2 API]', error);
|
||||||
|
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
39
src/app/auth/error/page.tsx
Normal file
39
src/app/auth/error/page.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
import { Suspense } from 'react';
|
||||||
|
|
||||||
|
function ErrorContent() {
|
||||||
|
const params = useSearchParams();
|
||||||
|
const error = params.get('error');
|
||||||
|
|
||||||
|
const messages: Record<string, string> = {
|
||||||
|
AccessDenied: 'Only @broadcom.com accounts are allowed.',
|
||||||
|
Configuration: 'Auth is not configured yet. Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET in .env.local.',
|
||||||
|
Default: 'An authentication error occurred.',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-[#F4F6F8]">
|
||||||
|
<div className="w-full max-w-sm bg-white rounded-2xl shadow-xl border border-gray-100 p-8 text-center">
|
||||||
|
<div className="w-14 h-14 mx-auto mb-4 rounded-full bg-red-50 flex items-center justify-center">
|
||||||
|
<svg className="w-7 h-7 text-red-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="10" /><line x1="15" y1="9" x2="9" y2="15" /><line x1="9" y1="9" x2="15" y2="15" /></svg>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-lg font-bold text-[#1B1D36] mb-2">Access Denied</h1>
|
||||||
|
<p className="text-sm text-gray-500 mb-6">{messages[error || ''] || messages.Default}</p>
|
||||||
|
<Link href="/auth/signin" className="inline-block px-6 py-2.5 bg-[#0098C7] text-white text-sm font-semibold rounded-xl hover:bg-[#007ba3] transition">
|
||||||
|
Try Again
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AuthErrorPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div className="min-h-screen flex items-center justify-center">Loading...</div>}>
|
||||||
|
<ErrorContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
src/app/auth/signin/page.tsx
Normal file
38
src/app/auth/signin/page.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { signIn } from 'next-auth/react';
|
||||||
|
|
||||||
|
export default function SignInPage() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-[#F4F6F8]">
|
||||||
|
<div className="w-full max-w-sm">
|
||||||
|
<div className="bg-white rounded-2xl shadow-xl border border-gray-100 p-8 text-center">
|
||||||
|
<div className="w-16 h-16 mx-auto mb-4 rounded-xl bg-[#1B1D36] flex items-center justify-center">
|
||||||
|
<svg className="w-8 h-8 text-[#0098C7]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="4" rx="1" /><rect x="14" y="10" width="7" height="11" rx="1" /><rect x="3" y="13" width="7" height="8" rx="1" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-xl font-bold text-[#1B1D36] mb-1">AgentMinder</h1>
|
||||||
|
<p className="text-sm text-gray-500 mb-6">Campaign Command Center</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => signIn('google', { callbackUrl: '/' })}
|
||||||
|
className="w-full flex items-center justify-center gap-3 px-4 py-3 bg-white border border-gray-300 rounded-xl text-sm font-medium text-gray-700 hover:bg-gray-50 hover:border-gray-400 transition shadow-sm"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" viewBox="0 0 24 24">
|
||||||
|
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4"/>
|
||||||
|
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
|
||||||
|
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
|
||||||
|
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
|
||||||
|
</svg>
|
||||||
|
Sign in with Google
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p className="text-[11px] text-gray-400 mt-4">
|
||||||
|
@broadcom.com accounts only
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
src/auth.ts
Normal file
38
src/auth.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import NextAuth from 'next-auth';
|
||||||
|
import Google from 'next-auth/providers/google';
|
||||||
|
|
||||||
|
const ALLOWED_DOMAINS = ['broadcom.com'];
|
||||||
|
const ADMIN_EMAILS = ['christopher.olson@broadcom.com'];
|
||||||
|
|
||||||
|
export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||||
|
providers: [
|
||||||
|
Google({
|
||||||
|
clientId: process.env.GOOGLE_CLIENT_ID,
|
||||||
|
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
callbacks: {
|
||||||
|
async signIn({ profile }) {
|
||||||
|
const email = profile?.email;
|
||||||
|
if (!email) return false;
|
||||||
|
const domain = email.split('@')[1];
|
||||||
|
return ALLOWED_DOMAINS.includes(domain);
|
||||||
|
},
|
||||||
|
async session({ session }) {
|
||||||
|
if (session.user?.email) {
|
||||||
|
(session as unknown as Record<string, unknown>).role = ADMIN_EMAILS.includes(session.user.email) ? 'admin' : 'readonly';
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
},
|
||||||
|
async jwt({ token, profile }) {
|
||||||
|
if (profile?.email) {
|
||||||
|
token.role = ADMIN_EMAILS.includes(profile.email) ? 'admin' : 'readonly';
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pages: {
|
||||||
|
signIn: '/auth/signin',
|
||||||
|
error: '/auth/error',
|
||||||
|
},
|
||||||
|
});
|
||||||
272
src/components/account/AccountNotes.tsx
Normal file
272
src/components/account/AccountNotes.tsx
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
|
interface AccountNote {
|
||||||
|
id: number;
|
||||||
|
Account_Name: string;
|
||||||
|
note_text: string;
|
||||||
|
next_action?: string;
|
||||||
|
is_pinned: number;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AccountNotesProps {
|
||||||
|
accountName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AccountNotes({ accountName }: AccountNotesProps) {
|
||||||
|
const [notes, setNotes] = useState<AccountNote[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [noteText, setNoteText] = useState('');
|
||||||
|
const [nextAction, setNextAction] = useState('');
|
||||||
|
const [isPinned, setIsPinned] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
|
const [editText, setEditText] = useState('');
|
||||||
|
const [editNextAction, setEditNextAction] = useState('');
|
||||||
|
|
||||||
|
const fetchNotes = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/phase2', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'notes.list', Account_Name: accountName }),
|
||||||
|
});
|
||||||
|
const json = await res.json();
|
||||||
|
setNotes(json.data ?? []);
|
||||||
|
} catch {
|
||||||
|
console.error('Failed to fetch notes');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [accountName]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchNotes();
|
||||||
|
}, [fetchNotes]);
|
||||||
|
|
||||||
|
const handleAdd = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!noteText.trim()) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await fetch('/api/phase2', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: 'notes.add',
|
||||||
|
Account_Name: accountName,
|
||||||
|
note_text: noteText.trim(),
|
||||||
|
next_action: nextAction.trim() || undefined,
|
||||||
|
is_pinned: isPinned ? 1 : 0,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
setNoteText('');
|
||||||
|
setNextAction('');
|
||||||
|
setIsPinned(false);
|
||||||
|
await fetchNotes();
|
||||||
|
} catch {
|
||||||
|
console.error('Failed to add note');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: number) => {
|
||||||
|
try {
|
||||||
|
await fetch('/api/phase2', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'notes.delete', id }),
|
||||||
|
});
|
||||||
|
await fetchNotes();
|
||||||
|
} catch {
|
||||||
|
console.error('Failed to delete note');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTogglePin = async (note: AccountNote) => {
|
||||||
|
try {
|
||||||
|
await fetch('/api/phase2', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'notes.update', id: note.id, is_pinned: note.is_pinned ? 0 : 1 }),
|
||||||
|
});
|
||||||
|
await fetchNotes();
|
||||||
|
} catch {
|
||||||
|
console.error('Failed to toggle pin');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startEdit = (note: AccountNote) => {
|
||||||
|
setEditingId(note.id);
|
||||||
|
setEditText(note.note_text);
|
||||||
|
setEditNextAction(note.next_action ?? '');
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelEdit = () => {
|
||||||
|
setEditingId(null);
|
||||||
|
setEditText('');
|
||||||
|
setEditNextAction('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdate = async (id: number) => {
|
||||||
|
try {
|
||||||
|
await fetch('/api/phase2', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: 'notes.update',
|
||||||
|
id,
|
||||||
|
note_text: editText.trim(),
|
||||||
|
next_action: editNextAction.trim() || undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
cancelEdit();
|
||||||
|
await fetchNotes();
|
||||||
|
} catch {
|
||||||
|
console.error('Failed to update note');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const sorted = [...notes].sort((a, b) => {
|
||||||
|
if (a.is_pinned !== b.is_pinned) return b.is_pinned - a.is_pinned;
|
||||||
|
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-card-bg rounded-xl border border-card-border p-4">
|
||||||
|
<h3 className="text-sm font-semibold text-foreground mb-3">Notes</h3>
|
||||||
|
|
||||||
|
{/* Add Note Form */}
|
||||||
|
<form onSubmit={handleAdd} className="mb-4 space-y-2">
|
||||||
|
<textarea
|
||||||
|
value={noteText}
|
||||||
|
onChange={(e) => setNoteText(e.target.value)}
|
||||||
|
placeholder="Add a note..."
|
||||||
|
rows={2}
|
||||||
|
className="w-full rounded-lg border border-card-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted focus:outline-none focus:ring-1 focus:ring-[#0098C7] resize-none"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={nextAction}
|
||||||
|
onChange={(e) => setNextAction(e.target.value)}
|
||||||
|
placeholder="Next action (optional)"
|
||||||
|
className="w-full rounded-lg border border-card-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted focus:outline-none focus:ring-1 focus:ring-[#0098C7]"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="flex items-center gap-1.5 text-xs text-muted cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isPinned}
|
||||||
|
onChange={(e) => setIsPinned(e.target.checked)}
|
||||||
|
className="rounded border-card-border accent-[#0098C7]"
|
||||||
|
/>
|
||||||
|
Pin this note
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting || !noteText.trim()}
|
||||||
|
className="px-3 py-1.5 text-xs font-medium text-white bg-[#0098C7] rounded-lg hover:bg-[#0098C7]/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
{submitting ? 'Adding...' : 'Add Note'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Notes List */}
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-xs text-muted">Loading notes...</p>
|
||||||
|
) : sorted.length === 0 ? (
|
||||||
|
<p className="text-xs text-muted">No notes yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{sorted.map((note) => (
|
||||||
|
<div key={note.id} className="rounded-lg border border-card-border p-3 bg-background">
|
||||||
|
{editingId === note.id ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<textarea
|
||||||
|
value={editText}
|
||||||
|
onChange={(e) => setEditText(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
className="w-full rounded-lg border border-card-border bg-card-bg px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-[#0098C7] resize-none"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editNextAction}
|
||||||
|
onChange={(e) => setEditNextAction(e.target.value)}
|
||||||
|
placeholder="Next action (optional)"
|
||||||
|
className="w-full rounded-lg border border-card-border bg-card-bg px-3 py-2 text-sm text-foreground placeholder:text-muted focus:outline-none focus:ring-1 focus:ring-[#0098C7]"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<button onClick={cancelEdit} className="px-2 py-1 text-xs text-muted hover:text-foreground transition-colors">Cancel</button>
|
||||||
|
<button onClick={() => handleUpdate(note.id)} className="px-2 py-1 text-xs font-medium text-white bg-[#0098C7] rounded-lg hover:bg-[#0098C7]/90 transition-colors">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-1.5 mb-1">
|
||||||
|
{note.is_pinned === 1 && (
|
||||||
|
<svg className="w-3 h-3 text-[#0098C7] flex-shrink-0" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M16 2l-4 4-4-2-4 4 2 4-4 4h6v8l2 2 2-2v-8h6l-4-4 2-4-4-4z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-muted">{formatDate(note.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-foreground whitespace-pre-wrap">{note.note_text}</p>
|
||||||
|
{note.next_action && (
|
||||||
|
<div className="mt-1.5 flex items-center gap-1">
|
||||||
|
<span className="text-xs font-medium text-[#0098C7]">Next:</span>
|
||||||
|
<span className="text-xs text-muted">{note.next_action}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => handleTogglePin(note)}
|
||||||
|
title={note.is_pinned ? 'Unpin' : 'Pin'}
|
||||||
|
className="p-1 rounded hover:bg-card-bg text-muted hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill={note.is_pinned ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M16 2l-4 4-4-2-4 4 2 4-4 4h6v8l2 2 2-2v-8h6l-4-4 2-4-4-4z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => startEdit(note)}
|
||||||
|
title="Edit"
|
||||||
|
className="p-1 rounded hover:bg-card-bg text-muted hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7" />
|
||||||
|
<path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(note.id)}
|
||||||
|
title="Delete"
|
||||||
|
className="p-1 rounded hover:bg-card-bg text-muted hover:text-danger transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<polyline points="3 6 5 6 21 6" />
|
||||||
|
<path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6m5 0V4a1 1 0 011-1h2a1 1 0 011 1v2" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
244
src/components/account/ContactMap.tsx
Normal file
244
src/components/account/ContactMap.tsx
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
|
const ROLES = ['Economic Buyer', 'Champion', 'Technical Evaluator', 'End User', 'Procurement', 'Executive Sponsor'] as const;
|
||||||
|
const SENTIMENTS = ['Champion', 'Supportive', 'Neutral', 'Skeptical', 'Blocker'] as const;
|
||||||
|
|
||||||
|
type Role = (typeof ROLES)[number];
|
||||||
|
type Sentiment = (typeof SENTIMENTS)[number];
|
||||||
|
|
||||||
|
interface Contact {
|
||||||
|
id: number;
|
||||||
|
Account_Name: string;
|
||||||
|
Contact_Name: string;
|
||||||
|
Role: Role;
|
||||||
|
Sentiment: Sentiment;
|
||||||
|
Email?: string;
|
||||||
|
LinkedIn_URL?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ContactMapProps {
|
||||||
|
accountName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SENTIMENT_COLORS: Record<Sentiment, string> = {
|
||||||
|
Champion: 'bg-emerald-500',
|
||||||
|
Supportive: 'bg-emerald-400',
|
||||||
|
Neutral: 'bg-yellow-400',
|
||||||
|
Skeptical: 'bg-red-400',
|
||||||
|
Blocker: 'bg-red-600',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ROLE_BADGE_CLASSES = 'inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-[#1B1D36]/10 text-[#1B1D36] dark:bg-[#0098C7]/15 dark:text-[#0098C7]';
|
||||||
|
|
||||||
|
const emptyForm = { Contact_Name: '', Role: 'Technical Evaluator' as Role, Sentiment: 'Neutral' as Sentiment, Email: '', LinkedIn_URL: '' };
|
||||||
|
|
||||||
|
export function ContactMap({ accountName }: ContactMapProps) {
|
||||||
|
const [contacts, setContacts] = useState<Contact[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
|
const [form, setForm] = useState(emptyForm);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const fetchContacts = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/phase2', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'contacts.list', Account_Name: accountName }),
|
||||||
|
});
|
||||||
|
const json = await res.json();
|
||||||
|
setContacts(json.data ?? []);
|
||||||
|
} catch {
|
||||||
|
console.error('Failed to fetch contacts');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [accountName]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchContacts();
|
||||||
|
}, [fetchContacts]);
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setForm(emptyForm);
|
||||||
|
setShowForm(false);
|
||||||
|
setEditingId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!form.Contact_Name.trim()) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await fetch('/api/phase2', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: 'contacts.upsert',
|
||||||
|
Account_Name: accountName,
|
||||||
|
Contact_Name: form.Contact_Name.trim(),
|
||||||
|
Role: form.Role,
|
||||||
|
Sentiment: form.Sentiment,
|
||||||
|
Email: form.Email.trim() || undefined,
|
||||||
|
LinkedIn_URL: form.LinkedIn_URL.trim() || undefined,
|
||||||
|
...(editingId != null ? { id: editingId } : {}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
resetForm();
|
||||||
|
await fetchContacts();
|
||||||
|
} catch {
|
||||||
|
console.error('Failed to save contact');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (contact: Contact) => {
|
||||||
|
setEditingId(contact.id);
|
||||||
|
setForm({
|
||||||
|
Contact_Name: contact.Contact_Name,
|
||||||
|
Role: contact.Role,
|
||||||
|
Sentiment: contact.Sentiment,
|
||||||
|
Email: contact.Email ?? '',
|
||||||
|
LinkedIn_URL: contact.LinkedIn_URL ?? '',
|
||||||
|
});
|
||||||
|
setShowForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: number) => {
|
||||||
|
try {
|
||||||
|
await fetch('/api/phase2', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'contacts.delete', id }),
|
||||||
|
});
|
||||||
|
await fetchContacts();
|
||||||
|
} catch {
|
||||||
|
console.error('Failed to delete contact');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectClasses = 'w-full rounded-lg border border-card-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-[#0098C7]';
|
||||||
|
const inputClasses = selectClasses;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-card-bg rounded-xl border border-card-border p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h3 className="text-sm font-semibold text-foreground">Stakeholder Map</h3>
|
||||||
|
{!showForm && (
|
||||||
|
<button
|
||||||
|
onClick={() => { resetForm(); setShowForm(true); }}
|
||||||
|
className="px-2.5 py-1 text-xs font-medium text-white bg-[#0098C7] rounded-lg hover:bg-[#0098C7]/90 transition-colors"
|
||||||
|
>
|
||||||
|
Add Contact
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Inline Form */}
|
||||||
|
{showForm && (
|
||||||
|
<form onSubmit={handleSubmit} className="mb-4 space-y-2 rounded-lg border border-card-border p-3 bg-background">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.Contact_Name}
|
||||||
|
onChange={(e) => setForm({ ...form, Contact_Name: e.target.value })}
|
||||||
|
placeholder="Contact name"
|
||||||
|
className={inputClasses}
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<select value={form.Role} onChange={(e) => setForm({ ...form, Role: e.target.value as Role })} className={selectClasses}>
|
||||||
|
{ROLES.map((r) => <option key={r} value={r}>{r}</option>)}
|
||||||
|
</select>
|
||||||
|
<select value={form.Sentiment} onChange={(e) => setForm({ ...form, Sentiment: e.target.value as Sentiment })} className={selectClasses}>
|
||||||
|
{SENTIMENTS.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={form.Email}
|
||||||
|
onChange={(e) => setForm({ ...form, Email: e.target.value })}
|
||||||
|
placeholder="Email (optional)"
|
||||||
|
className={inputClasses}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={form.LinkedIn_URL}
|
||||||
|
onChange={(e) => setForm({ ...form, LinkedIn_URL: e.target.value })}
|
||||||
|
placeholder="LinkedIn URL (optional)"
|
||||||
|
className={inputClasses}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<button type="button" onClick={resetForm} className="px-2 py-1 text-xs text-muted hover:text-foreground transition-colors">Cancel</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting || !form.Contact_Name.trim()}
|
||||||
|
className="px-3 py-1.5 text-xs font-medium text-white bg-[#0098C7] rounded-lg hover:bg-[#0098C7]/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
{submitting ? 'Saving...' : editingId != null ? 'Update' : 'Add'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Contacts List */}
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-xs text-muted">Loading contacts...</p>
|
||||||
|
) : contacts.length === 0 ? (
|
||||||
|
<p className="text-xs text-muted">No stakeholders added yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{contacts.map((contact) => (
|
||||||
|
<div key={contact.id} className="rounded-lg border border-card-border p-3 bg-background">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${SENTIMENT_COLORS[contact.Sentiment]}`} title={contact.Sentiment} />
|
||||||
|
<span className="text-sm font-medium text-foreground truncate">{contact.Contact_Name}</span>
|
||||||
|
<span className={ROLE_BADGE_CLASSES}>{contact.Role}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 ml-4">
|
||||||
|
{contact.Email && (
|
||||||
|
<a href={`mailto:${contact.Email}`} className="text-xs text-[#0098C7] hover:underline truncate">{contact.Email}</a>
|
||||||
|
)}
|
||||||
|
{contact.LinkedIn_URL && (
|
||||||
|
<a href={contact.LinkedIn_URL} target="_blank" rel="noopener noreferrer" className="text-muted hover:text-foreground transition-colors" title="LinkedIn">
|
||||||
|
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.064 2.064 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => handleEdit(contact)}
|
||||||
|
title="Edit"
|
||||||
|
className="p-1 rounded hover:bg-card-bg text-muted hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7" />
|
||||||
|
<path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(contact.id)}
|
||||||
|
title="Delete"
|
||||||
|
className="p-1 rounded hover:bg-card-bg text-muted hover:text-danger transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<polyline points="3 6 5 6 21 6" />
|
||||||
|
<path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6m5 0V4a1 1 0 011-1h2a1 1 0 011 1v2" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
2
src/components/account/index.ts
Normal file
2
src/components/account/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { AccountNotes } from './AccountNotes';
|
||||||
|
export { ContactMap } from './ContactMap';
|
||||||
@@ -4,6 +4,7 @@ import { ReactNode } from 'react';
|
|||||||
import { DataProvider } from '@/lib/data-context';
|
import { DataProvider } from '@/lib/data-context';
|
||||||
import { Sidebar } from './Sidebar';
|
import { Sidebar } from './Sidebar';
|
||||||
import { TopBar } from './TopBar';
|
import { TopBar } from './TopBar';
|
||||||
|
import { MobileActivityFAB } from '@/components/ui/MobileActivityFAB';
|
||||||
import { DashboardData, DashboardConfig } from '@/types/data';
|
import { DashboardData, DashboardConfig } from '@/types/data';
|
||||||
|
|
||||||
export function DashboardShell({ children, initialData, config }: { children: ReactNode; initialData: DashboardData; config: DashboardConfig }) {
|
export function DashboardShell({ children, initialData, config }: { children: ReactNode; initialData: DashboardData; config: DashboardConfig }) {
|
||||||
@@ -17,6 +18,7 @@ export function DashboardShell({ children, initialData, config }: { children: Re
|
|||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
<MobileActivityFAB />
|
||||||
</div>
|
</div>
|
||||||
</DataProvider>
|
</DataProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ const navItems = [
|
|||||||
{ href: '/activity', label: 'Activities', icon: ActivityIcon, shortLabel: 'Activities' },
|
{ href: '/activity', label: 'Activities', icon: ActivityIcon, shortLabel: 'Activities' },
|
||||||
{ href: '/pipeline', label: 'Opportunities', icon: PipelineIcon, shortLabel: 'Opps' },
|
{ href: '/pipeline', label: 'Opportunities', icon: PipelineIcon, shortLabel: 'Opps' },
|
||||||
{ href: '/implementation', label: 'Implementation', icon: ImplementIcon, shortLabel: 'Implement' },
|
{ href: '/implementation', label: 'Implementation', icon: ImplementIcon, shortLabel: 'Implement' },
|
||||||
|
{ href: '/goals', label: 'Goals & Targets', icon: GoalsIcon, shortLabel: 'Goals' },
|
||||||
|
{ href: '/playbooks', label: 'Playbooks', icon: PlaybookIcon, shortLabel: 'Plays' },
|
||||||
|
{ href: '/reports/status', label: 'Weekly Report', icon: ReportsIcon, shortLabel: 'Report' },
|
||||||
|
{ href: '/reports/executive', label: 'Exec Summary', icon: ExecIcon, shortLabel: 'Summary' },
|
||||||
];
|
];
|
||||||
|
|
||||||
function DashboardIcon({ className }: { className?: string }) {
|
function DashboardIcon({ className }: { className?: string }) {
|
||||||
@@ -44,6 +48,38 @@ function ImplementIcon({ className }: { className?: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PlaybookIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" /><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GoalsIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="10" /><circle cx="12" cy="12" r="6" /><circle cx="12" cy="12" r="2" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReportsIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><polyline points="14 2 14 8 20 8" /><line x1="16" y1="13" x2="8" y2="13" /><line x1="16" y1="17" x2="8" y2="17" /><polyline points="10 9 9 9 8 9" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExecIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" /><line x1="8" y1="21" x2="16" y2="21" /><line x1="12" y1="17" x2="12" y2="21" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function AccountsIcon({ className }: { className?: string }) {
|
function AccountsIcon({ className }: { className?: string }) {
|
||||||
return (
|
return (
|
||||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
|||||||
34
src/components/ui/ExportButton.tsx
Normal file
34
src/components/ui/ExportButton.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
'use client';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
export function ExportButton({ table, label }: { table: string; label?: string }) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleExport = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/phase2', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'export', table }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Export failed');
|
||||||
|
const blob = await res.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${table}-export.csv`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button onClick={handleExport} disabled={loading} className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-muted hover:text-foreground border border-card-border rounded-lg hover:bg-gray-50 transition disabled:opacity-50">
|
||||||
|
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||||
|
{loading ? 'Exporting...' : (label || 'Export CSV')}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
221
src/components/ui/MobileActivityFAB.tsx
Normal file
221
src/components/ui/MobileActivityFAB.tsx
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
const ACTIVITY_TYPES = ['Discovery', 'Demo', 'Exec Meeting', 'QBR Attach', 'Launch Briefing', 'Workshop', 'Email', 'Call'];
|
||||||
|
const PLAYS = ['Attach', 'Renewal-Trigger', 'Referral', 'Inbound', 'Whitespace'];
|
||||||
|
const CHANNELS = ['In Person', 'Video Call', 'Phone', 'Email', 'LinkedIn'];
|
||||||
|
const OUTCOMES = ['Advanced', 'Follow-up Scheduled', 'Opportunity Created', 'No Decision Yet', 'No Response', 'No Interest'];
|
||||||
|
|
||||||
|
export function MobileActivityFAB() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [accounts, setAccounts] = useState<string[]>([]);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
Account_Name: '',
|
||||||
|
Activity_Type: '',
|
||||||
|
Activity_Date: new Date().toISOString().split('T')[0],
|
||||||
|
District_Name: '',
|
||||||
|
Contact_Name: '',
|
||||||
|
Notes: '',
|
||||||
|
Play: '',
|
||||||
|
Channel: '',
|
||||||
|
Outcome: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && accounts.length === 0) {
|
||||||
|
fetch('/api/admin', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'list', table: 'accounts' }) })
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => {
|
||||||
|
const names = (d.data || []).map((a: { Account_Name: string; District_Name: string }) => a.Account_Name).sort();
|
||||||
|
setAccounts(names);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [open, accounts.length]);
|
||||||
|
|
||||||
|
const handleAccountChange = async (name: string) => {
|
||||||
|
setForm(f => ({ ...f, Account_Name: name }));
|
||||||
|
if (name) {
|
||||||
|
const res = await fetch('/api/admin', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'list', table: 'accounts' }) });
|
||||||
|
const data = await res.json();
|
||||||
|
const acct = (data.data || []).find((a: { Account_Name: string; District_Name: string }) => a.Account_Name === name);
|
||||||
|
if (acct) setForm(f => ({ ...f, District_Name: acct.District_Name }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!form.Account_Name || !form.Activity_Type) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await fetch('/api/admin', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'upsert', table: 'activities', record: form }),
|
||||||
|
});
|
||||||
|
setSuccess(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
setSuccess(false);
|
||||||
|
setOpen(false);
|
||||||
|
setForm({
|
||||||
|
Account_Name: '', Activity_Type: '', Activity_Date: new Date().toISOString().split('T')[0],
|
||||||
|
District_Name: '', Contact_Name: '', Notes: '', Play: '', Channel: '', Outcome: '',
|
||||||
|
});
|
||||||
|
}, 1500);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* FAB Button - only on mobile */}
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
className="lg:hidden fixed bottom-20 right-4 z-40 w-14 h-14 rounded-full bg-[#0098C7] text-white shadow-lg hover:bg-[#007ba3] transition flex items-center justify-center"
|
||||||
|
aria-label="Log Activity"
|
||||||
|
>
|
||||||
|
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"><line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" /></svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Quick Entry Sheet */}
|
||||||
|
{open && (
|
||||||
|
<div className="fixed inset-0 z-50 flex flex-col" onClick={() => setOpen(false)}>
|
||||||
|
<div className="absolute inset-0 bg-black/40" />
|
||||||
|
<div
|
||||||
|
className="relative mt-auto bg-white rounded-t-2xl shadow-2xl max-h-[85vh] overflow-y-auto animate-in slide-in-from-bottom"
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="sticky top-0 bg-white rounded-t-2xl border-b border-gray-200 px-5 py-4 flex items-center justify-between z-10">
|
||||||
|
<h2 className="text-base font-bold text-[#1B1D36]">Log Activity</h2>
|
||||||
|
<button onClick={() => setOpen(false)} className="p-1 rounded-lg hover:bg-gray-100">
|
||||||
|
<svg className="w-5 h-5 text-gray-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{success ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 px-5">
|
||||||
|
<div className="w-16 h-16 rounded-full bg-green-100 flex items-center justify-center mb-4">
|
||||||
|
<svg className="w-8 h-8 text-green-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"><polyline points="20 6 9 17 4 12" /></svg>
|
||||||
|
</div>
|
||||||
|
<div className="text-lg font-bold text-[#1B1D36]">Activity Logged</div>
|
||||||
|
<div className="text-sm text-gray-500 mt-1">Nice work! Keep the momentum going.</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="px-5 py-4 space-y-4">
|
||||||
|
{/* Account - large touch target */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-gray-600 mb-1.5">Account *</label>
|
||||||
|
<select
|
||||||
|
value={form.Account_Name}
|
||||||
|
onChange={e => handleAccountChange(e.target.value)}
|
||||||
|
className="w-full px-3 py-3 border border-gray-300 rounded-xl text-sm bg-white focus:ring-2 focus:ring-[#0098C7]/30 focus:border-[#0098C7]"
|
||||||
|
>
|
||||||
|
<option value="">Select account...</option>
|
||||||
|
{accounts.map(a => <option key={a} value={a}>{a}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Activity Type - large buttons */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-gray-600 mb-1.5">Activity Type *</label>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{ACTIVITY_TYPES.map(type => (
|
||||||
|
<button
|
||||||
|
key={type}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setForm(f => ({ ...f, Activity_Type: type }))}
|
||||||
|
className={`py-2.5 px-3 rounded-xl text-xs font-medium border transition ${
|
||||||
|
form.Activity_Type === type
|
||||||
|
? 'border-[#0098C7] bg-[#0098C7]/10 text-[#0098C7]'
|
||||||
|
: 'border-gray-200 text-gray-600 hover:border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{type}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-gray-600 mb-1.5">Date</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={form.Activity_Date}
|
||||||
|
onChange={e => setForm(f => ({ ...f, Activity_Date: e.target.value }))}
|
||||||
|
className="w-full px-3 py-3 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#0098C7]/30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contact */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-gray-600 mb-1.5">Contact Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.Contact_Name}
|
||||||
|
onChange={e => setForm(f => ({ ...f, Contact_Name: e.target.value }))}
|
||||||
|
placeholder="Who did you meet with?"
|
||||||
|
className="w-full px-3 py-3 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#0098C7]/30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Play & Channel row */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-gray-600 mb-1.5">Play</label>
|
||||||
|
<select value={form.Play} onChange={e => setForm(f => ({ ...f, Play: e.target.value }))} className="w-full px-3 py-3 border border-gray-300 rounded-xl text-sm bg-white focus:ring-2 focus:ring-[#0098C7]/30">
|
||||||
|
<option value="">—</option>
|
||||||
|
{PLAYS.map(p => <option key={p} value={p}>{p}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-gray-600 mb-1.5">Channel</label>
|
||||||
|
<select value={form.Channel} onChange={e => setForm(f => ({ ...f, Channel: e.target.value }))} className="w-full px-3 py-3 border border-gray-300 rounded-xl text-sm bg-white focus:ring-2 focus:ring-[#0098C7]/30">
|
||||||
|
<option value="">—</option>
|
||||||
|
{CHANNELS.map(c => <option key={c} value={c}>{c}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Outcome */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-gray-600 mb-1.5">Outcome</label>
|
||||||
|
<select value={form.Outcome} onChange={e => setForm(f => ({ ...f, Outcome: e.target.value }))} className="w-full px-3 py-3 border border-gray-300 rounded-xl text-sm bg-white focus:ring-2 focus:ring-[#0098C7]/30">
|
||||||
|
<option value="">—</option>
|
||||||
|
{OUTCOMES.map(o => <option key={o} value={o}>{o}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-gray-600 mb-1.5">Notes</label>
|
||||||
|
<textarea
|
||||||
|
value={form.Notes}
|
||||||
|
onChange={e => setForm(f => ({ ...f, Notes: e.target.value }))}
|
||||||
|
placeholder="Key takeaways, action items..."
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-3 py-3 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#0098C7]/30 resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submit */}
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!form.Account_Name || !form.Activity_Type || saving}
|
||||||
|
className="w-full py-3.5 bg-[#0098C7] text-white text-sm font-bold rounded-xl hover:bg-[#007ba3] disabled:opacity-40 disabled:cursor-not-allowed transition"
|
||||||
|
>
|
||||||
|
{saving ? 'Saving...' : 'Log Activity'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="h-4" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
148
src/components/ui/NextBestAction.tsx
Normal file
148
src/components/ui/NextBestAction.tsx
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { AccountRecord, ActivityRecord, PipelineRecord } from '@/types/data';
|
||||||
|
import { differenceInDays, parseISO, format } from 'date-fns';
|
||||||
|
|
||||||
|
interface ActionItem {
|
||||||
|
priority: 'urgent' | 'high' | 'medium';
|
||||||
|
account: string;
|
||||||
|
message: string;
|
||||||
|
detail: string;
|
||||||
|
type: 'stale' | 'renewal' | 'pipeline_gap' | 'deal_risk' | 'untouched';
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRIORITY_STYLES = {
|
||||||
|
urgent: 'border-l-red-500 bg-red-50',
|
||||||
|
high: 'border-l-amber-500 bg-amber-50',
|
||||||
|
medium: 'border-l-blue-500 bg-blue-50',
|
||||||
|
};
|
||||||
|
|
||||||
|
const PRIORITY_LABELS = {
|
||||||
|
urgent: { text: 'Urgent', color: 'text-red-600 bg-red-100' },
|
||||||
|
high: { text: 'Action Needed', color: 'text-amber-700 bg-amber-100' },
|
||||||
|
medium: { text: 'Consider', color: 'text-blue-600 bg-blue-100' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function NextBestAction({ accounts, activities, pipeline, limit = 8 }: {
|
||||||
|
accounts: AccountRecord[];
|
||||||
|
activities: ActivityRecord[];
|
||||||
|
pipeline: PipelineRecord[];
|
||||||
|
limit?: number;
|
||||||
|
}) {
|
||||||
|
const actions = useMemo(() => {
|
||||||
|
const items: ActionItem[] = [];
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
for (const acct of accounts) {
|
||||||
|
if (acct.Priority !== 'High' && acct.Priority !== 'Medium') continue;
|
||||||
|
|
||||||
|
const acctActivities = activities.filter(a => a.Account_Name === acct.Account_Name);
|
||||||
|
const acctPipeline = pipeline.filter(p => p.Account_Name === acct.Account_Name && p.Stage !== '06-Closed Won' && p.Stage !== '07-Closed Lost');
|
||||||
|
|
||||||
|
// Stale high-priority accounts
|
||||||
|
if (acct.Priority === 'High') {
|
||||||
|
if (acctActivities.length === 0) {
|
||||||
|
items.push({
|
||||||
|
priority: 'urgent',
|
||||||
|
account: acct.Account_Name,
|
||||||
|
message: 'High-priority account with zero touches',
|
||||||
|
detail: `${acct.Tier} account, ${acct.Current_ARR_USD ? `$${Math.round(acct.Current_ARR_USD / 1000)}K ARR` : 'no ARR recorded'}`,
|
||||||
|
type: 'untouched',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const lastDate = acctActivities.sort((a, b) => b.Activity_Date.localeCompare(a.Activity_Date))[0];
|
||||||
|
const daysSince = differenceInDays(now, parseISO(lastDate.Activity_Date));
|
||||||
|
if (daysSince > 30) {
|
||||||
|
items.push({
|
||||||
|
priority: daysSince > 60 ? 'urgent' : 'high',
|
||||||
|
account: acct.Account_Name,
|
||||||
|
message: `No activity in ${daysSince} days`,
|
||||||
|
detail: `Last touch: ${format(parseISO(lastDate.Activity_Date), 'MMM d')} (${lastDate.Activity_Type})`,
|
||||||
|
type: 'stale',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Approaching renewals with no play
|
||||||
|
if (acct.Next_Renewal_Date) {
|
||||||
|
const daysToRenewal = differenceInDays(parseISO(acct.Next_Renewal_Date), now);
|
||||||
|
if (daysToRenewal > 0 && daysToRenewal <= 120 && acctPipeline.length === 0) {
|
||||||
|
items.push({
|
||||||
|
priority: daysToRenewal <= 60 ? 'urgent' : 'high',
|
||||||
|
account: acct.Account_Name,
|
||||||
|
message: `Renewal in ${daysToRenewal} days, no pipeline`,
|
||||||
|
detail: `${acct.Next_Renewal_EAR ? `$${Math.round(acct.Next_Renewal_EAR / 1000)}K EAR` : 'EAR not set'} — start attach/renewal play`,
|
||||||
|
type: 'renewal',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// High-priority with no pipeline
|
||||||
|
if (acct.Priority === 'High' && acctPipeline.length === 0 && acctActivities.length > 0) {
|
||||||
|
const lastDate = acctActivities.sort((a, b) => b.Activity_Date.localeCompare(a.Activity_Date))[0];
|
||||||
|
const daysSince = differenceInDays(now, parseISO(lastDate.Activity_Date));
|
||||||
|
if (daysSince <= 30) {
|
||||||
|
items.push({
|
||||||
|
priority: 'medium',
|
||||||
|
account: acct.Account_Name,
|
||||||
|
message: 'Active engagement but no pipeline',
|
||||||
|
detail: `${acctActivities.length} touches — consider creating an opportunity`,
|
||||||
|
type: 'pipeline_gap',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deal risks — overdue close dates
|
||||||
|
const openDeals = pipeline.filter(p => p.Stage !== '06-Closed Won' && p.Stage !== '07-Closed Lost');
|
||||||
|
for (const deal of openDeals) {
|
||||||
|
if (deal.Expected_Close_Date) {
|
||||||
|
const daysOverdue = differenceInDays(now, parseISO(deal.Expected_Close_Date));
|
||||||
|
if (daysOverdue > 14) {
|
||||||
|
items.push({
|
||||||
|
priority: daysOverdue > 30 ? 'urgent' : 'high',
|
||||||
|
account: deal.Account_Name,
|
||||||
|
message: `Deal close date ${daysOverdue} days overdue`,
|
||||||
|
detail: `$${Math.round(deal.Amount_USD / 1000)}K — update close date or advance stage`,
|
||||||
|
type: 'deal_risk',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by priority
|
||||||
|
const order = { urgent: 0, high: 1, medium: 2 };
|
||||||
|
return items.sort((a, b) => order[a.priority] - order[b.priority]).slice(0, limit);
|
||||||
|
}, [accounts, activities, pipeline, limit]);
|
||||||
|
|
||||||
|
if (actions.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-6 text-sm text-muted">
|
||||||
|
No immediate actions needed — territory is well-covered.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{actions.map((action, i) => (
|
||||||
|
<div key={i} className={`border-l-3 rounded-lg p-3 ${PRIORITY_STYLES[action.priority]}`}>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-0.5">
|
||||||
|
<span className="text-xs font-bold text-foreground truncate">{action.account}</span>
|
||||||
|
<span className={`px-1.5 py-0.5 rounded text-[9px] font-semibold ${PRIORITY_LABELS[action.priority].color}`}>
|
||||||
|
{PRIORITY_LABELS[action.priority].text}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs font-medium text-foreground">{action.message}</div>
|
||||||
|
<div className="text-[11px] text-muted mt-0.5">{action.detail}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
320
src/lib/db.ts
320
src/lib/db.ts
@@ -8,6 +8,13 @@ import {
|
|||||||
ImplementationRecord,
|
ImplementationRecord,
|
||||||
MetricTarget,
|
MetricTarget,
|
||||||
DashboardData,
|
DashboardData,
|
||||||
|
AccountNote,
|
||||||
|
Contact,
|
||||||
|
Snapshot,
|
||||||
|
PipelineSnapshot,
|
||||||
|
Playbook,
|
||||||
|
PlaybookProgress,
|
||||||
|
DistrictTarget,
|
||||||
} from '@/types/data';
|
} from '@/types/data';
|
||||||
|
|
||||||
const DB_PATH = path.join(process.cwd(), 'data', 'campaign.db');
|
const DB_PATH = path.join(process.cwd(), 'data', 'campaign.db');
|
||||||
@@ -23,10 +30,26 @@ function getDb(): Database.Database {
|
|||||||
_db = new Database(DB_PATH);
|
_db = new Database(DB_PATH);
|
||||||
_db.pragma('journal_mode = WAL');
|
_db.pragma('journal_mode = WAL');
|
||||||
initSchema(_db);
|
initSchema(_db);
|
||||||
|
migrateSchema(_db);
|
||||||
|
seedDefaultPlaybooks();
|
||||||
}
|
}
|
||||||
return _db;
|
return _db;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function migrateSchema(db: Database.Database) {
|
||||||
|
const cols = db.prepare("PRAGMA table_info(accounts)").all() as { name: string }[];
|
||||||
|
const colNames = new Set(cols.map(c => c.name));
|
||||||
|
if (!colNames.has('Competitors')) {
|
||||||
|
db.exec("ALTER TABLE accounts ADD COLUMN Competitors TEXT DEFAULT NULL");
|
||||||
|
}
|
||||||
|
if (!colNames.has('Google_Drive_URL')) {
|
||||||
|
db.exec("ALTER TABLE accounts ADD COLUMN Google_Drive_URL TEXT DEFAULT NULL");
|
||||||
|
}
|
||||||
|
if (!colNames.has('Campaign_Artifacts_URL')) {
|
||||||
|
db.exec("ALTER TABLE accounts ADD COLUMN Campaign_Artifacts_URL TEXT DEFAULT NULL");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function initSchema(db: Database.Database) {
|
function initSchema(db: Database.Database) {
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS accounts (
|
CREATE TABLE IF NOT EXISTS accounts (
|
||||||
@@ -123,11 +146,89 @@ function initSchema(db: Database.Database) {
|
|||||||
Notes TEXT
|
Notes TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS account_notes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
Account_Name TEXT NOT NULL,
|
||||||
|
note_text TEXT NOT NULL,
|
||||||
|
next_action TEXT,
|
||||||
|
is_pinned INTEGER DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
created_by TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS contacts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
Account_Name TEXT NOT NULL,
|
||||||
|
Contact_Name TEXT NOT NULL,
|
||||||
|
Role TEXT,
|
||||||
|
Sentiment TEXT,
|
||||||
|
Email TEXT,
|
||||||
|
LinkedIn_URL TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS snapshots (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
snapshot_date TEXT NOT NULL,
|
||||||
|
period_type TEXT NOT NULL,
|
||||||
|
total_accounts INTEGER DEFAULT 0,
|
||||||
|
accounts_touched INTEGER DEFAULT 0,
|
||||||
|
total_pipeline REAL DEFAULT 0,
|
||||||
|
open_opps INTEGER DEFAULT 0,
|
||||||
|
closed_won_amount REAL DEFAULT 0,
|
||||||
|
total_activities INTEGER DEFAULT 0,
|
||||||
|
accounts_by_priority_json TEXT,
|
||||||
|
pipeline_by_stage_json TEXT,
|
||||||
|
district_data_json TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pipeline_snapshots (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
snapshot_date TEXT NOT NULL,
|
||||||
|
Opportunity_ID TEXT NOT NULL,
|
||||||
|
Account_Name TEXT NOT NULL,
|
||||||
|
Stage TEXT NOT NULL,
|
||||||
|
Amount_USD REAL NOT NULL DEFAULT 0,
|
||||||
|
Forecast_Category TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS playbooks (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
play_name TEXT NOT NULL UNIQUE,
|
||||||
|
description TEXT,
|
||||||
|
steps_json TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS playbook_progress (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
Account_Name TEXT NOT NULL,
|
||||||
|
playbook_id INTEGER NOT NULL,
|
||||||
|
current_step INTEGER DEFAULT 0,
|
||||||
|
status TEXT DEFAULT 'In Progress',
|
||||||
|
started_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (playbook_id) REFERENCES playbooks(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS district_targets (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
District_Name TEXT NOT NULL,
|
||||||
|
quarter TEXT NOT NULL,
|
||||||
|
activities_per_week REAL DEFAULT 0,
|
||||||
|
accounts_touched INTEGER DEFAULT 0,
|
||||||
|
pipeline_generated REAL DEFAULT 0,
|
||||||
|
UNIQUE(District_Name, quarter)
|
||||||
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_pipeline_account ON pipeline(Account_Name);
|
CREATE INDEX IF NOT EXISTS idx_pipeline_account ON pipeline(Account_Name);
|
||||||
CREATE INDEX IF NOT EXISTS idx_pipeline_district ON pipeline(District_Name);
|
CREATE INDEX IF NOT EXISTS idx_pipeline_district ON pipeline(District_Name);
|
||||||
CREATE INDEX IF NOT EXISTS idx_activities_account ON activities(Account_Name);
|
CREATE INDEX IF NOT EXISTS idx_activities_account ON activities(Account_Name);
|
||||||
CREATE INDEX IF NOT EXISTS idx_activities_date ON activities(Activity_Date);
|
CREATE INDEX IF NOT EXISTS idx_activities_date ON activities(Activity_Date);
|
||||||
CREATE INDEX IF NOT EXISTS idx_impl_account ON implementations(Account_Name);
|
CREATE INDEX IF NOT EXISTS idx_impl_account ON implementations(Account_Name);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notes_account ON account_notes(Account_Name);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_contacts_account ON contacts(Account_Name);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_snapshots_date ON snapshots(snapshot_date);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pipeline_snap_date ON pipeline_snapshots(snapshot_date);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_playbook_progress_account ON playbook_progress(Account_Name);
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -461,6 +562,225 @@ export function upsertPipeline(record: Record<string, unknown>) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Account Notes ---
|
||||||
|
export function getAccountNotes(accountName: string): AccountNote[] {
|
||||||
|
const db = getDb();
|
||||||
|
return db.prepare('SELECT * FROM account_notes WHERE Account_Name = ? ORDER BY is_pinned DESC, created_at DESC').all(accountName) as AccountNote[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addAccountNote(note: { Account_Name: string; note_text: string; next_action?: string | null; is_pinned?: number; created_by?: string | null }): AccountNote {
|
||||||
|
const db = getDb();
|
||||||
|
const result = db.prepare(`INSERT INTO account_notes (Account_Name, note_text, next_action, is_pinned, created_at, created_by) VALUES (?, ?, ?, ?, ?, ?)`).run(
|
||||||
|
note.Account_Name, note.note_text, note.next_action || null, note.is_pinned || 0, new Date().toISOString(), note.created_by || null
|
||||||
|
);
|
||||||
|
return db.prepare('SELECT * FROM account_notes WHERE id = ?').get(result.lastInsertRowid) as AccountNote;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteAccountNote(id: number) {
|
||||||
|
getDb().prepare('DELETE FROM account_notes WHERE id = ?').run(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateAccountNote(id: number, updates: { note_text?: string; next_action?: string | null; is_pinned?: number }) {
|
||||||
|
const db = getDb();
|
||||||
|
const sets: string[] = [];
|
||||||
|
const vals: unknown[] = [];
|
||||||
|
if (updates.note_text !== undefined) { sets.push('note_text = ?'); vals.push(updates.note_text); }
|
||||||
|
if (updates.next_action !== undefined) { sets.push('next_action = ?'); vals.push(updates.next_action); }
|
||||||
|
if (updates.is_pinned !== undefined) { sets.push('is_pinned = ?'); vals.push(updates.is_pinned); }
|
||||||
|
if (sets.length === 0) return;
|
||||||
|
vals.push(id);
|
||||||
|
db.prepare(`UPDATE account_notes SET ${sets.join(', ')} WHERE id = ?`).run(...vals);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Contacts ---
|
||||||
|
export function getContacts(accountName: string): Contact[] {
|
||||||
|
const db = getDb();
|
||||||
|
return db.prepare('SELECT * FROM contacts WHERE Account_Name = ? ORDER BY Contact_Name').all(accountName) as Contact[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAllContacts(): Contact[] {
|
||||||
|
return getDb().prepare('SELECT * FROM contacts ORDER BY Account_Name, Contact_Name').all() as Contact[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function upsertContact(contact: { id?: number; Account_Name: string; Contact_Name: string; Role?: string | null; Sentiment?: string | null; Email?: string | null; LinkedIn_URL?: string | null }): Contact {
|
||||||
|
const db = getDb();
|
||||||
|
if (contact.id) {
|
||||||
|
db.prepare(`UPDATE contacts SET Contact_Name=?, Role=?, Sentiment=?, Email=?, LinkedIn_URL=? WHERE id=?`).run(
|
||||||
|
contact.Contact_Name, contact.Role || null, contact.Sentiment || null, contact.Email || null, contact.LinkedIn_URL || null, contact.id
|
||||||
|
);
|
||||||
|
return db.prepare('SELECT * FROM contacts WHERE id = ?').get(contact.id) as Contact;
|
||||||
|
}
|
||||||
|
const result = db.prepare(`INSERT INTO contacts (Account_Name, Contact_Name, Role, Sentiment, Email, LinkedIn_URL) VALUES (?, ?, ?, ?, ?, ?)`).run(
|
||||||
|
contact.Account_Name, contact.Contact_Name, contact.Role || null, contact.Sentiment || null, contact.Email || null, contact.LinkedIn_URL || null
|
||||||
|
);
|
||||||
|
return db.prepare('SELECT * FROM contacts WHERE id = ?').get(result.lastInsertRowid) as Contact;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteContact(id: number) {
|
||||||
|
getDb().prepare('DELETE FROM contacts WHERE id = ?').run(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Snapshots ---
|
||||||
|
export function getSnapshots(periodType?: string): Snapshot[] {
|
||||||
|
const db = getDb();
|
||||||
|
if (periodType) return db.prepare('SELECT * FROM snapshots WHERE period_type = ? ORDER BY snapshot_date DESC').all(periodType) as Snapshot[];
|
||||||
|
return db.prepare('SELECT * FROM snapshots ORDER BY snapshot_date DESC').all() as Snapshot[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function captureSnapshot(periodType: 'weekly' | 'monthly' | 'quarterly') {
|
||||||
|
const db = getDb();
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
const existing = db.prepare('SELECT id FROM snapshots WHERE snapshot_date = ? AND period_type = ?').get(today, periodType);
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const accounts = db.prepare('SELECT * FROM accounts').all() as AccountRecord[];
|
||||||
|
const pipeline = db.prepare("SELECT * FROM pipeline WHERE Status != 'Closed Lost'").all() as PipelineRecord[];
|
||||||
|
const activities = db.prepare('SELECT * FROM activities').all() as ActivityRecord[];
|
||||||
|
const openPipeline = pipeline.filter(p => p.Stage !== '06-Closed Won' && p.Stage !== '07-Closed Lost');
|
||||||
|
const closedWon = pipeline.filter(p => p.Stage === '06-Closed Won');
|
||||||
|
|
||||||
|
const priorityCounts: Record<string, number> = {};
|
||||||
|
accounts.forEach(a => { priorityCounts[a.Priority || 'Unset'] = (priorityCounts[a.Priority || 'Unset'] || 0) + 1; });
|
||||||
|
|
||||||
|
const stageCounts: Record<string, { count: number; amount: number }> = {};
|
||||||
|
openPipeline.forEach(p => {
|
||||||
|
if (!stageCounts[p.Stage]) stageCounts[p.Stage] = { count: 0, amount: 0 };
|
||||||
|
stageCounts[p.Stage].count++;
|
||||||
|
stageCounts[p.Stage].amount += p.Amount_USD;
|
||||||
|
});
|
||||||
|
|
||||||
|
const districtData: Record<string, { activities: number; pipeline: number; touched: number }> = {};
|
||||||
|
activities.forEach(a => {
|
||||||
|
if (!districtData[a.District_Name]) districtData[a.District_Name] = { activities: 0, pipeline: 0, touched: 0 };
|
||||||
|
districtData[a.District_Name].activities++;
|
||||||
|
});
|
||||||
|
openPipeline.forEach(p => {
|
||||||
|
if (!districtData[p.District_Name]) districtData[p.District_Name] = { activities: 0, pipeline: 0, touched: 0 };
|
||||||
|
districtData[p.District_Name].pipeline += p.Amount_USD;
|
||||||
|
});
|
||||||
|
const touchedAccounts = new Set(activities.map(a => a.Account_Name));
|
||||||
|
|
||||||
|
const result = db.prepare(`INSERT INTO snapshots (snapshot_date, period_type, total_accounts, accounts_touched, total_pipeline, open_opps, closed_won_amount, total_activities, accounts_by_priority_json, pipeline_by_stage_json, district_data_json)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
|
||||||
|
today, periodType, accounts.length, touchedAccounts.size,
|
||||||
|
openPipeline.reduce((s, p) => s + p.Amount_USD, 0), openPipeline.length,
|
||||||
|
closedWon.reduce((s, p) => s + (p.Closed_Amount_USD || p.Amount_USD), 0), activities.length,
|
||||||
|
JSON.stringify(priorityCounts), JSON.stringify(stageCounts), JSON.stringify(districtData)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Also snapshot individual pipeline records for waterfall tracking
|
||||||
|
const pipeSnap = db.prepare(`INSERT INTO pipeline_snapshots (snapshot_date, Opportunity_ID, Account_Name, Stage, Amount_USD, Forecast_Category) VALUES (?, ?, ?, ?, ?, ?)`);
|
||||||
|
const tx = db.transaction(() => {
|
||||||
|
for (const p of pipeline) {
|
||||||
|
pipeSnap.run(today, p.Opportunity_ID, p.Account_Name, p.Stage, p.Amount_USD, p.Forecast_Category);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
tx();
|
||||||
|
|
||||||
|
return { id: result.lastInsertRowid };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Pipeline Snapshots (for waterfall) ---
|
||||||
|
export function getPipelineSnapshots(date?: string): PipelineSnapshot[] {
|
||||||
|
const db = getDb();
|
||||||
|
if (date) return db.prepare('SELECT * FROM pipeline_snapshots WHERE snapshot_date = ?').all(date) as PipelineSnapshot[];
|
||||||
|
return db.prepare('SELECT DISTINCT snapshot_date FROM pipeline_snapshots ORDER BY snapshot_date DESC').all() as PipelineSnapshot[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Playbooks ---
|
||||||
|
export function getPlaybooks(): Playbook[] {
|
||||||
|
return getDb().prepare('SELECT * FROM playbooks ORDER BY play_name').all() as Playbook[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function upsertPlaybook(playbook: { id?: number; play_name: string; description?: string | null; steps_json: string }) {
|
||||||
|
const db = getDb();
|
||||||
|
if (playbook.id) {
|
||||||
|
db.prepare('UPDATE playbooks SET play_name=?, description=?, steps_json=? WHERE id=?').run(playbook.play_name, playbook.description || null, playbook.steps_json, playbook.id);
|
||||||
|
} else {
|
||||||
|
db.prepare('INSERT INTO playbooks (play_name, description, steps_json) VALUES (?, ?, ?) ON CONFLICT(play_name) DO UPDATE SET description=excluded.description, steps_json=excluded.steps_json').run(playbook.play_name, playbook.description || null, playbook.steps_json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPlaybookProgress(accountName?: string): PlaybookProgress[] {
|
||||||
|
const db = getDb();
|
||||||
|
if (accountName) return db.prepare('SELECT * FROM playbook_progress WHERE Account_Name = ?').all(accountName) as PlaybookProgress[];
|
||||||
|
return db.prepare('SELECT * FROM playbook_progress ORDER BY updated_at DESC').all() as PlaybookProgress[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function upsertPlaybookProgress(progress: { id?: number; Account_Name: string; playbook_id: number; current_step: number; status: string }) {
|
||||||
|
const db = getDb();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
if (progress.id) {
|
||||||
|
db.prepare('UPDATE playbook_progress SET current_step=?, status=?, updated_at=? WHERE id=?').run(progress.current_step, progress.status, now, progress.id);
|
||||||
|
} else {
|
||||||
|
db.prepare('INSERT INTO playbook_progress (Account_Name, playbook_id, current_step, status, started_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)').run(
|
||||||
|
progress.Account_Name, progress.playbook_id, progress.current_step, progress.status, now, now
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function seedDefaultPlaybooks() {
|
||||||
|
const db = getDb();
|
||||||
|
const existing = db.prepare('SELECT COUNT(*) as c FROM playbooks').get() as { c: number };
|
||||||
|
if (existing.c > 0) return;
|
||||||
|
|
||||||
|
const playbooks = [
|
||||||
|
{ play_name: 'New Logo Acquisition', description: 'Full sales cycle for net-new accounts', steps_json: JSON.stringify(['Research & Targeting', 'Initial Outreach', 'Discovery Meeting', 'Demo / Workshop', 'Executive Alignment', 'Proposal & Negotiation', 'Close']) },
|
||||||
|
{ play_name: 'Attach / Cross-Sell', description: 'Expand within existing accounts', steps_json: JSON.stringify(['Account Review & Whitespace ID', 'Champion Alignment', 'Discovery / Needs Assessment', 'Demo / POC', 'Business Case', 'Close']) },
|
||||||
|
{ play_name: 'Renewal Defense', description: 'Protect existing contracts approaching renewal', steps_json: JSON.stringify(['Renewal Assessment', 'Stakeholder Re-engagement', 'Value Realization Review', 'QBR / Executive Briefing', 'Renewal Negotiation', 'Contract Execution']) },
|
||||||
|
{ play_name: 'Competitive Displacement', description: 'Displace an incumbent competitor', steps_json: JSON.stringify(['Competitive Intel Gathering', 'Pain Point Discovery', 'Differentiation Demo', 'Executive Sponsor Alignment', 'POC / Bake-off', 'Proposal & Close']) },
|
||||||
|
{ play_name: 'Inbound Response', description: 'Respond to inbound interest or RFP', steps_json: JSON.stringify(['Qualify Inbound', 'Discovery Call', 'Technical Deep Dive', 'Proposal / RFP Response', 'Negotiation', 'Close']) },
|
||||||
|
];
|
||||||
|
|
||||||
|
const stmt = db.prepare('INSERT INTO playbooks (play_name, description, steps_json) VALUES (?, ?, ?)');
|
||||||
|
const tx = db.transaction(() => { for (const p of playbooks) stmt.run(p.play_name, p.description, p.steps_json); });
|
||||||
|
tx();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- District Targets ---
|
||||||
|
export function getDistrictTargets(quarter?: string): DistrictTarget[] {
|
||||||
|
const db = getDb();
|
||||||
|
if (quarter) return db.prepare('SELECT * FROM district_targets WHERE quarter = ? ORDER BY District_Name').all(quarter) as DistrictTarget[];
|
||||||
|
return db.prepare('SELECT * FROM district_targets ORDER BY quarter DESC, District_Name').all() as DistrictTarget[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function upsertDistrictTarget(target: { District_Name: string; quarter: string; activities_per_week: number; accounts_touched: number; pipeline_generated: number }) {
|
||||||
|
const db = getDb();
|
||||||
|
db.prepare(`INSERT INTO district_targets (District_Name, quarter, activities_per_week, accounts_touched, pipeline_generated) VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(District_Name, quarter) DO UPDATE SET activities_per_week=excluded.activities_per_week, accounts_touched=excluded.accounts_touched, pipeline_generated=excluded.pipeline_generated`).run(
|
||||||
|
target.District_Name, target.quarter, target.activities_per_week, target.accounts_touched, target.pipeline_generated
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Data Quality ---
|
||||||
|
export function getDataQualityReport() {
|
||||||
|
const db = getDb();
|
||||||
|
const accounts = db.prepare('SELECT * FROM accounts').all() as AccountRecord[];
|
||||||
|
const pipeline = db.prepare("SELECT * FROM pipeline WHERE Status != 'Closed Lost' AND Stage NOT IN ('06-Closed Won', '07-Closed Lost')").all() as PipelineRecord[];
|
||||||
|
|
||||||
|
const issues: { type: string; severity: string; message: string; table: string; record_id: string }[] = [];
|
||||||
|
|
||||||
|
for (const a of accounts) {
|
||||||
|
if (!a.Priority || a.Priority === '') issues.push({ type: 'missing_field', severity: 'medium', message: `Missing Priority`, table: 'accounts', record_id: a.Account_Name });
|
||||||
|
if (!a.Tier || a.Tier === '') issues.push({ type: 'missing_field', severity: 'medium', message: `Missing Tier`, table: 'accounts', record_id: a.Account_Name });
|
||||||
|
if (!a.AD) issues.push({ type: 'missing_field', severity: 'low', message: `Missing AD assignment`, table: 'accounts', record_id: a.Account_Name });
|
||||||
|
if (!a.Next_Renewal_Date) issues.push({ type: 'missing_field', severity: 'low', message: `Missing Renewal Date`, table: 'accounts', record_id: a.Account_Name });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const p of pipeline) {
|
||||||
|
if (!p.Next_Step) issues.push({ type: 'missing_field', severity: 'high', message: `No Next Step defined`, table: 'pipeline', record_id: p.Opportunity_ID });
|
||||||
|
if (p.Expected_Close_Date && p.Expected_Close_Date < new Date().toISOString().split('T')[0]) issues.push({ type: 'stale_data', severity: 'high', message: `Close date in the past`, table: 'pipeline', record_id: p.Opportunity_ID });
|
||||||
|
if (!p.Champion_Name) issues.push({ type: 'missing_field', severity: 'medium', message: `No Champion identified`, table: 'pipeline', record_id: p.Opportunity_ID });
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalFields = accounts.length * 5 + pipeline.length * 3;
|
||||||
|
const missingFields = issues.filter(i => i.type === 'missing_field').length;
|
||||||
|
const completeness = totalFields > 0 ? Math.round(((totalFields - missingFields) / totalFields) * 100) : 100;
|
||||||
|
|
||||||
|
return { issues, completeness, totalAccounts: accounts.length, totalPipeline: pipeline.length };
|
||||||
|
}
|
||||||
|
|
||||||
export function upsertTarget(record: Record<string, unknown>) {
|
export function upsertTarget(record: Record<string, unknown>) {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const id = (record.Implementation_ID as string) || `IMPL-${Date.now()}`;
|
const id = (record.Implementation_ID as string) || `IMPL-${Date.now()}`;
|
||||||
|
|||||||
@@ -90,6 +90,9 @@ function generateAccounts(): AccountRecord[] {
|
|||||||
Next_Renewal_EAR: null,
|
Next_Renewal_EAR: null,
|
||||||
Anchor_Contract_Date: null,
|
Anchor_Contract_Date: null,
|
||||||
Anchor_Contract_EAR: null,
|
Anchor_Contract_EAR: null,
|
||||||
|
Google_Drive_URL: null,
|
||||||
|
Campaign_Artifacts_URL: null,
|
||||||
|
Competitors: null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
198
src/lib/scoring.ts
Normal file
198
src/lib/scoring.ts
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
import { PipelineRecord, AccountRecord, ActivityRecord } from '@/types/data';
|
||||||
|
import { differenceInDays, parseISO } from 'date-fns';
|
||||||
|
|
||||||
|
// --- Deal Risk Scoring ---
|
||||||
|
|
||||||
|
export interface DealRisk {
|
||||||
|
Opportunity_ID: string;
|
||||||
|
Account_Name: string;
|
||||||
|
risk_score: number; // 0-100, higher = more risky
|
||||||
|
risk_level: 'Low' | 'Medium' | 'High' | 'Critical';
|
||||||
|
risk_factors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scoreDealRisk(
|
||||||
|
deal: PipelineRecord,
|
||||||
|
activities: ActivityRecord[],
|
||||||
|
allDeals: PipelineRecord[]
|
||||||
|
): DealRisk {
|
||||||
|
const factors: string[] = [];
|
||||||
|
let score = 0;
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
// Skip closed deals
|
||||||
|
if (deal.Stage === '06-Closed Won' || deal.Stage === '07-Closed Lost') {
|
||||||
|
return { Opportunity_ID: deal.Opportunity_ID, Account_Name: deal.Account_Name, risk_score: 0, risk_level: 'Low', risk_factors: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Factor 1: Close date in the past
|
||||||
|
if (deal.Expected_Close_Date) {
|
||||||
|
const closeDate = parseISO(deal.Expected_Close_Date);
|
||||||
|
const daysOverdue = differenceInDays(now, closeDate);
|
||||||
|
if (daysOverdue > 30) {
|
||||||
|
score += 30;
|
||||||
|
factors.push(`Close date ${daysOverdue} days overdue`);
|
||||||
|
} else if (daysOverdue > 0) {
|
||||||
|
score += 20;
|
||||||
|
factors.push(`Close date ${daysOverdue} days past`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Factor 2: No next step defined
|
||||||
|
if (!deal.Next_Step || deal.Next_Step.trim() === '') {
|
||||||
|
score += 20;
|
||||||
|
factors.push('No next step defined');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Factor 3: Days in stage (aging)
|
||||||
|
if (deal.Stage_Entered_Date || deal.Created_Date) {
|
||||||
|
const stageDate = parseISO(deal.Stage_Entered_Date || deal.Created_Date);
|
||||||
|
const daysInStage = differenceInDays(now, stageDate);
|
||||||
|
|
||||||
|
// Calculate median days in stage for comparison
|
||||||
|
const sameStageDays = allDeals
|
||||||
|
.filter(d => d.Stage === deal.Stage && d.Opportunity_ID !== deal.Opportunity_ID)
|
||||||
|
.map(d => differenceInDays(now, parseISO(d.Stage_Entered_Date || d.Created_Date)));
|
||||||
|
const medianDays = sameStageDays.length > 0
|
||||||
|
? sameStageDays.sort((a, b) => a - b)[Math.floor(sameStageDays.length / 2)]
|
||||||
|
: 30;
|
||||||
|
|
||||||
|
if (daysInStage > medianDays * 2) {
|
||||||
|
score += 25;
|
||||||
|
factors.push(`${daysInStage} days in stage (2x median)`);
|
||||||
|
} else if (daysInStage > medianDays * 1.5) {
|
||||||
|
score += 15;
|
||||||
|
factors.push(`${daysInStage} days in stage (above median)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Factor 4: No recent activity on the account
|
||||||
|
const accountActivities = activities.filter(a => a.Account_Name === deal.Account_Name);
|
||||||
|
if (accountActivities.length === 0) {
|
||||||
|
score += 20;
|
||||||
|
factors.push('No activities logged for account');
|
||||||
|
} else {
|
||||||
|
const lastActivity = accountActivities.sort((a, b) => b.Activity_Date.localeCompare(a.Activity_Date))[0];
|
||||||
|
const daysSinceActivity = differenceInDays(now, parseISO(lastActivity.Activity_Date));
|
||||||
|
if (daysSinceActivity > 30) {
|
||||||
|
score += 15;
|
||||||
|
factors.push(`No activity in ${daysSinceActivity} days`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Factor 5: No champion identified
|
||||||
|
if (!deal.Champion_Name) {
|
||||||
|
score += 10;
|
||||||
|
factors.push('No champion identified');
|
||||||
|
}
|
||||||
|
|
||||||
|
const risk_level = score >= 60 ? 'Critical' : score >= 40 ? 'High' : score >= 20 ? 'Medium' : 'Low';
|
||||||
|
|
||||||
|
return {
|
||||||
|
Opportunity_ID: deal.Opportunity_ID,
|
||||||
|
Account_Name: deal.Account_Name,
|
||||||
|
risk_score: Math.min(score, 100),
|
||||||
|
risk_level,
|
||||||
|
risk_factors: factors,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Account Health Score ---
|
||||||
|
|
||||||
|
export interface AccountHealth {
|
||||||
|
Account_Name: string;
|
||||||
|
health_score: number; // 0-100, higher = healthier
|
||||||
|
health_level: 'Excellent' | 'Good' | 'Fair' | 'Poor' | 'Critical';
|
||||||
|
factors: { label: string; score: number; max: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scoreAccountHealth(
|
||||||
|
account: AccountRecord,
|
||||||
|
activities: ActivityRecord[],
|
||||||
|
pipeline: PipelineRecord[]
|
||||||
|
): AccountHealth {
|
||||||
|
const now = new Date();
|
||||||
|
const factors: { label: string; score: number; max: number }[] = [];
|
||||||
|
|
||||||
|
// Factor 1: Activity Recency (0-25 points)
|
||||||
|
const acctActivities = activities.filter(a => a.Account_Name === account.Account_Name);
|
||||||
|
let recencyScore = 0;
|
||||||
|
if (acctActivities.length > 0) {
|
||||||
|
const lastTouch = acctActivities.sort((a, b) => b.Activity_Date.localeCompare(a.Activity_Date))[0];
|
||||||
|
const daysSince = differenceInDays(now, parseISO(lastTouch.Activity_Date));
|
||||||
|
if (daysSince <= 7) recencyScore = 25;
|
||||||
|
else if (daysSince <= 14) recencyScore = 20;
|
||||||
|
else if (daysSince <= 30) recencyScore = 15;
|
||||||
|
else if (daysSince <= 60) recencyScore = 8;
|
||||||
|
else recencyScore = 0;
|
||||||
|
}
|
||||||
|
factors.push({ label: 'Activity Recency', score: recencyScore, max: 25 });
|
||||||
|
|
||||||
|
// Factor 2: Touch Frequency (0-20 points)
|
||||||
|
let frequencyScore = 0;
|
||||||
|
const touchCount = account.Touch_Count || 0;
|
||||||
|
if (touchCount >= 5) frequencyScore = 20;
|
||||||
|
else if (touchCount >= 3) frequencyScore = 15;
|
||||||
|
else if (touchCount >= 2) frequencyScore = 10;
|
||||||
|
else if (touchCount >= 1) frequencyScore = 5;
|
||||||
|
factors.push({ label: 'Touch Frequency', score: frequencyScore, max: 20 });
|
||||||
|
|
||||||
|
// Factor 3: Pipeline Presence (0-20 points)
|
||||||
|
const acctPipeline = pipeline.filter(p => p.Account_Name === account.Account_Name && p.Stage !== '07-Closed Lost');
|
||||||
|
const openPipeline = acctPipeline.filter(p => p.Stage !== '06-Closed Won');
|
||||||
|
const closedWon = acctPipeline.filter(p => p.Stage === '06-Closed Won');
|
||||||
|
let pipelineScore = 0;
|
||||||
|
if (closedWon.length > 0) pipelineScore = 20;
|
||||||
|
else if (openPipeline.length > 0) pipelineScore = 12;
|
||||||
|
else pipelineScore = 0;
|
||||||
|
factors.push({ label: 'Pipeline Presence', score: pipelineScore, max: 20 });
|
||||||
|
|
||||||
|
// Factor 4: Renewal Risk (0-20 points — inverted: closer renewal = lower score unless engaged)
|
||||||
|
let renewalScore = 10; // default if no renewal
|
||||||
|
if (account.Next_Renewal_Date) {
|
||||||
|
const daysToRenewal = differenceInDays(parseISO(account.Next_Renewal_Date), now);
|
||||||
|
if (daysToRenewal <= 90) {
|
||||||
|
renewalScore = recencyScore >= 15 ? 20 : 0; // close renewal + recent engagement = good; close + no engagement = bad
|
||||||
|
} else if (daysToRenewal <= 180) {
|
||||||
|
renewalScore = recencyScore >= 10 ? 15 : 5;
|
||||||
|
} else {
|
||||||
|
renewalScore = 10;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
factors.push({ label: 'Renewal Position', score: renewalScore, max: 20 });
|
||||||
|
|
||||||
|
// Factor 5: Engagement Quality (0-15 points)
|
||||||
|
let qualityScore = 0;
|
||||||
|
const activityTypes = new Set(acctActivities.map(a => a.Activity_Type));
|
||||||
|
if (activityTypes.has('Exec Meeting')) qualityScore += 5;
|
||||||
|
if (activityTypes.has('Demo') || activityTypes.has('Workshop')) qualityScore += 5;
|
||||||
|
if (activityTypes.has('Discovery')) qualityScore += 3;
|
||||||
|
if (activityTypes.size >= 3) qualityScore += 2;
|
||||||
|
qualityScore = Math.min(qualityScore, 15);
|
||||||
|
factors.push({ label: 'Engagement Quality', score: qualityScore, max: 15 });
|
||||||
|
|
||||||
|
const total = factors.reduce((s, f) => s + f.score, 0);
|
||||||
|
const health_level = total >= 80 ? 'Excellent' : total >= 60 ? 'Good' : total >= 40 ? 'Fair' : total >= 20 ? 'Poor' : 'Critical';
|
||||||
|
|
||||||
|
return {
|
||||||
|
Account_Name: account.Account_Name,
|
||||||
|
health_score: total,
|
||||||
|
health_level,
|
||||||
|
factors,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HEALTH_LEVEL_COLORS: Record<string, string> = {
|
||||||
|
'Excellent': '#16A34A',
|
||||||
|
'Good': '#61A60E',
|
||||||
|
'Fair': '#F59E0B',
|
||||||
|
'Poor': '#EA580C',
|
||||||
|
'Critical': '#DC2626',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RISK_LEVEL_COLORS: Record<string, string> = {
|
||||||
|
'Low': '#61A60E',
|
||||||
|
'Medium': '#F59E0B',
|
||||||
|
'High': '#EA580C',
|
||||||
|
'Critical': '#DC2626',
|
||||||
|
};
|
||||||
29
src/middleware.ts
Normal file
29
src/middleware.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
export async function middleware(req: NextRequest) {
|
||||||
|
const isAuthConfigured = process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET && process.env.AUTH_SECRET;
|
||||||
|
if (!isAuthConfigured) return NextResponse.next();
|
||||||
|
|
||||||
|
const isAuthRoute = req.nextUrl.pathname.startsWith('/auth') || req.nextUrl.pathname.startsWith('/api/auth');
|
||||||
|
if (isAuthRoute) return NextResponse.next();
|
||||||
|
|
||||||
|
const { auth } = await import('@/auth');
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.redirect(new URL('/auth/signin', req.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = (session as unknown as Record<string, unknown>).role;
|
||||||
|
const isWriteRoute = req.nextUrl.pathname === '/admin' || (req.nextUrl.pathname.startsWith('/api/admin') && req.method === 'POST');
|
||||||
|
if (isWriteRoute && role === 'readonly') {
|
||||||
|
return new NextResponse('Forbidden', { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ['/((?!_next/static|_next/image|favicon.ico|logo.svg).*)'],
|
||||||
|
};
|
||||||
@@ -48,6 +48,7 @@ export interface AccountRecord {
|
|||||||
Anchor_Contract_EAR: number | null;
|
Anchor_Contract_EAR: number | null;
|
||||||
Google_Drive_URL: string | null;
|
Google_Drive_URL: string | null;
|
||||||
Campaign_Artifacts_URL: string | null;
|
Campaign_Artifacts_URL: string | null;
|
||||||
|
Competitors: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ActivityRecord {
|
export interface ActivityRecord {
|
||||||
@@ -101,6 +102,77 @@ export interface MetricTarget {
|
|||||||
Notes: string | null;
|
Notes: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AccountNote {
|
||||||
|
id: number;
|
||||||
|
Account_Name: string;
|
||||||
|
note_text: string;
|
||||||
|
next_action: string | null;
|
||||||
|
is_pinned: number;
|
||||||
|
created_at: string;
|
||||||
|
created_by: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Contact {
|
||||||
|
id: number;
|
||||||
|
Account_Name: string;
|
||||||
|
Contact_Name: string;
|
||||||
|
Role: string | null;
|
||||||
|
Sentiment: string | null;
|
||||||
|
Email: string | null;
|
||||||
|
LinkedIn_URL: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Snapshot {
|
||||||
|
id: number;
|
||||||
|
snapshot_date: string;
|
||||||
|
period_type: 'weekly' | 'monthly' | 'quarterly';
|
||||||
|
total_accounts: number;
|
||||||
|
accounts_touched: number;
|
||||||
|
total_pipeline: number;
|
||||||
|
open_opps: number;
|
||||||
|
closed_won_amount: number;
|
||||||
|
total_activities: number;
|
||||||
|
accounts_by_priority_json: string;
|
||||||
|
pipeline_by_stage_json: string;
|
||||||
|
district_data_json: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PipelineSnapshot {
|
||||||
|
id: number;
|
||||||
|
snapshot_date: string;
|
||||||
|
Opportunity_ID: string;
|
||||||
|
Account_Name: string;
|
||||||
|
Stage: string;
|
||||||
|
Amount_USD: number;
|
||||||
|
Forecast_Category: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Playbook {
|
||||||
|
id: number;
|
||||||
|
play_name: string;
|
||||||
|
description: string | null;
|
||||||
|
steps_json: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlaybookProgress {
|
||||||
|
id: number;
|
||||||
|
Account_Name: string;
|
||||||
|
playbook_id: number;
|
||||||
|
current_step: number;
|
||||||
|
status: string;
|
||||||
|
started_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DistrictTarget {
|
||||||
|
id: number;
|
||||||
|
District_Name: string;
|
||||||
|
quarter: string;
|
||||||
|
activities_per_week: number;
|
||||||
|
accounts_touched: number;
|
||||||
|
pipeline_generated: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DashboardData {
|
export interface DashboardData {
|
||||||
pipeline: PipelineRecord[];
|
pipeline: PipelineRecord[];
|
||||||
accounts: AccountRecord[];
|
accounts: AccountRecord[];
|
||||||
|
|||||||
Reference in New Issue
Block a user