'use client'; import { useState, useEffect, useMemo, useCallback } from 'react'; import { PageHeader } from '@/components/ui/PageHeader'; import { ChartCard } from '@/components/ui/ChartCard'; import { Scorecard } from '@/components/ui/Scorecard'; import { formatCurrency, CHART_COLORS } from '@/lib/formatters'; import { useData } from '@/lib/data-context'; import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, } from 'recharts'; /* ---------- Types ---------- */ interface ForecastOverride { id: string; Opportunity_ID: string; override_date: string; original_amount: number; override_amount: number; original_category: string; override_category: string; original_close_date?: string; override_close_date?: string; reason?: string; } interface ForecastLock { id: string; lock_date: string; quarter: string; locked_by: string; notes: string; total_commit: number; total_best_case: number; total_pipeline: number; deals_json: string; } interface PipelineDeal { Opportunity_ID: string; Account_Name: string; District_Name: string; Stage: string; Amount_USD: number; Forecast_Category: string; Expected_Close_Date: string; Probability_Pct: number; } /* ---------- Constants ---------- */ const QUARTERS = ['Q1 FY27', 'Q2 FY27', 'Q3 FY27', 'Q4 FY27']; const DEFAULT_QUOTA = 2_000_000; const CLOSED_STAGES = ['06-Closed Won', '07-Closed Lost']; const CATEGORY_ORDER: Record = { Commit: 0, 'Best Case': 1, Pipeline: 2 }; function currentQuarter(): string { // Today is ~2026-09-01 which maps to Q3 FY27 return 'Q3 FY27'; } /* ---------- Helpers ---------- */ async function apiFetch(action: string, body: Record = {}): Promise { const res = await fetch('/api/phase2', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action, ...body }), }); if (!res.ok) throw new Error(`API error: ${res.status}`); return res.json(); } /* ---------- Sub-components ---------- */ function LockDialog({ open, message, notes, onNotesChange, onConfirm, onCancel, confirming, }: { open: boolean; message: string; notes: string; onNotesChange: (v: string) => void; onConfirm: () => void; onCancel: () => void; confirming: boolean; }) { if (!open) return null; return (

Lock Forecast

{message}

onNotesChange(e.target.value)} className="w-full bg-card-bg border border-card-border rounded-lg px-3 py-2 text-sm text-foreground placeholder:text-muted mb-6" />
); } function InlineEdit({ value, type, options, onSave, }: { value: string; type: 'text' | 'number' | 'date' | 'select'; options?: string[]; onSave: (v: string) => void; }) { const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(value); useEffect(() => setDraft(value), [value]); if (!editing) { return ( ); } const commit = () => { if (draft !== value) onSave(draft); setEditing(false); }; if (type === 'select') { return ( ); } return ( setDraft(e.target.value)} onBlur={commit} onKeyDown={(e) => e.key === 'Enter' && commit()} className="bg-card-bg border border-card-border rounded px-2 py-1 text-xs text-foreground w-28" /> ); } /* ---------- Main Page ---------- */ export default function ForecastWorkspacePage() { const { filtered } = useData(); const { pipeline } = filtered; const [quarter, setQuarter] = useState(currentQuarter); const [overrides, setOverrides] = useState([]); const [locks, setLocks] = useState([]); const [loading, setLoading] = useState(true); const [lockDialogOpen, setLockDialogOpen] = useState(false); const [locking, setLocking] = useState(false); const [lockNotes, setLockNotes] = useState(''); /* -- Load overrides & locks -- */ const loadData = useCallback(async () => { setLoading(true); try { const [ovRes, lockRes] = await Promise.all([ apiFetch<{ data: ForecastOverride[] }>('forecast.overrides'), apiFetch<{ data: ForecastLock[] }>('forecast.locks', { quarter }), ]); setOverrides(ovRes.data ?? []); setLocks(lockRes.data ?? []); } catch { // silently handle — data will be empty } finally { setLoading(false); } }, [quarter]); useEffect(() => { loadData(); }, [loadData]); /* -- Build effective deals: pipeline filtered to open, with overrides applied -- */ const overrideMap = useMemo(() => { const m = new Map(); for (const o of overrides) m.set(o.Opportunity_ID, o); return m; }, [overrides]); const openDeals = useMemo(() => { return (pipeline as PipelineDeal[]) .filter((d) => !CLOSED_STAGES.includes(d.Stage)) .map((d) => { const ov = overrideMap.get(d.Opportunity_ID); return { ...d, Amount_USD: ov ? ov.override_amount : d.Amount_USD, Forecast_Category: ov ? ov.override_category : d.Forecast_Category, Expected_Close_Date: ov?.override_close_date ?? d.Expected_Close_Date, _overridden: !!ov, _original: d, }; }) .filter((d) => ['Commit', 'Best Case', 'Pipeline'].includes(d.Forecast_Category)); }, [pipeline, overrideMap]); /* -- Aggregates -- */ const totals = useMemo(() => { let commit = 0, bestCase = 0, pipe = 0; for (const d of openDeals) { if (d.Forecast_Category === 'Commit') commit += d.Amount_USD; else if (d.Forecast_Category === 'Best Case') bestCase += d.Amount_USD; else if (d.Forecast_Category === 'Pipeline') pipe += d.Amount_USD; } const coverage = DEFAULT_QUOTA > 0 ? (commit + bestCase) / DEFAULT_QUOTA : 0; return { commit, bestCase, pipeline: pipe, coverage }; }, [openDeals]); /* -- Grouped deals -- */ const grouped = useMemo(() => { const groups: Record = { Commit: [], 'Best Case': [], Pipeline: [] }; for (const d of openDeals) { if (groups[d.Forecast_Category]) groups[d.Forecast_Category].push(d); } // Sort each group by amount desc for (const k of Object.keys(groups)) { groups[k].sort((a, b) => b.Amount_USD - a.Amount_USD); } return groups; }, [openDeals]); /* -- Chart data: stacked by district -- */ const chartData = useMemo(() => { const byDistrict: Record = {}; for (const d of openDeals) { if (!byDistrict[d.District_Name]) { byDistrict[d.District_Name] = { Commit: 0, 'Best Case': 0, Pipeline: 0 }; } const cat = d.Forecast_Category as 'Commit' | 'Best Case' | 'Pipeline'; byDistrict[d.District_Name][cat] += d.Amount_USD; } return Object.entries(byDistrict) .map(([name, vals]) => ({ district: name, ...vals })) .sort((a, b) => (b.Commit + b['Best Case'] + b.Pipeline) - (a.Commit + a['Best Case'] + a.Pipeline)); }, [openDeals]); /* -- Override handler -- */ const handleOverride = useCallback( async (deal: (typeof openDeals)[0], field: 'Amount_USD' | 'Forecast_Category' | 'Expected_Close_Date', newValue: string) => { const orig = deal._original; const current = { ...deal }; if (field === 'Amount_USD') current.Amount_USD = Number(newValue); else if (field === 'Forecast_Category') current.Forecast_Category = newValue; else if (field === 'Expected_Close_Date') current.Expected_Close_Date = newValue; try { await apiFetch('forecast.override', { Opportunity_ID: orig.Opportunity_ID, original_amount: orig.Amount_USD, override_amount: field === 'Amount_USD' ? Number(newValue) : current.Amount_USD, original_category: orig.Forecast_Category, override_category: field === 'Forecast_Category' ? newValue : current.Forecast_Category, original_close_date: orig.Expected_Close_Date, override_close_date: field === 'Expected_Close_Date' ? newValue : current.Expected_Close_Date, reason: 'Forecast workspace edit', }); await loadData(); } catch { // silently handle } }, [loadData], ); /* -- Lock handler -- */ const handleLock = useCallback(async () => { setLocking(true); try { await apiFetch('forecast.lock', { quarter, notes: lockNotes }); setLockDialogOpen(false); setLockNotes(''); await loadData(); } catch { // silently handle } finally { setLocking(false); } }, [quarter, lockNotes, loadData]); /* ---------- Render ---------- */ return (
{/* Quarter selector & Lock button */}
{/* Scorecards */}
= 1 ? 'green' : 'red'} />
{/* Loading indicator */} {loading && (
Loading forecast data...
)} {/* Deal table grouped by category */}
{(['Commit', 'Best Case', 'Pipeline'] as const).map((cat) => (

{cat}

({grouped[cat]?.length ?? 0} deals ·{' '} {formatCurrency(grouped[cat]?.reduce((s, d) => s + d.Amount_USD, 0) ?? 0, true)})
{(grouped[cat] ?? []).map((deal) => ( ))} {(grouped[cat] ?? []).length === 0 && ( )}
Account Opportunity Stage Amount Category Close Date
{deal.Account_Name} {deal._overridden && ( edited )} {deal.Opportunity_ID} {deal.Stage} handleOverride(deal, 'Amount_USD', v)} /> handleOverride(deal, 'Forecast_Category', v)} /> handleOverride(deal, 'Expected_Close_Date', v)} />
No deals
))}
{/* Stacked bar chart by district */}
formatCurrency(v, true)} tick={{ fontSize: 11, fill: 'var(--color-muted)' }} /> formatCurrency(value as number)} contentStyle={{ backgroundColor: 'var(--color-card-bg)', border: '1px solid var(--color-card-border)', borderRadius: '8px', fontSize: '12px', }} />
{/* Forecast History */} {locks.length === 0 && !loading ? (

No forecast locks recorded yet for {quarter}.

) : (
{locks.map((lock) => { const commitDelta = totals.commit - lock.total_commit; return ( ); })}
Lock Date Locked By Commit Best Case Pipeline Notes vs Current
{new Date(lock.lock_date).toLocaleDateString()} {lock.locked_by} {formatCurrency(lock.total_commit, true)} {formatCurrency(lock.total_best_case, true)} {formatCurrency(lock.total_pipeline, true)} {lock.notes || '—'} = 0 ? 'text-success' : 'text-danger'}> {commitDelta >= 0 ? '+' : ''} {formatCurrency(commitDelta, true)}
)}
{/* Lock Confirm Dialog */} { setLockDialogOpen(false); setLockNotes(''); }} confirming={locking} />
); }