Add admin goals management page
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
426
src/app/admin/goals/page.tsx
Normal file
426
src/app/admin/goals/page.tsx
Normal 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">
|
||||||
|
← Back to Admin
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-lg font-bold">Goal Setting & 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user