Add Phase 3: Analytics & Forecast Intelligence
- Pipeline Movement Tracker with waterfall chart and snapshot capture - Forecast Call Workspace with inline deal overrides and lock history - Forecast Accuracy tracking against locked snapshots - Win/Loss Analysis with rate breakdowns by stage, competitor, deal size, play - Playbook Effectiveness dashboard with conversion funnel and step drop-off - Account engagement trends with 12-week sparklines and direction badges - Analytics sidebar navigation with collapsible submenu - Data layer: forecast_locks, forecast_overrides tables, pipeline movement API - Fix Turbopack function hoisting issues in Sidebar and db.ts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
553
src/app/(dashboard)/analytics/forecast/page.tsx
Normal file
553
src/app/(dashboard)/analytics/forecast/page.tsx
Normal file
@@ -0,0 +1,553 @@
|
||||
'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<string, number> = { 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<T>(action: string, body: Record<string, unknown> = {}): Promise<T> {
|
||||
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 (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div className="bg-card-bg border border-card-border rounded-xl p-6 max-w-md w-full mx-4 shadow-xl">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">Lock Forecast</h3>
|
||||
<p className="text-sm text-muted mb-4">{message}</p>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Optional notes..."
|
||||
value={notes}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-sm rounded-lg border border-card-border text-muted hover:bg-card-bg transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
disabled={confirming}
|
||||
className="px-4 py-2 text-sm rounded-lg bg-brand-azure text-white hover:opacity-90 transition-opacity disabled:opacity-50"
|
||||
>
|
||||
{confirming ? 'Locking...' : 'Lock Forecast'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<button
|
||||
onClick={() => setEditing(true)}
|
||||
className="text-left hover:text-brand-azure transition-colors underline-offset-2 hover:underline"
|
||||
title="Click to edit"
|
||||
>
|
||||
{type === 'number' ? formatCurrency(Number(value)) : value}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const commit = () => {
|
||||
if (draft !== value) onSave(draft);
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
if (type === 'select') {
|
||||
return (
|
||||
<select
|
||||
autoFocus
|
||||
value={draft}
|
||||
onChange={(e) => { setDraft(e.target.value); }}
|
||||
onBlur={commit}
|
||||
className="bg-card-bg border border-card-border rounded px-2 py-1 text-xs text-foreground"
|
||||
>
|
||||
{options?.map((o) => (
|
||||
<option key={o} value={o}>{o}</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
autoFocus
|
||||
type={type === 'number' ? 'number' : type === 'date' ? 'date' : 'text'}
|
||||
value={draft}
|
||||
onChange={(e) => 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<ForecastOverride[]>([]);
|
||||
const [locks, setLocks] = useState<ForecastLock[]>([]);
|
||||
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<string, ForecastOverride>();
|
||||
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<string, typeof openDeals> = { 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<string, { Commit: number; 'Best Case': number; Pipeline: number }> = {};
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="Forecast Workspace" />
|
||||
|
||||
{/* Quarter selector & Lock button */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="text-sm font-medium text-muted">Quarter</label>
|
||||
<select
|
||||
value={quarter}
|
||||
onChange={(e) => setQuarter(e.target.value)}
|
||||
className="bg-card-bg border border-card-border rounded-lg px-3 py-2 text-sm text-foreground"
|
||||
>
|
||||
{QUARTERS.map((q) => (
|
||||
<option key={q} value={q}>{q}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setLockDialogOpen(true)}
|
||||
className="px-4 py-2 text-sm font-medium rounded-lg bg-brand-navy text-white hover:opacity-90 transition-opacity"
|
||||
>
|
||||
Lock Forecast
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Scorecards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Scorecard label="Commit" value={formatCurrency(totals.commit, true)} color="green" />
|
||||
<Scorecard label="Best Case" value={formatCurrency(totals.bestCase, true)} />
|
||||
<Scorecard label="Pipeline" value={formatCurrency(totals.pipeline, true)} />
|
||||
<Scorecard
|
||||
label="Coverage"
|
||||
value={`${totals.coverage.toFixed(1)}x`}
|
||||
subtitle={`vs ${formatCurrency(DEFAULT_QUOTA, true)} quota`}
|
||||
color={totals.coverage >= 1 ? 'green' : 'red'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Loading indicator */}
|
||||
{loading && (
|
||||
<div className="text-center py-8 text-muted text-sm">Loading forecast data...</div>
|
||||
)}
|
||||
|
||||
{/* Deal table grouped by category */}
|
||||
<ChartCard title="Deals by Forecast Category" subtitle="Click any value to override">
|
||||
<div className="space-y-6">
|
||||
{(['Commit', 'Best Case', 'Pipeline'] as const).map((cat) => (
|
||||
<div key={cat}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{
|
||||
backgroundColor:
|
||||
cat === 'Commit' ? CHART_COLORS.navy : cat === 'Best Case' ? CHART_COLORS.azure : CHART_COLORS.green,
|
||||
}}
|
||||
/>
|
||||
<h4 className="text-sm font-semibold text-foreground">{cat}</h4>
|
||||
<span className="text-xs text-muted">
|
||||
({grouped[cat]?.length ?? 0} deals ·{' '}
|
||||
{formatCurrency(grouped[cat]?.reduce((s, d) => s + d.Amount_USD, 0) ?? 0, true)})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-left text-muted border-b border-card-border">
|
||||
<th className="pb-2 pr-4 font-medium">Account</th>
|
||||
<th className="pb-2 pr-4 font-medium">Opportunity</th>
|
||||
<th className="pb-2 pr-4 font-medium">Stage</th>
|
||||
<th className="pb-2 pr-4 font-medium text-right">Amount</th>
|
||||
<th className="pb-2 pr-4 font-medium">Category</th>
|
||||
<th className="pb-2 font-medium">Close Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(grouped[cat] ?? []).map((deal) => (
|
||||
<tr key={deal.Opportunity_ID} className="border-b border-card-border/50 hover:bg-card-bg/80">
|
||||
<td className="py-2 pr-4 text-foreground">
|
||||
{deal.Account_Name}
|
||||
{deal._overridden && (
|
||||
<span className="ml-1.5 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-brand-azure/10 text-brand-azure">
|
||||
edited
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-muted font-mono">{deal.Opportunity_ID}</td>
|
||||
<td className="py-2 pr-4 text-muted">{deal.Stage}</td>
|
||||
<td className="py-2 pr-4 text-right">
|
||||
<InlineEdit
|
||||
value={String(deal.Amount_USD)}
|
||||
type="number"
|
||||
onSave={(v) => handleOverride(deal, 'Amount_USD', v)}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 pr-4">
|
||||
<InlineEdit
|
||||
value={deal.Forecast_Category}
|
||||
type="select"
|
||||
options={['Commit', 'Best Case', 'Pipeline', 'Omit']}
|
||||
onSave={(v) => handleOverride(deal, 'Forecast_Category', v)}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<InlineEdit
|
||||
value={deal.Expected_Close_Date}
|
||||
type="date"
|
||||
onSave={(v) => handleOverride(deal, 'Expected_Close_Date', v)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{(grouped[cat] ?? []).length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="py-4 text-center text-muted">No deals</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ChartCard>
|
||||
|
||||
{/* Stacked bar chart by district */}
|
||||
<ChartCard title="Forecast by District" subtitle="Commit vs Best Case vs Pipeline">
|
||||
<div className="h-72">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={chartData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-card-border)" />
|
||||
<XAxis dataKey="district" tick={{ fontSize: 11, fill: 'var(--color-muted)' }} />
|
||||
<YAxis
|
||||
tickFormatter={(v: number) => formatCurrency(v, true)}
|
||||
tick={{ fontSize: 11, fill: 'var(--color-muted)' }}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value) => formatCurrency(value as number)}
|
||||
contentStyle={{
|
||||
backgroundColor: 'var(--color-card-bg)',
|
||||
border: '1px solid var(--color-card-border)',
|
||||
borderRadius: '8px',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: '12px' }} />
|
||||
<Bar dataKey="Commit" stackId="a" fill={CHART_COLORS.navy} radius={[0, 0, 0, 0]} />
|
||||
<Bar dataKey="Best Case" stackId="a" fill={CHART_COLORS.azure} />
|
||||
<Bar dataKey="Pipeline" stackId="a" fill={CHART_COLORS.green} radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</ChartCard>
|
||||
|
||||
{/* Forecast History */}
|
||||
<ChartCard title="Forecast History" subtitle="Past locked snapshots">
|
||||
{locks.length === 0 && !loading ? (
|
||||
<p className="text-sm text-muted py-4 text-center">No forecast locks recorded yet for {quarter}.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-left text-muted border-b border-card-border">
|
||||
<th className="pb-2 pr-4 font-medium">Lock Date</th>
|
||||
<th className="pb-2 pr-4 font-medium">Locked By</th>
|
||||
<th className="pb-2 pr-4 font-medium text-right">Commit</th>
|
||||
<th className="pb-2 pr-4 font-medium text-right">Best Case</th>
|
||||
<th className="pb-2 pr-4 font-medium text-right">Pipeline</th>
|
||||
<th className="pb-2 font-medium">Notes</th>
|
||||
<th className="pb-2 font-medium text-right">vs Current</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{locks.map((lock) => {
|
||||
const commitDelta = totals.commit - lock.total_commit;
|
||||
return (
|
||||
<tr key={lock.id} className="border-b border-card-border/50">
|
||||
<td className="py-2 pr-4 text-foreground">
|
||||
{new Date(lock.lock_date).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-muted">{lock.locked_by}</td>
|
||||
<td className="py-2 pr-4 text-right text-foreground">{formatCurrency(lock.total_commit, true)}</td>
|
||||
<td className="py-2 pr-4 text-right text-foreground">{formatCurrency(lock.total_best_case, true)}</td>
|
||||
<td className="py-2 pr-4 text-right text-foreground">{formatCurrency(lock.total_pipeline, true)}</td>
|
||||
<td className="py-2 pr-4 text-muted">{lock.notes || '—'}</td>
|
||||
<td className="py-2 text-right">
|
||||
<span className={commitDelta >= 0 ? 'text-success' : 'text-danger'}>
|
||||
{commitDelta >= 0 ? '+' : ''}
|
||||
{formatCurrency(commitDelta, true)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</ChartCard>
|
||||
|
||||
{/* Lock Confirm Dialog */}
|
||||
<LockDialog
|
||||
open={lockDialogOpen}
|
||||
message={`Save a snapshot of the current ${quarter} forecast? This records Commit: ${formatCurrency(totals.commit, true)}, Best Case: ${formatCurrency(totals.bestCase, true)}, Pipeline: ${formatCurrency(totals.pipeline, true)}.`}
|
||||
notes={lockNotes}
|
||||
onNotesChange={setLockNotes}
|
||||
onConfirm={handleLock}
|
||||
onCancel={() => { setLockDialogOpen(false); setLockNotes(''); }}
|
||||
confirming={locking}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user