Add admin goals management page

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-09-01 11:01:46 -04:00
parent a88821990f
commit 4266fef8dc

View File

@@ -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<DistrictTarget, 'activities_per_week' | 'accounts_touched' | 'pipeline_generated'>; 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<string, { activities_per_week: number; accounts_touched: number; pipeline_generated: number }> {
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<string, { activities_per_week: number; accounts_touched: number; pipeline_generated: number }> = {};
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<Record<string, DistrictTarget>>({});
const [actuals, setActuals] = useState<Record<string, { activities_per_week: number; accounts_touched: number; pipeline_generated: number }>>({});
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<string, DistrictTarget> = {};
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 (
<div className="min-h-screen bg-[#f5f6f8]">
{/* Header */}
<header className="bg-[#1B1D36] text-white px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href="/admin" className="text-white/70 hover:text-white text-sm">
&larr; Back to Admin
</Link>
<h1 className="text-lg font-bold">Goal Setting &amp; Progress</h1>
</div>
</header>
{/* Message bar */}
{message && (
<div
className={`px-6 py-3 text-sm font-medium ${
message.type === 'success' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}
>
{message.text}
</div>
)}
<div className="p-6 max-w-6xl mx-auto space-y-8">
{/* Quarter Selector */}
<div className="flex items-center gap-4">
<label className="text-sm font-semibold text-gray-700">Quarter:</label>
<select
value={selectedQuarter}
onChange={(e) => setSelectedQuarter(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm bg-white focus:outline-none focus:ring-2 focus:ring-[#0098C7]/30 focus:border-[#0098C7]"
>
{quarterOptions.map((q) => (
<option key={q.value} value={q.value}>
{q.label}
</option>
))}
</select>
</div>
{/* Target Setting Grid */}
<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 flex items-center justify-between">
<h2 className="text-sm font-bold text-[#1B1D36]">District Targets</h2>
<button
onClick={handleSave}
disabled={saving}
className="px-4 py-2 bg-[#0098C7] text-white text-sm font-semibold rounded-lg hover:bg-[#007ba3] disabled:opacity-50 transition"
>
{saving ? 'Saving...' : 'Save Targets'}
</button>
</div>
{loadingTargets ? (
<div className="p-6 text-gray-500 text-sm">Loading targets...</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-3 font-semibold text-gray-700 w-48">District</th>
{METRICS.map((m) => (
<th key={m.key} className="text-left px-4 py-3 font-semibold text-gray-700">
{m.label}
</th>
))}
</tr>
</thead>
<tbody>
{DISTRICTS.map((district) => (
<tr key={district} className="border-b border-gray-100 hover:bg-gray-50">
<td className="px-4 py-3 font-medium text-gray-800">{district}</td>
{METRICS.map((m) => (
<td key={m.key} className="px-4 py-3">
<div className="flex items-center gap-1">
{m.prefix && <span className="text-gray-400 text-sm">{m.prefix}</span>}
<input
type="number"
min="0"
step={m.key === 'activities_per_week' ? '0.5' : '1'}
value={targets[district]?.[m.key] ?? 0}
onChange={(e) => 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]"
/>
</div>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Progress Dashboard */}
<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">
<h2 className="text-sm font-bold text-[#1B1D36]">Progress vs. Targets</h2>
</div>
{loadingActuals || loadingTargets ? (
<div className="p-6 text-gray-500 text-sm">Loading progress data...</div>
) : (
<div className="divide-y divide-gray-100">
{DISTRICTS.map((district) => {
const target = targets[district];
const actual = actuals[district] || { activities_per_week: 0, accounts_touched: 0, pipeline_generated: 0 };
return (
<div key={district} className="p-5">
<h3 className="text-sm font-bold text-[#1B1D36] mb-4">{district}</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{METRICS.map((m) => {
const targetVal = target?.[m.key] ?? 0;
const actualVal = actual[m.key] ?? 0;
const pct = progressPct(actualVal, targetVal);
return (
<div key={m.key}>
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-medium text-gray-600">{m.label}</span>
<span className={`text-xs font-bold ${progressTextColor(pct)}`}>
{pct}%
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-3 overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${progressColor(pct)}`}
style={{ width: `${pct}%` }}
/>
</div>
<div className="flex items-center justify-between mt-1">
<span className="text-xs text-gray-500">
{formatValue(actualVal, m.prefix)} actual
</span>
<span className="text-xs text-gray-400">
{formatValue(targetVal, m.prefix)} target
</span>
</div>
</div>
);
})}
</div>
</div>
);
})}
</div>
)}
</div>
</div>
</div>
);
}