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:
@@ -77,8 +77,20 @@ everything else is post-launch polish unless promoted.
|
|||||||
|
|
||||||
#### Cross-cutting capabilities (apply to every report)
|
#### Cross-cutting capabilities (apply to every report)
|
||||||
|
|
||||||
- ❌ **Period comparison toggle** — "this period vs prior period" delta
|
- ⚠️ **Period comparison toggle** — "this period vs prior period" delta
|
||||||
arrows on KPI cards. Spec calls for it on every report. Not on any.
|
arrows on KPI cards. **Sales: SHIPPED locally (2026-05-31)** — a
|
||||||
|
"Compare to prior period" toggle in the header computes an
|
||||||
|
equal-length preceding window (`previousPeriodBounds`), the API
|
||||||
|
recomputes KPIs for that window behind `?compare=1`, and the five
|
||||||
|
window-derived 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) intentionally have no delta.
|
||||||
|
Persisted in the saved-template config. TDD'd:
|
||||||
|
`previousPeriodBounds` + `computeSalesKpiComparison` unit tests.
|
||||||
|
Operational already rendered period-start deltas. **Still open:** the
|
||||||
|
spec's "on every report" — Operational uses a different
|
||||||
|
"vs period start" baseline; reconcile the two semantics if a single
|
||||||
|
consistent comparison is wanted.
|
||||||
- ❌ **Rep multi-select filter** — exists implicitly via the single-rep
|
- ❌ **Rep multi-select filter** — exists implicitly via the single-rep
|
||||||
leaderboard collapse, but no explicit multi-select dropdown.
|
leaderboard collapse, but no explicit multi-select dropdown.
|
||||||
- ❌ **Source multi-select filter** — Sales has lead-category + outcome
|
- ❌ **Source multi-select filter** — Sales has lead-category + outcome
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { z } from 'zod';
|
|||||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
import { errorResponse } from '@/lib/errors';
|
import { errorResponse } from '@/lib/errors';
|
||||||
import { PIPELINE_STAGES, type PipelineStage } from '@/lib/constants';
|
import { PIPELINE_STAGES, type PipelineStage } from '@/lib/constants';
|
||||||
|
import { previousPeriodBounds } from '@/lib/analytics/range';
|
||||||
|
import { computeSalesKpiComparison } from '@/lib/services/reports/sales-comparison';
|
||||||
import {
|
import {
|
||||||
getSalesKpis,
|
getSalesKpis,
|
||||||
getPipelineFunnel,
|
getPipelineFunnel,
|
||||||
@@ -50,6 +52,8 @@ const querySchema = z.object({
|
|||||||
stage: z.string().optional(),
|
stage: z.string().optional(),
|
||||||
leadCategory: z.string().optional(),
|
leadCategory: z.string().optional(),
|
||||||
outcome: z.string().optional(),
|
outcome: z.string().optional(),
|
||||||
|
// "1" / "true" enables the prior-period comparison (adds per-KPI deltas).
|
||||||
|
compare: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -86,14 +90,17 @@ export const GET = withAuth(
|
|||||||
withPermission('reports', 'view_dashboard', async (req: NextRequest, ctx) => {
|
withPermission('reports', 'view_dashboard', async (req: NextRequest, ctx) => {
|
||||||
try {
|
try {
|
||||||
const params = req.nextUrl.searchParams;
|
const params = req.nextUrl.searchParams;
|
||||||
const { from, to, stage, leadCategory, outcome } = querySchema.parse({
|
const { from, to, stage, leadCategory, outcome, compare } = querySchema.parse({
|
||||||
from: params.get('from') ?? undefined,
|
from: params.get('from') ?? undefined,
|
||||||
to: params.get('to') ?? undefined,
|
to: params.get('to') ?? undefined,
|
||||||
stage: params.get('stage') ?? undefined,
|
stage: params.get('stage') ?? undefined,
|
||||||
leadCategory: params.get('leadCategory') ?? undefined,
|
leadCategory: params.get('leadCategory') ?? undefined,
|
||||||
outcome: params.get('outcome') ?? undefined,
|
outcome: params.get('outcome') ?? undefined,
|
||||||
|
compare: params.get('compare') ?? undefined,
|
||||||
});
|
});
|
||||||
const range = resolveRange(from, to);
|
const range = resolveRange(from, to);
|
||||||
|
const compareEnabled = compare === '1' || compare === 'true';
|
||||||
|
const priorBounds = compareEnabled ? previousPeriodBounds(range) : null;
|
||||||
|
|
||||||
const filters: SalesFilters | undefined = (() => {
|
const filters: SalesFilters | undefined = (() => {
|
||||||
const stages = parseCsv<PipelineStage>(stage, PIPELINE_STAGES);
|
const stages = parseCsv<PipelineStage>(stage, PIPELINE_STAGES);
|
||||||
@@ -119,6 +126,7 @@ export const GET = withAuth(
|
|||||||
closingThisMonth,
|
closingThisMonth,
|
||||||
recentWins,
|
recentWins,
|
||||||
lostReasonBreakdown,
|
lostReasonBreakdown,
|
||||||
|
priorKpis,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
getSalesKpis(ctx.portId, range),
|
getSalesKpis(ctx.portId, range),
|
||||||
getPipelineFunnel(ctx.portId),
|
getPipelineFunnel(ctx.portId),
|
||||||
@@ -132,11 +140,27 @@ export const GET = withAuth(
|
|||||||
getClosingThisMonth(ctx.portId, filters),
|
getClosingThisMonth(ctx.portId, filters),
|
||||||
getRecentWins(ctx.portId, filters),
|
getRecentWins(ctx.portId, filters),
|
||||||
getLostReasonBreakdown(ctx.portId, range, filters),
|
getLostReasonBreakdown(ctx.portId, range, filters),
|
||||||
|
// Prior-window KPIs for the comparison toggle. Runs in parallel
|
||||||
|
// with the main batch (depends only on the derived priorBounds);
|
||||||
|
// resolves to null when the toggle is off so we pay nothing.
|
||||||
|
priorBounds ? getSalesKpis(ctx.portId, priorBounds) : Promise.resolve(null),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const comparison =
|
||||||
|
priorKpis && priorBounds
|
||||||
|
? {
|
||||||
|
deltas: computeSalesKpiComparison(kpis, priorKpis),
|
||||||
|
priorRange: {
|
||||||
|
from: priorBounds.from.toISOString(),
|
||||||
|
to: priorBounds.to.toISOString(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
data: {
|
data: {
|
||||||
kpis,
|
kpis,
|
||||||
|
comparison,
|
||||||
funnel,
|
funnel,
|
||||||
stageVelocity,
|
stageVelocity,
|
||||||
winRateOverTime,
|
winRateOverTime,
|
||||||
|
|||||||
@@ -3,9 +3,16 @@
|
|||||||
import { useCallback, useMemo, useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
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 { PageHeader } from '@/components/shared/page-header';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -19,6 +26,7 @@ import {
|
|||||||
} from '@/components/shared/filter-bar';
|
} from '@/components/shared/filter-bar';
|
||||||
import { rangeToBounds, type DateRange } from '@/lib/analytics/range';
|
import { rangeToBounds, type DateRange } from '@/lib/analytics/range';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
import { PIPELINE_STAGES, STAGE_LABELS, OUTCOME_LABELS, type PipelineStage } from '@/lib/constants';
|
import { PIPELINE_STAGES, STAGE_LABELS, OUTCOME_LABELS, type PipelineStage } from '@/lib/constants';
|
||||||
import { formatMoney } from '@/lib/reports/format-currency';
|
import { formatMoney } from '@/lib/reports/format-currency';
|
||||||
import type { ReportPayload } from '@/lib/reports/types';
|
import type { ReportPayload } from '@/lib/reports/types';
|
||||||
@@ -164,9 +172,26 @@ interface LostReasonRow {
|
|||||||
avgDaysFromFirstContactToLoss: number | null;
|
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 {
|
interface SalesReportPayload {
|
||||||
data: {
|
data: {
|
||||||
kpis: SalesKpis;
|
kpis: SalesKpis;
|
||||||
|
comparison: SalesComparison | null;
|
||||||
funnel: FunnelRow[];
|
funnel: FunnelRow[];
|
||||||
stageVelocity: StageVelocityRow[];
|
stageVelocity: StageVelocityRow[];
|
||||||
winRateOverTime: WinRateOverTime;
|
winRateOverTime: WinRateOverTime;
|
||||||
@@ -226,6 +251,7 @@ interface SalesTemplateConfig extends Record<string, unknown> {
|
|||||||
kind: 'sales';
|
kind: 'sales';
|
||||||
range: DateRange;
|
range: DateRange;
|
||||||
filters: FilterValues;
|
filters: FilterValues;
|
||||||
|
compare?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SalesReportClient({ portSlug: _portSlug }: { portSlug: string }) {
|
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 [range, setRange] = useState<DateRange>('30d');
|
||||||
const [filterValues, setFilterValues] = useState<FilterValues>({});
|
const [filterValues, setFilterValues] = useState<FilterValues>({});
|
||||||
|
const [compareEnabled, setCompareEnabled] = useState(false);
|
||||||
const [activeTemplateId, setActiveTemplateId] = useState<string | null>(initialTemplateId);
|
const [activeTemplateId, setActiveTemplateId] = useState<string | null>(initialTemplateId);
|
||||||
|
|
||||||
// Wrap the user-driven setters so any view-state change clears the
|
// 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(
|
const currentConfig: SalesTemplateConfig = useMemo(
|
||||||
() => ({ kind: 'sales', range, filters: filterValues }),
|
() => ({ kind: 'sales', range, filters: filterValues, compare: compareEnabled }),
|
||||||
[range, filterValues],
|
[range, filterValues, compareEnabled],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleApplyTemplate = useCallback((config: SalesTemplateConfig) => {
|
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.
|
// active-template badge, which the user-facing setters above do.
|
||||||
if (config.range) setRange(config.range);
|
if (config.range) setRange(config.range);
|
||||||
setFilterValues(config.filters ?? {});
|
setFilterValues(config.filters ?? {});
|
||||||
|
setCompareEnabled(config.compare ?? false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const bounds = useMemo(() => rangeToBounds(range), [range]);
|
const bounds = useMemo(() => rangeToBounds(range), [range]);
|
||||||
@@ -281,15 +309,23 @@ export function SalesReportClient({ portSlug: _portSlug }: { portSlug: string })
|
|||||||
}, [filterValues]);
|
}, [filterValues]);
|
||||||
|
|
||||||
const query = useQuery<SalesReportPayload>({
|
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: () =>
|
queryFn: () =>
|
||||||
apiFetch<SalesReportPayload>(
|
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,
|
staleTime: 30_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const kpis = query.data?.data.kpis;
|
const kpis = query.data?.data.kpis;
|
||||||
|
const deltas = query.data?.data.comparison?.deltas ?? null;
|
||||||
const funnel = query.data?.data.funnel ?? [];
|
const funnel = query.data?.data.funnel ?? [];
|
||||||
const stageVelocity = query.data?.data.stageVelocity ?? [];
|
const stageVelocity = query.data?.data.stageVelocity ?? [];
|
||||||
const winRateOverTime = query.data?.data.winRateOverTime ?? {
|
const winRateOverTime = query.data?.data.winRateOverTime ?? {
|
||||||
@@ -529,6 +565,20 @@ export function SalesReportClient({ portSlug: _portSlug }: { portSlug: string })
|
|||||||
actions={
|
actions={
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<DateRangePicker value={range} onChange={handleRangeChange} />
|
<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>
|
<ReportTemplatesButton<SalesTemplateConfig>
|
||||||
kind="sales"
|
kind="sales"
|
||||||
currentConfig={currentConfig}
|
currentConfig={currentConfig}
|
||||||
@@ -561,11 +611,13 @@ export function SalesReportClient({ portSlug: _portSlug }: { portSlug: string })
|
|||||||
label="Won in period"
|
label="Won in period"
|
||||||
value={formatInt(kpis.wonInWindow)}
|
value={formatInt(kpis.wonInWindow)}
|
||||||
valueTrend={kpis.wonInWindow > 0 ? 'positive' : 'neutral'}
|
valueTrend={kpis.wonInWindow > 0 ? 'positive' : 'neutral'}
|
||||||
|
delta={deltas ? buildKpiDelta(deltas.wonInWindow, 'up', formatInt) : null}
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
label="Lost in period"
|
label="Lost in period"
|
||||||
value={formatInt(kpis.lostInWindow)}
|
value={formatInt(kpis.lostInWindow)}
|
||||||
valueTrend={kpis.lostInWindow > 0 ? 'negative' : 'neutral'}
|
valueTrend={kpis.lostInWindow > 0 ? 'negative' : 'neutral'}
|
||||||
|
delta={deltas ? buildKpiDelta(deltas.lostInWindow, 'down', formatInt) : null}
|
||||||
hint={
|
hint={
|
||||||
kpis.lossBreakdown.length > 0
|
kpis.lossBreakdown.length > 0
|
||||||
? kpis.lossBreakdown
|
? kpis.lossBreakdown
|
||||||
@@ -577,6 +629,11 @@ export function SalesReportClient({ portSlug: _portSlug }: { portSlug: string })
|
|||||||
<KpiCard
|
<KpiCard
|
||||||
label="Win rate"
|
label="Win rate"
|
||||||
value={kpis.winRate === null ? '—' : formatPercent(kpis.winRate)}
|
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'}
|
hint={kpis.winRate === null ? 'No closed deals in period' : 'Excludes cancellations'}
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
@@ -595,6 +652,15 @@ export function SalesReportClient({ portSlug: _portSlug }: { portSlug: string })
|
|||||||
? '—'
|
? '—'
|
||||||
: formatDurationFromDays(kpis.medianTimeToCloseDays)
|
: formatDurationFromDays(kpis.medianTimeToCloseDays)
|
||||||
}
|
}
|
||||||
|
delta={
|
||||||
|
deltas
|
||||||
|
? buildKpiDelta(
|
||||||
|
deltas.medianTimeToCloseDays,
|
||||||
|
'down',
|
||||||
|
(abs) => `${Math.round(abs)}d`,
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
}
|
||||||
hint={
|
hint={
|
||||||
kpis.medianTimeToCloseDays === null
|
kpis.medianTimeToCloseDays === null
|
||||||
? 'Need ≥3 won deals for a meaningful median'
|
? 'Need ≥3 won deals for a meaningful median'
|
||||||
@@ -604,6 +670,7 @@ export function SalesReportClient({ portSlug: _portSlug }: { portSlug: string })
|
|||||||
<KpiCard
|
<KpiCard
|
||||||
label="New leads"
|
label="New leads"
|
||||||
value={formatInt(kpis.newLeadsInWindow)}
|
value={formatInt(kpis.newLeadsInWindow)}
|
||||||
|
delta={deltas ? buildKpiDelta(deltas.newLeadsInWindow, 'up', formatInt) : null}
|
||||||
hint={
|
hint={
|
||||||
kpis.newLeadsBySource.length > 0
|
kpis.newLeadsBySource.length > 0
|
||||||
? kpis.newLeadsBySource
|
? kpis.newLeadsBySource
|
||||||
@@ -759,14 +826,26 @@ export function SalesReportClient({ portSlug: _portSlug }: { portSlug: string })
|
|||||||
|
|
||||||
// ─── KPI tile primitives ─────────────────────────────────────────────────────
|
// ─── 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 {
|
interface KpiCardProps {
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
hint?: string;
|
hint?: string;
|
||||||
valueTrend?: 'positive' | 'negative' | 'neutral';
|
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)
|
// Padding goes directly on the bare Card (skipping CardContent)
|
||||||
// because CardContent ships with `p-4 pt-0 sm:p-6 sm:pt-0` for
|
// 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
|
// 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 />
|
<TrendingDown className="h-3.5 w-3.5 text-rose-600" aria-hidden />
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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 ? (
|
{hint ? (
|
||||||
<p className="text-[11px] text-muted-foreground leading-snug line-clamp-2">{hint}</p>
|
<p className="text-[11px] text-muted-foreground leading-snug line-clamp-2">{hint}</p>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -825,6 +924,26 @@ function formatPercent(fraction: number): string {
|
|||||||
return `${Math.round(fraction * 1000) / 10}%`;
|
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
|
// Money helpers come from the shared module — `formatMoney` for KPI
|
||||||
// tile readability, `formatMoneyCompact` for tight dense tables.
|
// tile readability, `formatMoneyCompact` for tight dense tables.
|
||||||
|
|
||||||
|
|||||||
@@ -100,3 +100,23 @@ export function rangeSpanDays(range: DateRange): number {
|
|||||||
}
|
}
|
||||||
return rangeToDays(range);
|
return rangeToDays(range);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a concrete {from, to} window, return the equal-length window
|
||||||
|
* immediately preceding it — used for "this period vs prior period"
|
||||||
|
* comparison on report KPIs. The prior window is contiguous (its `to`
|
||||||
|
* equals the current window's `from`) and exactly the same duration, so
|
||||||
|
* a 30-day current window compares against the prior 30 days.
|
||||||
|
*
|
||||||
|
* Pure: takes Dates in, returns fresh Dates, never mutates the input.
|
||||||
|
*/
|
||||||
|
export function previousPeriodBounds(bounds: { from: Date; to: Date }): {
|
||||||
|
from: Date;
|
||||||
|
to: Date;
|
||||||
|
} {
|
||||||
|
const lengthMs = bounds.to.getTime() - bounds.from.getTime();
|
||||||
|
return {
|
||||||
|
from: new Date(bounds.from.getTime() - lengthMs),
|
||||||
|
to: new Date(bounds.from.getTime()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
47
src/lib/services/reports/sales-comparison.ts
Normal file
47
src/lib/services/reports/sales-comparison.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* Period-comparison diffing for the Sales report KPI strip.
|
||||||
|
*
|
||||||
|
* DB-free + pure so it unit-tests without a database. The route computes
|
||||||
|
* the current-window KPIs and the prior-window KPIs (via
|
||||||
|
* `getSalesKpis(portId, previousPeriodBounds(range))`) and hands both
|
||||||
|
* here to produce the per-tile deltas the KpiCard renders as
|
||||||
|
* "vs prior period" arrows.
|
||||||
|
*
|
||||||
|
* Only the window-derived KPIs get a delta — the point-in-time tiles
|
||||||
|
* (Active interests, Pipeline value) have no prior-window analogue, so
|
||||||
|
* they are intentionally absent here.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Type-only import: erased at compile time, so this module does NOT pull
|
||||||
|
// in the DB driver that `sales.service.ts` imports at runtime.
|
||||||
|
import type { SalesKpis } from '@/lib/services/reports/sales.service';
|
||||||
|
|
||||||
|
export interface SalesKpiDeltas {
|
||||||
|
/** current - prior */
|
||||||
|
wonInWindow: number;
|
||||||
|
/** current - prior */
|
||||||
|
lostInWindow: number;
|
||||||
|
/** current - prior */
|
||||||
|
newLeadsInWindow: number;
|
||||||
|
/** current - prior as a fraction [-1, 1]; null when either period has
|
||||||
|
* no rate (denominator was 0). */
|
||||||
|
winRate: number | null;
|
||||||
|
/** current - prior in days; null when either period has no median
|
||||||
|
* (sample too small). */
|
||||||
|
medianTimeToCloseDays: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function diffNullable(current: number | null, prior: number | null): number | null {
|
||||||
|
if (current === null || prior === null) return null;
|
||||||
|
return current - prior;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeSalesKpiComparison(current: SalesKpis, prior: SalesKpis): SalesKpiDeltas {
|
||||||
|
return {
|
||||||
|
wonInWindow: current.wonInWindow - prior.wonInWindow,
|
||||||
|
lostInWindow: current.lostInWindow - prior.lostInWindow,
|
||||||
|
newLeadsInWindow: current.newLeadsInWindow - prior.newLeadsInWindow,
|
||||||
|
winRate: diffNullable(current.winRate, prior.winRate),
|
||||||
|
medianTimeToCloseDays: diffNullable(current.medianTimeToCloseDays, prior.medianTimeToCloseDays),
|
||||||
|
};
|
||||||
|
}
|
||||||
35
tests/unit/analytics/range.test.ts
Normal file
35
tests/unit/analytics/range.test.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
|
import { previousPeriodBounds } from '@/lib/analytics/range';
|
||||||
|
|
||||||
|
describe('previousPeriodBounds', () => {
|
||||||
|
it('returns the equal-length window immediately preceding the given bounds', () => {
|
||||||
|
const from = new Date('2026-05-01T00:00:00.000Z');
|
||||||
|
const to = new Date('2026-05-31T00:00:00.000Z'); // 30-day span
|
||||||
|
const prior = previousPeriodBounds({ from, to });
|
||||||
|
|
||||||
|
// Prior window is contiguous (prior.to === current.from) and equal length.
|
||||||
|
expect(prior.to.toISOString()).toBe('2026-05-01T00:00:00.000Z');
|
||||||
|
expect(prior.from.toISOString()).toBe('2026-04-01T00:00:00.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves sub-day spans (e.g. a single-day window)', () => {
|
||||||
|
const from = new Date('2026-05-10T00:00:00.000Z');
|
||||||
|
const to = new Date('2026-05-10T23:59:59.999Z');
|
||||||
|
const prior = previousPeriodBounds({ from, to });
|
||||||
|
|
||||||
|
const lenMs = to.getTime() - from.getTime();
|
||||||
|
expect(prior.to.getTime()).toBe(from.getTime());
|
||||||
|
expect(prior.from.getTime()).toBe(from.getTime() - lenMs);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not mutate the input bounds', () => {
|
||||||
|
const from = new Date('2026-05-01T00:00:00.000Z');
|
||||||
|
const to = new Date('2026-05-31T00:00:00.000Z');
|
||||||
|
const fromCopy = from.getTime();
|
||||||
|
const toCopy = to.getTime();
|
||||||
|
previousPeriodBounds({ from, to });
|
||||||
|
expect(from.getTime()).toBe(fromCopy);
|
||||||
|
expect(to.getTime()).toBe(toCopy);
|
||||||
|
});
|
||||||
|
});
|
||||||
67
tests/unit/services/reports/sales-comparison.test.ts
Normal file
67
tests/unit/services/reports/sales-comparison.test.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
|
import { computeSalesKpiComparison } from '@/lib/services/reports/sales-comparison';
|
||||||
|
import type { SalesKpis } from '@/lib/services/reports/sales.service';
|
||||||
|
|
||||||
|
function makeKpis(overrides: Partial<SalesKpis>): SalesKpis {
|
||||||
|
return {
|
||||||
|
activeInterests: 0,
|
||||||
|
wonInWindow: 0,
|
||||||
|
lostInWindow: 0,
|
||||||
|
lossBreakdown: [],
|
||||||
|
winRate: null,
|
||||||
|
pipelineValue: 0,
|
||||||
|
pipelineValueCurrency: 'EUR',
|
||||||
|
pipelineValueExcludedCount: 0,
|
||||||
|
pipelineValueTotalActiveCount: 0,
|
||||||
|
medianTimeToCloseDays: null,
|
||||||
|
timeToCloseSampleSize: 0,
|
||||||
|
newLeadsInWindow: 0,
|
||||||
|
newLeadsBySource: [],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('computeSalesKpiComparison', () => {
|
||||||
|
it('diffs the window-derived count fields (current - prior)', () => {
|
||||||
|
const current = makeKpis({ wonInWindow: 10, lostInWindow: 4, newLeadsInWindow: 20 });
|
||||||
|
const prior = makeKpis({ wonInWindow: 6, lostInWindow: 5, newLeadsInWindow: 12 });
|
||||||
|
|
||||||
|
const deltas = computeSalesKpiComparison(current, prior);
|
||||||
|
|
||||||
|
expect(deltas.wonInWindow).toBe(4);
|
||||||
|
expect(deltas.lostInWindow).toBe(-1);
|
||||||
|
expect(deltas.newLeadsInWindow).toBe(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('diffs winRate as a fraction when both periods have a rate', () => {
|
||||||
|
const current = makeKpis({ winRate: 0.5 });
|
||||||
|
const prior = makeKpis({ winRate: 0.4 });
|
||||||
|
const deltas = computeSalesKpiComparison(current, prior);
|
||||||
|
expect(deltas.winRate).toBeCloseTo(0.1, 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null winRate delta when either period has no rate', () => {
|
||||||
|
expect(
|
||||||
|
computeSalesKpiComparison(makeKpis({ winRate: 0.5 }), makeKpis({ winRate: null })).winRate,
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
computeSalesKpiComparison(makeKpis({ winRate: null }), makeKpis({ winRate: 0.5 })).winRate,
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('diffs medianTimeToCloseDays only when both periods have a median', () => {
|
||||||
|
expect(
|
||||||
|
computeSalesKpiComparison(
|
||||||
|
makeKpis({ medianTimeToCloseDays: 30 }),
|
||||||
|
makeKpis({ medianTimeToCloseDays: 45 }),
|
||||||
|
).medianTimeToCloseDays,
|
||||||
|
).toBe(-15);
|
||||||
|
expect(
|
||||||
|
computeSalesKpiComparison(
|
||||||
|
makeKpis({ medianTimeToCloseDays: null }),
|
||||||
|
makeKpis({ medianTimeToCloseDays: 45 }),
|
||||||
|
).medianTimeToCloseDays,
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user