feat(reports): prior-period comparison toggle on the Sales report
Adds a "Compare to prior period" toggle to the Sales report header. When on, the API recomputes the KPI window for the equal-length window immediately preceding the selected range (previousPeriodBounds) behind `?compare=1`, and the five window-derived KPI tiles (Won, Lost, Win rate, Avg time-to-close, New leads) render colour-correct "vs prior" deltas. Point-in-time tiles (Active interests, Pipeline value) have no prior-window analogue and intentionally show no delta. The prior-window query runs in parallel with the main batch and resolves to null when the toggle is off (zero cost). Toggle state persists in the saved-template config. Closes the spec's "period comparison on every report" gap for Sales; Operational already rendered period-start deltas. Pure helpers TDD'd: previousPeriodBounds (range.ts) + computeSalesKpiComparison (sales-comparison.ts), 7 unit tests. tsc + lint clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,9 +3,16 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { TrendingDown, TrendingUp } from 'lucide-react';
|
||||
import {
|
||||
ArrowDownRight,
|
||||
ArrowLeftRight,
|
||||
ArrowUpRight,
|
||||
TrendingDown,
|
||||
TrendingUp,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -19,6 +26,7 @@ import {
|
||||
} from '@/components/shared/filter-bar';
|
||||
import { rangeToBounds, type DateRange } from '@/lib/analytics/range';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { PIPELINE_STAGES, STAGE_LABELS, OUTCOME_LABELS, type PipelineStage } from '@/lib/constants';
|
||||
import { formatMoney } from '@/lib/reports/format-currency';
|
||||
import type { ReportPayload } from '@/lib/reports/types';
|
||||
@@ -164,9 +172,26 @@ interface LostReasonRow {
|
||||
avgDaysFromFirstContactToLoss: number | null;
|
||||
}
|
||||
|
||||
// Per-KPI deltas for the prior-period comparison toggle. Only the
|
||||
// window-derived tiles get a delta; the point-in-time tiles (Active
|
||||
// interests, Pipeline value) have no prior-window analogue.
|
||||
interface SalesKpiDeltas {
|
||||
wonInWindow: number;
|
||||
lostInWindow: number;
|
||||
newLeadsInWindow: number;
|
||||
winRate: number | null;
|
||||
medianTimeToCloseDays: number | null;
|
||||
}
|
||||
|
||||
interface SalesComparison {
|
||||
deltas: SalesKpiDeltas;
|
||||
priorRange: { from: string; to: string };
|
||||
}
|
||||
|
||||
interface SalesReportPayload {
|
||||
data: {
|
||||
kpis: SalesKpis;
|
||||
comparison: SalesComparison | null;
|
||||
funnel: FunnelRow[];
|
||||
stageVelocity: StageVelocityRow[];
|
||||
winRateOverTime: WinRateOverTime;
|
||||
@@ -226,6 +251,7 @@ interface SalesTemplateConfig extends Record<string, unknown> {
|
||||
kind: 'sales';
|
||||
range: DateRange;
|
||||
filters: FilterValues;
|
||||
compare?: boolean;
|
||||
}
|
||||
|
||||
export function SalesReportClient({ portSlug: _portSlug }: { portSlug: string }) {
|
||||
@@ -234,6 +260,7 @@ export function SalesReportClient({ portSlug: _portSlug }: { portSlug: string })
|
||||
|
||||
const [range, setRange] = useState<DateRange>('30d');
|
||||
const [filterValues, setFilterValues] = useState<FilterValues>({});
|
||||
const [compareEnabled, setCompareEnabled] = useState(false);
|
||||
const [activeTemplateId, setActiveTemplateId] = useState<string | null>(initialTemplateId);
|
||||
|
||||
// Wrap the user-driven setters so any view-state change clears the
|
||||
@@ -256,8 +283,8 @@ export function SalesReportClient({ portSlug: _portSlug }: { portSlug: string })
|
||||
}, []);
|
||||
|
||||
const currentConfig: SalesTemplateConfig = useMemo(
|
||||
() => ({ kind: 'sales', range, filters: filterValues }),
|
||||
[range, filterValues],
|
||||
() => ({ kind: 'sales', range, filters: filterValues, compare: compareEnabled }),
|
||||
[range, filterValues, compareEnabled],
|
||||
);
|
||||
|
||||
const handleApplyTemplate = useCallback((config: SalesTemplateConfig) => {
|
||||
@@ -265,6 +292,7 @@ export function SalesReportClient({ portSlug: _portSlug }: { portSlug: string })
|
||||
// active-template badge, which the user-facing setters above do.
|
||||
if (config.range) setRange(config.range);
|
||||
setFilterValues(config.filters ?? {});
|
||||
setCompareEnabled(config.compare ?? false);
|
||||
}, []);
|
||||
|
||||
const bounds = useMemo(() => rangeToBounds(range), [range]);
|
||||
@@ -281,15 +309,23 @@ export function SalesReportClient({ portSlug: _portSlug }: { portSlug: string })
|
||||
}, [filterValues]);
|
||||
|
||||
const query = useQuery<SalesReportPayload>({
|
||||
queryKey: ['reports', 'sales', bounds.from.toISOString(), bounds.to.toISOString(), filterQs],
|
||||
queryKey: [
|
||||
'reports',
|
||||
'sales',
|
||||
bounds.from.toISOString(),
|
||||
bounds.to.toISOString(),
|
||||
filterQs,
|
||||
compareEnabled,
|
||||
],
|
||||
queryFn: () =>
|
||||
apiFetch<SalesReportPayload>(
|
||||
`/api/v1/reports/sales?from=${encodeURIComponent(bounds.from.toISOString())}&to=${encodeURIComponent(bounds.to.toISOString())}${filterQs}`,
|
||||
`/api/v1/reports/sales?from=${encodeURIComponent(bounds.from.toISOString())}&to=${encodeURIComponent(bounds.to.toISOString())}${filterQs}${compareEnabled ? '&compare=1' : ''}`,
|
||||
),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const kpis = query.data?.data.kpis;
|
||||
const deltas = query.data?.data.comparison?.deltas ?? null;
|
||||
const funnel = query.data?.data.funnel ?? [];
|
||||
const stageVelocity = query.data?.data.stageVelocity ?? [];
|
||||
const winRateOverTime = query.data?.data.winRateOverTime ?? {
|
||||
@@ -529,6 +565,20 @@ export function SalesReportClient({ portSlug: _portSlug }: { portSlug: string })
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<DateRangePicker value={range} onChange={handleRangeChange} />
|
||||
<Button
|
||||
type="button"
|
||||
variant={compareEnabled ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setCompareEnabled((v) => !v);
|
||||
setActiveTemplateId(null);
|
||||
}}
|
||||
aria-pressed={compareEnabled}
|
||||
title="Show change vs the equal-length window immediately before the selected period"
|
||||
>
|
||||
<ArrowLeftRight className="h-4 w-4" aria-hidden />
|
||||
Compare to prior period
|
||||
</Button>
|
||||
<ReportTemplatesButton<SalesTemplateConfig>
|
||||
kind="sales"
|
||||
currentConfig={currentConfig}
|
||||
@@ -561,11 +611,13 @@ export function SalesReportClient({ portSlug: _portSlug }: { portSlug: string })
|
||||
label="Won in period"
|
||||
value={formatInt(kpis.wonInWindow)}
|
||||
valueTrend={kpis.wonInWindow > 0 ? 'positive' : 'neutral'}
|
||||
delta={deltas ? buildKpiDelta(deltas.wonInWindow, 'up', formatInt) : null}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Lost in period"
|
||||
value={formatInt(kpis.lostInWindow)}
|
||||
valueTrend={kpis.lostInWindow > 0 ? 'negative' : 'neutral'}
|
||||
delta={deltas ? buildKpiDelta(deltas.lostInWindow, 'down', formatInt) : null}
|
||||
hint={
|
||||
kpis.lossBreakdown.length > 0
|
||||
? kpis.lossBreakdown
|
||||
@@ -577,6 +629,11 @@ export function SalesReportClient({ portSlug: _portSlug }: { portSlug: string })
|
||||
<KpiCard
|
||||
label="Win rate"
|
||||
value={kpis.winRate === null ? '—' : formatPercent(kpis.winRate)}
|
||||
delta={
|
||||
deltas
|
||||
? buildKpiDelta(deltas.winRate, 'up', (abs) => `${(abs * 100).toFixed(1)}pp`)
|
||||
: null
|
||||
}
|
||||
hint={kpis.winRate === null ? 'No closed deals in period' : 'Excludes cancellations'}
|
||||
/>
|
||||
<KpiCard
|
||||
@@ -595,6 +652,15 @@ export function SalesReportClient({ portSlug: _portSlug }: { portSlug: string })
|
||||
? '—'
|
||||
: formatDurationFromDays(kpis.medianTimeToCloseDays)
|
||||
}
|
||||
delta={
|
||||
deltas
|
||||
? buildKpiDelta(
|
||||
deltas.medianTimeToCloseDays,
|
||||
'down',
|
||||
(abs) => `${Math.round(abs)}d`,
|
||||
)
|
||||
: null
|
||||
}
|
||||
hint={
|
||||
kpis.medianTimeToCloseDays === null
|
||||
? 'Need ≥3 won deals for a meaningful median'
|
||||
@@ -604,6 +670,7 @@ export function SalesReportClient({ portSlug: _portSlug }: { portSlug: string })
|
||||
<KpiCard
|
||||
label="New leads"
|
||||
value={formatInt(kpis.newLeadsInWindow)}
|
||||
delta={deltas ? buildKpiDelta(deltas.newLeadsInWindow, 'up', formatInt) : null}
|
||||
hint={
|
||||
kpis.newLeadsBySource.length > 0
|
||||
? kpis.newLeadsBySource
|
||||
@@ -759,14 +826,26 @@ export function SalesReportClient({ portSlug: _portSlug }: { portSlug: string })
|
||||
|
||||
// ─── KPI tile primitives ─────────────────────────────────────────────────────
|
||||
|
||||
interface KpiDeltaDisplay {
|
||||
/** Pre-formatted magnitude with sign, e.g. "+3", "−2.0pp", "−1.5d". */
|
||||
formatted: string;
|
||||
/** Colour semantics: positive = good (green), negative = bad (rose),
|
||||
* neutral = no change (muted). Computed by the caller because "good"
|
||||
* depends on the metric (more wins = good; longer time-to-close = bad). */
|
||||
tone: 'positive' | 'negative' | 'neutral';
|
||||
}
|
||||
|
||||
interface KpiCardProps {
|
||||
label: string;
|
||||
value: string;
|
||||
hint?: string;
|
||||
valueTrend?: 'positive' | 'negative' | 'neutral';
|
||||
/** Prior-period comparison delta. Rendered as a small "vs prior" line
|
||||
* under the value when the comparison toggle is on. */
|
||||
delta?: KpiDeltaDisplay | null;
|
||||
}
|
||||
|
||||
function KpiCard({ label, value, hint, valueTrend = 'neutral' }: KpiCardProps) {
|
||||
function KpiCard({ label, value, hint, valueTrend = 'neutral', delta }: KpiCardProps) {
|
||||
// Padding goes directly on the bare Card (skipping CardContent)
|
||||
// because CardContent ships with `p-4 pt-0 sm:p-6 sm:pt-0` for
|
||||
// use-with-CardHeader contexts. KPI tiles have no header, so any
|
||||
@@ -787,6 +866,26 @@ function KpiCard({ label, value, hint, valueTrend = 'neutral' }: KpiCardProps) {
|
||||
<TrendingDown className="h-3.5 w-3.5 text-rose-600" aria-hidden />
|
||||
) : null}
|
||||
</div>
|
||||
{delta ? (
|
||||
<p
|
||||
className={cn(
|
||||
'flex items-center gap-0.5 text-[11px] font-medium tabular-nums',
|
||||
delta.tone === 'positive'
|
||||
? 'text-emerald-600'
|
||||
: delta.tone === 'negative'
|
||||
? 'text-rose-600'
|
||||
: 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{delta.tone === 'positive' ? (
|
||||
<ArrowUpRight className="h-3 w-3" aria-hidden />
|
||||
) : delta.tone === 'negative' ? (
|
||||
<ArrowDownRight className="h-3 w-3" aria-hidden />
|
||||
) : null}
|
||||
{delta.formatted}
|
||||
<span className="font-normal text-muted-foreground">vs prior</span>
|
||||
</p>
|
||||
) : null}
|
||||
{hint ? (
|
||||
<p className="text-[11px] text-muted-foreground leading-snug line-clamp-2">{hint}</p>
|
||||
) : null}
|
||||
@@ -825,6 +924,26 @@ function formatPercent(fraction: number): string {
|
||||
return `${Math.round(fraction * 1000) / 10}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the prior-period delta display for a KPI tile. `goodDirection`
|
||||
* encodes which way is good for THIS metric (more wins = up-is-good;
|
||||
* shorter time-to-close = down-is-good) so the colour matches intuition
|
||||
* regardless of the raw sign. Returns null when the metric had no value
|
||||
* in one of the two periods (e.g. win rate with no closed deals).
|
||||
*/
|
||||
function buildKpiDelta(
|
||||
value: number | null,
|
||||
goodDirection: 'up' | 'down',
|
||||
format: (abs: number) => string,
|
||||
): KpiDeltaDisplay | null {
|
||||
if (value === null) return null;
|
||||
if (value === 0) return { formatted: 'no change', tone: 'neutral' };
|
||||
const sign = value > 0 ? '+' : '−';
|
||||
const formatted = `${sign}${format(Math.abs(value))}`;
|
||||
const isGood = goodDirection === 'up' ? value > 0 : value < 0;
|
||||
return { formatted, tone: isGood ? 'positive' : 'negative' };
|
||||
}
|
||||
|
||||
// Money helpers come from the shared module — `formatMoney` for KPI
|
||||
// tile readability, `formatMoneyCompact` for tight dense tables.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user