From 1a1cf7e652e67b2df54d524680272c809382f3ea Mon Sep 17 00:00:00 2001 From: Chris Olson Date: Tue, 1 Sep 2026 10:22:20 -0400 Subject: [PATCH] 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 --- src/app/(dashboard)/activity/page.tsx | 362 +++++++++++++++++++++++++- src/app/(dashboard)/page.tsx | 10 +- src/app/admin/page.tsx | 1 + src/app/api/admin/route.ts | 7 +- src/lib/db.ts | 39 ++- src/lib/mock-data.ts | 1 + src/types/data.ts | 1 + 7 files changed, 409 insertions(+), 12 deletions(-) diff --git a/src/app/(dashboard)/activity/page.tsx b/src/app/(dashboard)/activity/page.tsx index 765ede5..acc8aca 100644 --- a/src/app/(dashboard)/activity/page.tsx +++ b/src/app/(dashboard)/activity/page.tsx @@ -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('all'); const [typeFilter, setTypeFilter] = useState>(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(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) => { + 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 (
+ {/* Subview tabs */} +
+ {([ + { key: 'all' as SubView, label: 'All Activities' }, + { key: 'follow-ups' as SubView, label: 'Follow-Ups', count: followUpItems.length }, + ]).map(tab => ( + + ))} +
+ + {subView === 'follow-ups' && ( + + )} + + {subView === 'all' && <> {/* Type filter chips */}
{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)} > - {format(parseISO(a.Activity_Date), 'MMM d')} + + {format(parseISO(a.Activity_Date), 'MMM d')} + {a.Status === 'Planned' && a.Activity_Date > today && ( + Planned + )} + {a.Status === 'Planned' && a.Activity_Date <= today && ( + Follow-Up + )} + {a.Account_Name} @@ -428,6 +532,7 @@ export default function ActivityDeepDive() {
)} + } {/* Activity Detail Overlay */} {selectedActivity && overlayData && ( @@ -664,3 +769,252 @@ export default function ActivityDeepDive() {
); } + +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; + createNextStep: (accountName: string, districtName: string, form: Record) => Promise; + saving: boolean; +}) { + const [expandedId, setExpandedId] = useState(null); + const [outcomes, setOutcomes] = useState>({}); + const [noteInputs, setNoteInputs] = useState>({}); + const [nextStepForms, setNextStepForms] = useState>({}); + const [showNextStep, setShowNextStep] = useState>({}); + + 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 ( +
+ + {followUpItems.length === 0 ? ( +
+ No pending follow-ups. You're all caught up! +
+ ) : ( +
+ {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 ( +
+
setExpandedId(isExpanded ? null : id)} + > +
7 ? 'bg-red-500' : daysOverdue > 3 ? 'bg-amber-500' : 'bg-blue-500'}`} /> +
+
+ {item.Account_Name} + + {item.Activity_Type} + +
+
+ Planned for {format(parseISO(item.Activity_Date), 'MMM d, yyyy')} + {item.Contact_Name && with {item.Contact_Name}} + 7 ? 'text-red-600' : daysOverdue > 3 ? 'text-amber-600' : 'text-blue-600'}`}> + {daysOverdue === 0 ? 'Due today' : `${daysOverdue}d overdue`} + +
+
+ +
+ + {isExpanded && ( +
+ {item.Notes && ( +
+ Original notes: {item.Notes} +
+ )} + +
+
+ + +
+
+ + 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" + /> +
+
+ +
+ + + +
+ + {showNextStep[id] && ( +
+
Next Step Activity
+
+
+ + 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" + /> +
+
+ + +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ +
+ )} +
+ )} +
+ ); + })} +
+ )} + + + {plannedUpcoming.length > 0 && ( + +
+ + + + + + + + + + + + {plannedUpcoming.map((a, i) => { + const daysUntil = Math.ceil((new Date(a.Activity_Date).getTime() - new Date().getTime()) / 86400000); + return ( + + + + + + + + ); + })} + +
DateAccountTypeContactNotes
+ {format(parseISO(a.Activity_Date), 'MMM d')} + + {daysUntil === 0 ? 'Today' : daysUntil === 1 ? 'Tomorrow' : `in ${daysUntil}d`} + + {a.Account_Name} + + {a.Activity_Type} + + {a.Contact_Name || '—'}{a.Notes || '—'}
+
+
+ )} +
+ ); +} diff --git a/src/app/(dashboard)/page.tsx b/src/app/(dashboard)/page.tsx index 70068f6..98e0c40 100644 --- a/src/app/(dashboard)/page.tsx +++ b/src/app/(dashboard)/page.tsx @@ -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() {
-
+
+ 0 ? 'green' : undefined} /> + 0 ? 'amber' : 'green'} />
diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 2e586dd..52c41f6 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -310,6 +310,7 @@ const TABLE_FIELDS: Record = { { 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 }, diff --git a/src/app/api/admin/route.ts b/src/app/api/admin/route.ts index c643c1c..cd28a79 100644 --- a/src/app/api/admin/route.ts +++ b/src/app/api/admin/route.ts @@ -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 = { diff --git a/src/lib/db.ts b/src/lib/db.ts index 9b87261..9fb252a 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -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,9 +374,27 @@ export function addActivity(record: { Persona: record.Persona || null, Outcome: record.Outcome || null, Logged_By: record.Logged_By || null, + Status: status, }); - updateLastTouched(record.Account_Name, record.Activity_Date); + 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 = { 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: { diff --git a/src/lib/mock-data.ts b/src/lib/mock-data.ts index 70e721d..e56c3cb 100644 --- a/src/lib/mock-data.ts +++ b/src/lib/mock-data.ts @@ -190,6 +190,7 @@ function generateActivities(accounts: AccountRecord[]): ActivityRecord[] { Persona: null, Outcome: null, Logged_By: null, + Status: null, }); } } diff --git a/src/types/data.ts b/src/types/data.ts index 8c3b091..650fb3e 100644 --- a/src/types/data.ts +++ b/src/types/data.ts @@ -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 {