Add planned activities and follow-ups workflow
Activities with future dates are automatically tagged as Planned. Once the date passes they become Pending Follow-Ups requiring resolution. - Added Status column to activities (Planned/Completed/Did Not Occur) - Built Follow-Ups subview with expandable cards, outcome capture, and Create Next Step workflow for scheduling follow-on activities - Upcoming Planned table shows scheduled future activities - Executive Overview gains Meetings Scheduled and Follow-Ups Pending KPIs - Planned/Follow-Up badges appear in the activity detail table - Admin page includes Status field for activities Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,13 +4,13 @@ import { useData } from '@/lib/data-context';
|
||||
import { ChartCard } from '@/components/ui/ChartCard';
|
||||
import { PageHeader } from '@/components/ui/PageHeader';
|
||||
import { CHART_COLORS, CHART_PALETTE, DISTRICT_SHORT, formatPercent, formatCurrency, STATUS_COLORS, TIER_COLORS, STAGE_COLORS } from '@/lib/formatters';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMemo, useState, useCallback } from 'react';
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend,
|
||||
PieChart, Pie, Cell,
|
||||
} from 'recharts';
|
||||
import { parseISO, format, startOfWeek, differenceInDays, eachDayOfInterval } from 'date-fns';
|
||||
import { ActivityRecord } from '@/types/data';
|
||||
import { ActivityRecord, ACTIVITY_TYPES } from '@/types/data';
|
||||
import { ExportButton } from '@/components/ui/ExportButton';
|
||||
import { AccountNotes } from '@/components/account/AccountNotes';
|
||||
import { ContactMap } from '@/components/account/ContactMap';
|
||||
@@ -48,15 +48,19 @@ function CustomTooltip({ active, payload, label }: { active?: boolean; payload?:
|
||||
);
|
||||
}
|
||||
|
||||
type SubView = 'all' | 'follow-ups';
|
||||
|
||||
export default function ActivityDeepDive() {
|
||||
const { filtered, config } = useData();
|
||||
const { filtered, config, refresh } = useData();
|
||||
const { activities, accounts, pipeline, targets } = filtered;
|
||||
const [subView, setSubView] = useState<SubView>('all');
|
||||
const [typeFilter, setTypeFilter] = useState<Set<string>>(new Set());
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [sortField, setSortField] = useState<'Activity_Date' | 'Account_Name' | 'Activity_Type'>('Activity_Date');
|
||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
|
||||
const [page, setPage] = useState(0);
|
||||
const [selectedActivity, setSelectedActivity] = useState<ActivityRecord | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const pageSize = 15;
|
||||
|
||||
const filteredActivities = useMemo(() => {
|
||||
@@ -172,10 +176,102 @@ export default function ActivityDeepDive() {
|
||||
|
||||
const allTypes = [...new Set(activities.map(a => a.Activity_Type))].sort();
|
||||
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
const followUpItems = useMemo(() => {
|
||||
return (activities as (ActivityRecord & { id?: number })[])
|
||||
.filter(a => {
|
||||
if (a.Status === 'Completed' || a.Status === 'Did Not Occur') return false;
|
||||
if (a.Status === 'Planned' && a.Activity_Date <= today) return true;
|
||||
return false;
|
||||
})
|
||||
.sort((a, b) => a.Activity_Date.localeCompare(b.Activity_Date));
|
||||
}, [activities, today]);
|
||||
|
||||
const plannedUpcoming = useMemo(() => {
|
||||
return (activities as (ActivityRecord & { id?: number })[])
|
||||
.filter(a => a.Status === 'Planned' && a.Activity_Date > today)
|
||||
.sort((a, b) => a.Activity_Date.localeCompare(b.Activity_Date));
|
||||
}, [activities, today]);
|
||||
|
||||
const resolveFollowUp = useCallback(async (id: number, status: 'Completed' | 'Did Not Occur', outcome?: string, notes?: string) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await fetch('/api/admin', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'update_status', record: { id, Status: status, Outcome: outcome || null, Notes: notes || null } }),
|
||||
});
|
||||
await refresh();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [refresh]);
|
||||
|
||||
const createNextStep = useCallback(async (accountName: string, districtName: string, form: Record<string, string>) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await fetch('/api/admin', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: 'upsert',
|
||||
table: 'activities',
|
||||
record: {
|
||||
Activity_Date: form.date,
|
||||
Activity_Type: form.type || 'Call',
|
||||
Account_Name: accountName,
|
||||
District_Name: districtName,
|
||||
Contact_Name: form.contact || null,
|
||||
Notes: form.notes || null,
|
||||
},
|
||||
}),
|
||||
});
|
||||
await refresh();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [refresh]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader title="Activities" />
|
||||
|
||||
{/* Subview tabs */}
|
||||
<div className="flex items-center gap-1 mb-4 border-b border-card-border">
|
||||
{([
|
||||
{ key: 'all' as SubView, label: 'All Activities' },
|
||||
{ key: 'follow-ups' as SubView, label: 'Follow-Ups', count: followUpItems.length },
|
||||
]).map(tab => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => { setSubView(tab.key); setPage(0); }}
|
||||
className={`px-4 py-2 text-xs font-medium border-b-2 transition -mb-px ${
|
||||
subView === tab.key
|
||||
? 'border-brand-azure text-brand-azure'
|
||||
: 'border-transparent text-muted hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
{tab.count !== undefined && tab.count > 0 && (
|
||||
<span className="ml-1.5 px-1.5 py-0.5 rounded-full text-[10px] font-bold bg-red-500 text-white">{tab.count}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{subView === 'follow-ups' && (
|
||||
<FollowUpsView
|
||||
followUpItems={followUpItems}
|
||||
plannedUpcoming={plannedUpcoming}
|
||||
accounts={accounts}
|
||||
resolveFollowUp={resolveFollowUp}
|
||||
createNextStep={createNextStep}
|
||||
saving={saving}
|
||||
/>
|
||||
)}
|
||||
|
||||
{subView === 'all' && <>
|
||||
{/* Type filter chips */}
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{allTypes.map(type => (
|
||||
@@ -403,7 +499,15 @@ export default function ActivityDeepDive() {
|
||||
className="border-b border-card-border/50 hover:bg-brand-azure/5 transition cursor-pointer"
|
||||
onClick={() => setSelectedActivity(a)}
|
||||
>
|
||||
<td className="py-2 px-2 text-muted">{format(parseISO(a.Activity_Date), 'MMM d')}</td>
|
||||
<td className="py-2 px-2 text-muted">
|
||||
{format(parseISO(a.Activity_Date), 'MMM d')}
|
||||
{a.Status === 'Planned' && a.Activity_Date > today && (
|
||||
<span className="ml-1 px-1.5 py-0.5 rounded text-[9px] font-bold bg-blue-100 text-blue-700">Planned</span>
|
||||
)}
|
||||
{a.Status === 'Planned' && a.Activity_Date <= today && (
|
||||
<span className="ml-1 px-1.5 py-0.5 rounded text-[9px] font-bold bg-amber-100 text-amber-700">Follow-Up</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 px-2 font-medium">{a.Account_Name}</td>
|
||||
<td className="py-2 px-2">
|
||||
<span className="px-2 py-0.5 rounded-full text-[10px] text-white font-medium" style={{ backgroundColor: TYPE_COLORS[a.Activity_Type] || CHART_COLORS.navy }}>
|
||||
@@ -428,6 +532,7 @@ export default function ActivityDeepDive() {
|
||||
</div>
|
||||
)}
|
||||
</ChartCard>
|
||||
</>}
|
||||
|
||||
{/* Activity Detail Overlay */}
|
||||
{selectedActivity && overlayData && (
|
||||
@@ -664,3 +769,252 @@ export default function ActivityDeepDive() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FollowUpsView({
|
||||
followUpItems,
|
||||
plannedUpcoming,
|
||||
accounts,
|
||||
resolveFollowUp,
|
||||
createNextStep,
|
||||
saving,
|
||||
}: {
|
||||
followUpItems: (ActivityRecord & { id?: number })[];
|
||||
plannedUpcoming: (ActivityRecord & { id?: number })[];
|
||||
accounts: { Account_Name: string; District_Name: string }[];
|
||||
resolveFollowUp: (id: number, status: 'Completed' | 'Did Not Occur', outcome?: string, notes?: string) => Promise<void>;
|
||||
createNextStep: (accountName: string, districtName: string, form: Record<string, string>) => Promise<void>;
|
||||
saving: boolean;
|
||||
}) {
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||
const [outcomes, setOutcomes] = useState<Record<number, string>>({});
|
||||
const [noteInputs, setNoteInputs] = useState<Record<number, string>>({});
|
||||
const [nextStepForms, setNextStepForms] = useState<Record<number, { date: string; type: string; contact: string; notes: string }>>({});
|
||||
const [showNextStep, setShowNextStep] = useState<Record<number, boolean>>({});
|
||||
|
||||
const handleResolve = async (id: number, status: 'Completed' | 'Did Not Occur') => {
|
||||
await resolveFollowUp(id, status, outcomes[id], noteInputs[id]);
|
||||
setExpandedId(null);
|
||||
};
|
||||
|
||||
const handleCreateNext = async (id: number, accountName: string, districtName: string) => {
|
||||
const form = nextStepForms[id];
|
||||
if (!form?.date) return;
|
||||
await createNextStep(accountName, districtName, form);
|
||||
setShowNextStep(prev => ({ ...prev, [id]: false }));
|
||||
setNextStepForms(prev => { const n = { ...prev }; delete n[id]; return n; });
|
||||
};
|
||||
|
||||
const getDefaultNextDate = () => {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + 7);
|
||||
return d.toISOString().split('T')[0];
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<ChartCard title="Pending Follow-Ups" subtitle={`${followUpItems.length} activities need resolution`}>
|
||||
{followUpItems.length === 0 ? (
|
||||
<div className="text-center py-8 text-sm text-muted">
|
||||
No pending follow-ups. You're all caught up!
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{followUpItems.map(item => {
|
||||
const id = item.id!;
|
||||
const isExpanded = expandedId === id;
|
||||
const daysOverdue = Math.floor((new Date().getTime() - new Date(item.Activity_Date).getTime()) / 86400000);
|
||||
|
||||
return (
|
||||
<div key={id} className={`rounded-lg border transition ${isExpanded ? 'border-brand-azure bg-brand-azure/5' : 'border-card-border hover:border-brand-azure/40'}`}>
|
||||
<div
|
||||
className="flex items-center gap-3 px-4 py-3 cursor-pointer"
|
||||
onClick={() => setExpandedId(isExpanded ? null : id)}
|
||||
>
|
||||
<div className={`w-2 h-2 rounded-full flex-shrink-0 ${daysOverdue > 7 ? 'bg-red-500' : daysOverdue > 3 ? 'bg-amber-500' : 'bg-blue-500'}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold truncate">{item.Account_Name}</span>
|
||||
<span className="px-2 py-0.5 rounded-full text-[10px] text-white font-medium" style={{ backgroundColor: TYPE_COLORS[item.Activity_Type] || CHART_COLORS.navy }}>
|
||||
{item.Activity_Type}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-[11px] text-muted mt-0.5">
|
||||
<span>Planned for {format(parseISO(item.Activity_Date), 'MMM d, yyyy')}</span>
|
||||
{item.Contact_Name && <span>with {item.Contact_Name}</span>}
|
||||
<span className={`font-medium ${daysOverdue > 7 ? 'text-red-600' : daysOverdue > 3 ? 'text-amber-600' : 'text-blue-600'}`}>
|
||||
{daysOverdue === 0 ? 'Due today' : `${daysOverdue}d overdue`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<svg className={`w-4 h-4 text-muted transition-transform ${isExpanded ? 'rotate-180' : ''}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="6 9 12 15 18 9" /></svg>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="px-4 pb-4 border-t border-card-border/50 pt-3 space-y-3">
|
||||
{item.Notes && (
|
||||
<div className="text-xs text-muted bg-white rounded-lg p-2 border border-card-border">
|
||||
<span className="font-medium text-foreground">Original notes: </span>{item.Notes}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-[10px] font-medium text-muted uppercase mb-1 block">Outcome</label>
|
||||
<select
|
||||
value={outcomes[id] || ''}
|
||||
onChange={e => setOutcomes(prev => ({ ...prev, [id]: e.target.value }))}
|
||||
className="w-full text-xs border border-card-border rounded-lg px-2.5 py-1.5 focus:outline-none focus:ring-2 focus:ring-brand-azure/30"
|
||||
>
|
||||
<option value="">Select outcome...</option>
|
||||
<option value="Meeting Held">Meeting Held</option>
|
||||
<option value="Meeting Rescheduled">Meeting Rescheduled</option>
|
||||
<option value="Positive Response">Positive Response</option>
|
||||
<option value="No Response">No Response</option>
|
||||
<option value="Left Message">Left Message</option>
|
||||
<option value="Proposal Sent">Proposal Sent</option>
|
||||
<option value="Follow-Up Required">Follow-Up Required</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-medium text-muted uppercase mb-1 block">Notes</label>
|
||||
<input
|
||||
type="text"
|
||||
value={noteInputs[id] || ''}
|
||||
onChange={e => setNoteInputs(prev => ({ ...prev, [id]: e.target.value }))}
|
||||
placeholder="What happened?"
|
||||
className="w-full text-xs border border-card-border rounded-lg px-2.5 py-1.5 focus:outline-none focus:ring-2 focus:ring-brand-azure/30"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={() => handleResolve(id, 'Completed')}
|
||||
disabled={saving}
|
||||
className="px-3 py-1.5 text-xs font-medium rounded-lg bg-green-600 text-white hover:bg-green-700 disabled:opacity-50 transition"
|
||||
>
|
||||
Mark Completed
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleResolve(id, 'Did Not Occur')}
|
||||
disabled={saving}
|
||||
className="px-3 py-1.5 text-xs font-medium rounded-lg bg-gray-500 text-white hover:bg-gray-600 disabled:opacity-50 transition"
|
||||
>
|
||||
Did Not Occur
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowNextStep(prev => ({ ...prev, [id]: !prev[id] }));
|
||||
if (!nextStepForms[id]) {
|
||||
setNextStepForms(prev => ({ ...prev, [id]: { date: getDefaultNextDate(), type: item.Activity_Type, contact: item.Contact_Name || '', notes: '' } }));
|
||||
}
|
||||
}}
|
||||
className="px-3 py-1.5 text-xs font-medium rounded-lg border border-brand-azure text-brand-azure hover:bg-brand-azure/10 transition"
|
||||
>
|
||||
Create Next Step
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showNextStep[id] && (
|
||||
<div className="bg-white rounded-lg border border-brand-azure/30 p-3 space-y-2">
|
||||
<div className="text-[10px] font-bold text-brand-navy uppercase tracking-wider">Next Step Activity</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||
<div>
|
||||
<label className="text-[10px] text-muted block mb-0.5">Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={nextStepForms[id]?.date || ''}
|
||||
onChange={e => setNextStepForms(prev => ({ ...prev, [id]: { ...prev[id], date: e.target.value } }))}
|
||||
className="w-full text-xs border border-card-border rounded px-2 py-1 focus:outline-none focus:ring-2 focus:ring-brand-azure/30"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-muted block mb-0.5">Type</label>
|
||||
<select
|
||||
value={nextStepForms[id]?.type || ''}
|
||||
onChange={e => setNextStepForms(prev => ({ ...prev, [id]: { ...prev[id], type: e.target.value } }))}
|
||||
className="w-full text-xs border border-card-border rounded px-2 py-1 focus:outline-none focus:ring-2 focus:ring-brand-azure/30"
|
||||
>
|
||||
{ACTIVITY_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-muted block mb-0.5">Contact</label>
|
||||
<input
|
||||
type="text"
|
||||
value={nextStepForms[id]?.contact || ''}
|
||||
onChange={e => setNextStepForms(prev => ({ ...prev, [id]: { ...prev[id], contact: e.target.value } }))}
|
||||
className="w-full text-xs border border-card-border rounded px-2 py-1 focus:outline-none focus:ring-2 focus:ring-brand-azure/30"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-muted block mb-0.5">Notes</label>
|
||||
<input
|
||||
type="text"
|
||||
value={nextStepForms[id]?.notes || ''}
|
||||
onChange={e => setNextStepForms(prev => ({ ...prev, [id]: { ...prev[id], notes: e.target.value } }))}
|
||||
className="w-full text-xs border border-card-border rounded px-2 py-1 focus:outline-none focus:ring-2 focus:ring-brand-azure/30"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleCreateNext(id, item.Account_Name, item.District_Name)}
|
||||
disabled={saving || !nextStepForms[id]?.date}
|
||||
className="px-3 py-1.5 text-xs font-medium rounded-lg bg-brand-azure text-white hover:bg-brand-azure/90 disabled:opacity-50 transition"
|
||||
>
|
||||
Create & Schedule
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ChartCard>
|
||||
|
||||
{plannedUpcoming.length > 0 && (
|
||||
<ChartCard title="Upcoming Planned" subtitle={`${plannedUpcoming.length} scheduled activities`}>
|
||||
<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">Date</th>
|
||||
<th className="text-left py-2 px-2 text-muted font-medium">Account</th>
|
||||
<th className="text-left py-2 px-2 text-muted font-medium">Type</th>
|
||||
<th className="text-left py-2 px-2 text-muted font-medium hidden sm:table-cell">Contact</th>
|
||||
<th className="text-left py-2 px-2 text-muted font-medium hidden md:table-cell">Notes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{plannedUpcoming.map((a, i) => {
|
||||
const daysUntil = Math.ceil((new Date(a.Activity_Date).getTime() - new Date().getTime()) / 86400000);
|
||||
return (
|
||||
<tr key={i} className="border-b border-card-border/50">
|
||||
<td className="py-2 px-2">
|
||||
<span className="text-muted">{format(parseISO(a.Activity_Date), 'MMM d')}</span>
|
||||
<span className="ml-1 text-[10px] text-blue-600 font-medium">
|
||||
{daysUntil === 0 ? 'Today' : daysUntil === 1 ? 'Tomorrow' : `in ${daysUntil}d`}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 px-2 font-medium">{a.Account_Name}</td>
|
||||
<td className="py-2 px-2">
|
||||
<span className="px-2 py-0.5 rounded-full text-[10px] text-white font-medium" style={{ backgroundColor: TYPE_COLORS[a.Activity_Type] || CHART_COLORS.navy }}>
|
||||
{a.Activity_Type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 px-2 text-muted hidden sm:table-cell">{a.Contact_Name || '—'}</td>
|
||||
<td className="py-2 px-2 text-muted hidden md:table-cell max-w-[200px] truncate">{a.Notes || '—'}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</ChartCard>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,7 +44,11 @@ export default function ExecutiveOverview() {
|
||||
const recentActivities = activities.filter(a => a.Activity_Date >= thirtyDaysAgo).length;
|
||||
const touchRate = totalAccounts > 0 ? (touchedAccounts / totalAccounts) * 100 : 0;
|
||||
|
||||
return { totalAccounts, touchedAccounts, touchRate, openPipeline, closedWon, coverageRatio, activeOpps, recentActivities };
|
||||
const todayStr = new Date().toISOString().split('T')[0];
|
||||
const meetingsScheduled = activities.filter(a => a.Status === 'Planned').length;
|
||||
const followUpsPending = activities.filter(a => a.Status === 'Planned' && a.Activity_Date <= todayStr).length;
|
||||
|
||||
return { totalAccounts, touchedAccounts, touchRate, openPipeline, closedWon, coverageRatio, activeOpps, recentActivities, meetingsScheduled, followUpsPending };
|
||||
}, [pipeline, accounts, activities, config]);
|
||||
|
||||
const allStatuses = useMemo(() => {
|
||||
@@ -115,7 +119,7 @@ export default function ExecutiveOverview() {
|
||||
<div>
|
||||
<PageHeader title="Executive Overview" />
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-7 gap-3 mb-6">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-9 gap-3 mb-6">
|
||||
<Scorecard label="Total Accounts" value={kpis.totalAccounts} />
|
||||
<Scorecard
|
||||
label="Accounts Touched"
|
||||
@@ -132,6 +136,8 @@ export default function ExecutiveOverview() {
|
||||
/>
|
||||
<Scorecard label="Active Opps" value={kpis.activeOpps} />
|
||||
<Scorecard label="Activities (30d)" value={kpis.recentActivities} />
|
||||
<Scorecard label="Meetings Scheduled" value={kpis.meetingsScheduled} color={kpis.meetingsScheduled > 0 ? 'green' : undefined} />
|
||||
<Scorecard label="Follow-Ups Pending" value={kpis.followUpsPending} color={kpis.followUpsPending > 0 ? 'amber' : 'green'} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4">
|
||||
|
||||
@@ -310,6 +310,7 @@ const TABLE_FIELDS: Record<string, FieldDef[]> = {
|
||||
{ key: 'Persona', label: 'Persona', options: FIELD_OPTIONS.Persona },
|
||||
{ key: 'Outcome', label: 'Outcome', options: FIELD_OPTIONS.Outcome },
|
||||
{ key: 'Logged_By', label: 'Logged By' },
|
||||
{ key: 'Status', label: 'Status', options: ['Planned', 'Completed', 'Did Not Occur'] },
|
||||
],
|
||||
targets: [
|
||||
{ key: 'Account_Name', label: 'Account Name', required: true },
|
||||
|
||||
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import {
|
||||
upsertAccount, deleteAccount,
|
||||
upsertPipeline, deletePipeline,
|
||||
addActivity, deleteActivity,
|
||||
addActivity, deleteActivity, updateActivityStatus,
|
||||
upsertTarget, deleteTarget,
|
||||
getDbStats, getDashboardData,
|
||||
} from '@/lib/db';
|
||||
@@ -35,6 +35,11 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ success: true, stats: getDbStats() });
|
||||
}
|
||||
|
||||
if (action === 'update_status') {
|
||||
updateActivityStatus(record.id, { Status: record.Status, Outcome: record.Outcome, Notes: record.Notes });
|
||||
return NextResponse.json({ success: true, stats: getDbStats() });
|
||||
}
|
||||
|
||||
if (action === 'list') {
|
||||
const data = getDashboardData();
|
||||
const tableData = {
|
||||
|
||||
@@ -52,6 +52,12 @@ function migrateSchema(db: Database.Database) {
|
||||
if (!colNames.has('CPM')) {
|
||||
db.exec("ALTER TABLE accounts ADD COLUMN CPM TEXT DEFAULT NULL");
|
||||
}
|
||||
|
||||
const actCols = db.prepare("PRAGMA table_info(activities)").all() as { name: string }[];
|
||||
const actColNames = new Set(actCols.map(c => c.name));
|
||||
if (!actColNames.has('Status')) {
|
||||
db.exec("ALTER TABLE activities ADD COLUMN Status TEXT DEFAULT NULL");
|
||||
}
|
||||
}
|
||||
|
||||
function initSchema(db: Database.Database) {
|
||||
@@ -120,7 +126,8 @@ function initSchema(db: Database.Database) {
|
||||
Channel TEXT,
|
||||
Persona TEXT,
|
||||
Outcome TEXT,
|
||||
Logged_By TEXT
|
||||
Logged_By TEXT,
|
||||
Status TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS implementations (
|
||||
@@ -243,7 +250,7 @@ export function getDashboardData(): DashboardData {
|
||||
return {
|
||||
pipeline: db.prepare('SELECT * FROM pipeline').all() as PipelineRecord[],
|
||||
accounts: db.prepare('SELECT * FROM accounts').all() as AccountRecord[],
|
||||
activities: db.prepare('SELECT id, Activity_Date, Activity_Type, Account_Name, District_Name, Contact_Name, Notes, Play, Channel, Persona, Outcome, Logged_By FROM activities ORDER BY Activity_Date DESC').all() as (ActivityRecord & { id: number })[],
|
||||
activities: db.prepare('SELECT id, Activity_Date, Activity_Type, Account_Name, District_Name, Contact_Name, Notes, Play, Channel, Persona, Outcome, Logged_By, Status FROM activities ORDER BY Activity_Date DESC').all() as (ActivityRecord & { id: number })[],
|
||||
targets: buildTargetsFromImplementations(db),
|
||||
implementations: db.prepare('SELECT * FROM implementations WHERE Account_Name IS NOT NULL').all() as ImplementationRecord[],
|
||||
metricTargets: db.prepare('SELECT * FROM metric_targets').all() as MetricTarget[],
|
||||
@@ -346,11 +353,15 @@ export function addActivity(record: {
|
||||
Persona?: string | null;
|
||||
Outcome?: string | null;
|
||||
Logged_By?: string | null;
|
||||
Status?: string | null;
|
||||
}) {
|
||||
const db = getDb();
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const status = record.Status || (record.Activity_Date > today ? 'Planned' : null);
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO activities (Activity_Date, Activity_Type, Account_Name, District_Name, Contact_Name, Notes, Play, Channel, Persona, Outcome, Logged_By)
|
||||
VALUES (@Activity_Date, @Activity_Type, @Account_Name, @District_Name, @Contact_Name, @Notes, @Play, @Channel, @Persona, @Outcome, @Logged_By)
|
||||
INSERT INTO activities (Activity_Date, Activity_Type, Account_Name, District_Name, Contact_Name, Notes, Play, Channel, Persona, Outcome, Logged_By, Status)
|
||||
VALUES (@Activity_Date, @Activity_Type, @Account_Name, @District_Name, @Contact_Name, @Notes, @Play, @Channel, @Persona, @Outcome, @Logged_By, @Status)
|
||||
`).run({
|
||||
Activity_Date: record.Activity_Date,
|
||||
Activity_Type: record.Activity_Type,
|
||||
@@ -363,10 +374,28 @@ export function addActivity(record: {
|
||||
Persona: record.Persona || null,
|
||||
Outcome: record.Outcome || null,
|
||||
Logged_By: record.Logged_By || null,
|
||||
Status: status,
|
||||
});
|
||||
|
||||
if (!status || status !== 'Planned') {
|
||||
updateLastTouched(record.Account_Name, record.Activity_Date);
|
||||
}
|
||||
}
|
||||
|
||||
export function updateActivityStatus(id: number, updates: { Status: string; Outcome?: string | null; Notes?: string | null }) {
|
||||
const db = getDb();
|
||||
const params: Record<string, unknown> = { id, Status: updates.Status };
|
||||
const sets = ['Status = @Status'];
|
||||
if (updates.Outcome !== undefined) { sets.push('Outcome = @Outcome'); params.Outcome = updates.Outcome ?? null; }
|
||||
if (updates.Notes !== undefined) { sets.push('Notes = @Notes'); params.Notes = updates.Notes ?? null; }
|
||||
|
||||
db.prepare(`UPDATE activities SET ${sets.join(', ')} WHERE id = @id`).run(params);
|
||||
|
||||
if (updates.Status === 'Completed') {
|
||||
const act = db.prepare('SELECT Account_Name, Activity_Date FROM activities WHERE id = ?').get(id) as { Account_Name: string; Activity_Date: string } | undefined;
|
||||
if (act) updateLastTouched(act.Account_Name, act.Activity_Date);
|
||||
}
|
||||
}
|
||||
|
||||
export function importData(data: {
|
||||
accounts?: Record<string, string | null>[];
|
||||
|
||||
@@ -190,6 +190,7 @@ function generateActivities(accounts: AccountRecord[]): ActivityRecord[] {
|
||||
Persona: null,
|
||||
Outcome: null,
|
||||
Logged_By: null,
|
||||
Status: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ export interface ActivityRecord {
|
||||
Persona: string | null;
|
||||
Outcome: string | null;
|
||||
Logged_By: string | null;
|
||||
Status: 'Planned' | 'Completed' | 'Did Not Occur' | null;
|
||||
}
|
||||
|
||||
export interface TargetRecord {
|
||||
|
||||
Reference in New Issue
Block a user