diff --git a/src/app/admin/goals/page.tsx b/src/app/admin/goals/page.tsx new file mode 100644 index 0000000..69d4e82 --- /dev/null +++ b/src/app/admin/goals/page.tsx @@ -0,0 +1,426 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import Link from 'next/link'; +import { + startOfQuarter, + endOfQuarter, + addQuarters, + parseISO, + isWithinInterval, + differenceInWeeks, + format, +} from 'date-fns'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface DistrictTarget { + id?: number; + District_Name: string; + quarter: string; + activities_per_week: number; + accounts_touched: number; + pipeline_generated: number; +} + +interface ActivityRecord { + Activity_Date: string; + Account_Name: string; + District_Name: string; + [key: string]: unknown; +} + +interface PipelineRecord { + District_Name: string; + Amount_USD: number; + Created_Date: string; + [key: string]: unknown; +} + +interface DashboardData { + activities: ActivityRecord[]; + pipeline: PipelineRecord[]; + [key: string]: unknown; +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const DISTRICTS = ['SE-SUNSHINE', 'SE-PEACHTREE', 'SE-MISS-VALLEY', 'SE-MID-ATL']; + +const METRICS: { key: keyof Pick; label: string; prefix?: string }[] = [ + { key: 'activities_per_week', label: 'Activities / Week' }, + { key: 'accounts_touched', label: 'Accounts Touched' }, + { key: 'pipeline_generated', label: 'Pipeline Generated', prefix: '$' }, +]; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Build quarter options: current + next 2 */ +function buildQuarterOptions(): { label: string; value: string }[] { + const now = new Date(); + const results: { label: string; value: string }[] = []; + for (let i = 0; i < 3; i++) { + const d = addQuarters(now, i); + const q = Math.ceil((d.getMonth() + 1) / 3); + const year = d.getFullYear(); + results.push({ + label: `Q${q} FY${year - 2000}`, + value: `${year}-Q${q}`, + }); + } + return results; +} + +/** Parse "2026-Q3" into { start, end } dates */ +function quarterRange(qValue: string): { start: Date; end: Date } { + const [yearStr, qStr] = qValue.split('-Q'); + const year = Number(yearStr); + const q = Number(qStr); + const monthStart = (q - 1) * 3; // 0-indexed + const refDate = new Date(year, monthStart, 15); + return { + start: startOfQuarter(refDate), + end: endOfQuarter(refDate), + }; +} + +/** Compute actuals from dashboard data for a given quarter */ +function computeActuals( + data: DashboardData, + quarter: string, +): Record { + const { start, end } = quarterRange(quarter); + const now = new Date(); + const effectiveEnd = now < end ? now : end; + const weeksElapsed = Math.max(1, differenceInWeeks(effectiveEnd, start) || 1); + + const result: Record = {}; + + for (const district of DISTRICTS) { + const districtActivities = (data.activities || []).filter((a) => { + if (a.District_Name !== district) return false; + try { + const d = parseISO(a.Activity_Date); + return isWithinInterval(d, { start, end }); + } catch { + return false; + } + }); + + const activitiesPerWeek = districtActivities.length / weeksElapsed; + + const uniqueAccounts = new Set(districtActivities.map((a) => a.Account_Name)); + + const districtPipeline = (data.pipeline || []).filter((p) => { + if (p.District_Name !== district) return false; + try { + const d = parseISO(p.Created_Date); + return isWithinInterval(d, { start, end }); + } catch { + return false; + } + }); + + const pipelineGenerated = districtPipeline.reduce((sum, p) => sum + (Number(p.Amount_USD) || 0), 0); + + result[district] = { + activities_per_week: Math.round(activitiesPerWeek * 10) / 10, + accounts_touched: uniqueAccounts.size, + pipeline_generated: Math.round(pipelineGenerated), + }; + } + + return result; +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export default function GoalsPage() { + const quarterOptions = buildQuarterOptions(); + const [selectedQuarter, setSelectedQuarter] = useState(quarterOptions[0].value); + const [targets, setTargets] = useState>({}); + const [actuals, setActuals] = useState>({}); + const [saving, setSaving] = useState(false); + const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); + const [loadingTargets, setLoadingTargets] = useState(true); + const [loadingActuals, setLoadingActuals] = useState(true); + + const showMessage = (type: 'success' | 'error', text: string) => { + setMessage({ type, text }); + setTimeout(() => setMessage(null), 4000); + }; + + // Load targets for the selected quarter + const loadTargets = useCallback(async () => { + setLoadingTargets(true); + try { + const res = await fetch('/api/phase2', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'targets.list', quarter: selectedQuarter }), + }); + const json = await res.json(); + const map: Record = {}; + for (const district of DISTRICTS) { + const existing = (json.data || []).find((t: DistrictTarget) => t.District_Name === district); + map[district] = existing || { + District_Name: district, + quarter: selectedQuarter, + activities_per_week: 0, + accounts_touched: 0, + pipeline_generated: 0, + }; + } + setTargets(map); + } catch (err) { + showMessage('error', 'Failed to load targets: ' + String(err)); + } finally { + setLoadingTargets(false); + } + }, [selectedQuarter]); + + // Load actuals from live data + const loadActuals = useCallback(async () => { + setLoadingActuals(true); + try { + const res = await fetch(`/api/sheets?bust=${Date.now()}`); + const data: DashboardData = await res.json(); + setActuals(computeActuals(data, selectedQuarter)); + } catch (err) { + showMessage('error', 'Failed to load actuals: ' + String(err)); + } finally { + setLoadingActuals(false); + } + }, [selectedQuarter]); + + useEffect(() => { + loadTargets(); + loadActuals(); + }, [loadTargets, loadActuals]); + + // Handle input changes + const handleInputChange = (district: string, metric: keyof DistrictTarget, value: string) => { + setTargets((prev) => ({ + ...prev, + [district]: { + ...prev[district], + [metric]: value === '' ? 0 : Number(value), + }, + })); + }; + + // Save all targets + const handleSave = async () => { + setSaving(true); + try { + for (const district of DISTRICTS) { + const t = targets[district]; + await fetch('/api/phase2', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'targets.upsert', + District_Name: district, + quarter: selectedQuarter, + activities_per_week: t.activities_per_week, + accounts_touched: t.accounts_touched, + pipeline_generated: t.pipeline_generated, + }), + }); + } + showMessage('success', 'Targets saved successfully.'); + loadTargets(); + } catch (err) { + showMessage('error', 'Failed to save targets: ' + String(err)); + } finally { + setSaving(false); + } + }; + + // Progress bar helper + const progressPct = (actual: number, target: number): number => { + if (!target || target === 0) return 0; + return Math.min(100, Math.round((actual / target) * 100)); + }; + + const progressColor = (pct: number): string => { + if (pct >= 75) return 'bg-green-500'; + if (pct >= 50) return 'bg-amber-500'; + return 'bg-red-500'; + }; + + const progressTextColor = (pct: number): string => { + if (pct >= 75) return 'text-green-700'; + if (pct >= 50) return 'text-amber-700'; + return 'text-red-700'; + }; + + const formatValue = (value: number, prefix?: string) => { + if (prefix === '$') { + return `$${value.toLocaleString()}`; + } + return value.toLocaleString(); + }; + + return ( +
+ {/* Header */} +
+
+ + ← Back to Admin + +

Goal Setting & Progress

+
+
+ + {/* Message bar */} + {message && ( +
+ {message.text} +
+ )} + +
+ {/* Quarter Selector */} +
+ + +
+ + {/* Target Setting Grid */} +
+
+

District Targets

+ +
+ + {loadingTargets ? ( +
Loading targets...
+ ) : ( +
+ + + + + {METRICS.map((m) => ( + + ))} + + + + {DISTRICTS.map((district) => ( + + + {METRICS.map((m) => ( + + ))} + + ))} + +
District + {m.label} +
{district} +
+ {m.prefix && {m.prefix}} + handleInputChange(district, m.key, e.target.value)} + className="w-32 px-2.5 py-1.5 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-[#0098C7]/30 focus:border-[#0098C7]" + /> +
+
+
+ )} +
+ + {/* Progress Dashboard */} +
+
+

Progress vs. Targets

+
+ + {loadingActuals || loadingTargets ? ( +
Loading progress data...
+ ) : ( +
+ {DISTRICTS.map((district) => { + const target = targets[district]; + const actual = actuals[district] || { activities_per_week: 0, accounts_touched: 0, pipeline_generated: 0 }; + + return ( +
+

{district}

+
+ {METRICS.map((m) => { + const targetVal = target?.[m.key] ?? 0; + const actualVal = actual[m.key] ?? 0; + const pct = progressPct(actualVal, targetVal); + + return ( +
+
+ {m.label} + + {pct}% + +
+
+
+
+
+ + {formatValue(actualVal, m.prefix)} actual + + + {formatValue(targetVal, m.prefix)} target + +
+
+ ); + })} +
+
+ ); + })} +
+ )} +
+
+
+ ); +}