PlByDimension
- Status
- Implemented
- Family
- 13 of 13
- Registry
- @lopifi/pl-by-dimension
npx shadcn@latest add @lopifi/theme @lopifi/pl-by-dimensionThe four-layer spec
The profit and loss cut by department, entity, product or region rather than by period. Same lines, different question — and the second one is what the department head is in the room to ask.
Total is a column and it is computed from the members beside it plus whatever is unallocated, never supplied separately. A total that does not equal its parts loses the room.
Unallocated is its own column, not a rounding and not an allocation. Corporate costs that belong to no department exist, and spreading them on a rule nobody agreed to is how a department P&L becomes something its owner cannot argue with.
Lifted verbatim from Metrics & analysis. The doc and this page are the same paragraphs, so they cannot disagree.
What lands in your repository
One component is never one file. Everything below is copied into your project, where you own it.
- Files
components/ledger/pl-by-dimension.tsx
- Lopifi dependencies · 14
- clipboardcncolumn-managerformat-moneyiconsinfo-tipmoneymoney.typesperiod-changeperiod-gridtooltipuse-column-widthsuse-grid-navuse-scroll-edges
- npm packages
- @base-ui-components/reactclass-variance-authorityclsxtailwind-merge
- Registry entry
- /r/pl-by-dimension.json
The whole component
No compiled bundle, no black box. This is the file the registry copies, and the file you will edit.
"use client";
import * as React from "react";
import { cn } from "@/lib/ledger/cn";
import { shareOfTotal } from "@/lib/ledger/period-change";
import { InfoTip } from "@/components/ledger/info-tip";
import type { PeriodColumn, PeriodRow } from "@/components/ledger/period-grid";
import { PeriodGrid } from "@/components/ledger/period-grid";
export interface DimensionMember {
/** Members with no activity in the period are hidden by default, not dropped. */
hidden?: boolean;
id: string;
/** "Sales", "Engineering", "UK entity", "Enterprise". */
label: string;
}
export interface PlDimension {
id: string;
/** "Department", "Entity", "Product", "Region". */
label: string;
members: DimensionMember[];
}
export interface PlDimensionLine {
/**
* Values keyed by dimension member id. A member with no figure for this
* line is absent from the map, and renders `n/a` — not 0. A department that
* did not spend and a department whose spend has not posted are different
* facts.
*/
byMember: Record<string, number | null>;
id: string;
label: string;
level?: number;
/** Expense lines are inverted. Read from the account type, never chosen per call. */
polarity?: "standard" | "inverted" | "neutral";
/** Lines that are not allocated to any member — corporate, unallocated. */
unallocated?: number | null;
variant?: "line" | "subtotal" | "total" | "section" | "ratio";
}
export interface PlByDimensionProps {
className?: string;
currency: string;
/** Which dimension is shown. Controlled when `onDimensionChange` is given. */
dimensionId?: string;
dimensions: PlDimension[];
lines: PlDimensionLine[];
onDimensionChange?: (id: string) => void;
/** The resolved period. "October 2026", never "Current month". */
periodLabel: string;
/** The revenue line, so every figure can be read as a share of it. */
revenueLineId?: string;
storageKey?: string;
}
/**
* The profit and loss, cut by a dimension rather than by period.
*
* A monthly P&L answers "what happened"; this answers "where". They are the
* same lines under a different question, and the second one is the one a
* department head is actually in the room to ask.
*
* Three decisions carry it:
*
* 1. **Total is a column, and it is computed.** It is the sum of the members
* plus whatever is unallocated — never a separately-supplied figure that
* can drift from the columns beside it. A total that does not equal its
* parts is the fastest way to lose the room.
* 2. **Unallocated is a column, not a rounding.** Corporate costs that belong
* to no department exist, and hiding them inside an allocation nobody
* agreed to is how department P&Ls become unarguable-with. It has its own
* column, and it is empty when there is nothing in it.
* 3. **Every member column compares to total.** Switch the grid to `% of` and
* each cell reads as its share of the line — which is the real question
* behind "is engineering heavy", asked in one keystroke rather than in a
* second table.
*/
export function PlByDimension({
periodLabel,
dimensions,
dimensionId,
onDimensionChange,
lines,
currency,
revenueLineId,
storageKey,
className,
}: PlByDimensionProps) {
const [uncontrolled, setUncontrolled] = React.useState(dimensions[0]?.id ?? "");
const activeId = dimensionId ?? uncontrolled;
const dimension = dimensions.find((d) => d.id === activeId) ?? dimensions[0];
const setDimension = (id: string) => {
setUncontrolled(id);
onDimensionChange?.(id);
};
const members = dimension?.members ?? [];
const hasUnallocated = lines.some((l) => l.unallocated != null);
const columns: PeriodColumn[] = React.useMemo(
() => [
...members.map((m) => ({
hidden: m.hidden,
id: m.id,
kind: "actual" as const,
label: m.label,
})),
...(hasUnallocated
? [
{
hint: "no owner",
id: "__unallocated",
kind: "derived" as const,
label: "Unallocated",
},
]
: []),
{
id: "__total",
kind: "derived" as const,
label: "Total",
locked: true,
width: 132,
},
],
[members, hasUnallocated],
);
const rows: PeriodRow[] = React.useMemo(
() =>
lines.map((line) => {
const memberValues = members.map((m) => line.byMember[m.id] ?? null);
const unallocated = line.unallocated ?? null;
/* Summed here, from the same array the cells read. A column of
members and a total that came from somewhere else will eventually
disagree, and the disagreement will surface in a board meeting. */
const present = [...memberValues, unallocated].filter((v): v is number => v != null);
const total = present.length ? present.reduce((a, b) => a + b, 0) : null;
return {
derived: false,
id: line.id,
label: line.label,
level: line.level ?? 1,
polarity: line.polarity ?? "standard",
values: [...memberValues, ...(hasUnallocated ? [unallocated] : []), total],
variant: line.variant ?? "line",
};
}),
[lines, members, hasUnallocated],
);
/* The share each member takes of the revenue line, for the caption under
the grid — the one sentence a reader wants before they read a cell. */
const revenueSplit = React.useMemo(() => {
if (!revenueLineId) return null;
const line = lines.find((l) => l.id === revenueLineId);
const row = rows.find((r) => r.id === revenueLineId);
if (!line || !row) return null;
const total = row.values[row.values.length - 1];
const ranked = members
.map((m) => ({ label: m.label, share: shareOfTotal(line.byMember[m.id] ?? null, total) }))
.filter((m): m is { label: string; share: number } => m.share != null)
.sort((a, b) => b.share - a.share)
.slice(0, 3);
return ranked.length ? ranked : null;
}, [revenueLineId, lines, rows, members]);
return (
<div className={cn("space-y-3", className)}>
<PeriodGrid
caption={`Profit and loss by ${dimension?.label.toLowerCase() ?? "dimension"}, ${periodLabel}`}
columns={columns}
currency={currency}
footnote={
<span className="flex flex-wrap items-center gap-x-4 gap-y-1">
<span>{periodLabel} · Total is the sum of the columns to its left, including unallocated.</span>
{hasUnallocated ? (
<span className="flex items-center gap-1">
Unallocated is shown, not spread
<InfoTip about="Why unallocated is its own column">
Corporate costs that belong to no member of this dimension. Spreading them across
departments on a rule nobody agreed to is how a department P&L becomes something its owner
cannot argue with — so they sit in their own column instead.
</InfoTip>
</span>
) : null}
{revenueSplit ? (
<span>
Revenue concentration:{" "}
{revenueSplit.map((m) => `${m.label} ${m.share.toFixed(0)}%`).join(" · ")}
</span>
) : null}
</span>
}
labelHeader="Line"
labelWidth={260}
rows={rows}
shareBase={revenueLineId}
storageKey={storageKey ? `${storageKey}:${activeId}` : undefined}
toolbar={
dimensions.length > 1 ? (
<label className="flex items-center gap-2">
<span className="text-label font-medium text-text-secondary">Cut by</span>
<select
className="h-8 rounded-sm border border-border bg-surface px-2 text-label outline-none focus-visible:border-accent focus-visible:ring-2 focus-visible:ring-accent-subtle"
onChange={(e) => setDimension(e.target.value)}
value={activeId}
>
{dimensions.map((d) => (
<option key={d.id} value={d.id}>
{d.label}
</option>
))}
</select>
</label>
) : null
}
/>
</div>
);
}
The accounting it encodes
Concepts this component renders. Each one is defined once, in the lexicon, and used identically in the code and the copy.
Also in Metrics & analysis
- MetricDefinitionEvery metric requires one. Formula with values substituted.
- NonGaapReconciliationEBITDA below net income, every adjustment named.Spec
- MrrBridgeThe movement, not a single number with a percentage.Spec
- BudgetVsActualPolarity-aware, materiality-filtered, versions tracked.
- ForecastTableProjections dashed and labeled everywhere.
- CovenantMonitorThe contractual definition, not the generic one.Spec
- KpiHealthStripProvisional marking for pre-close numbers.Spec
- RevenueTrackerRevenue by stream, with mix, growth and concentration.
- ExpenseTrackerSpend by category on three bases. Polarity derived per row from the sign convention.
- DriverModelThe inputs a forecast is built from, and what they produce.
- ScenarioCompareScenarios side by side, all measured against the base.
See also
A component is specified in one place, defined in another, and running in a third. All three, from here.
- Metrics & analysis — the family specThe four-layer specification this component was built against, before it was written.Doc
- Financial metric components: definitions and varianceThe family running, with real figures in it, in both themes and at every density.Live
- CatalogAll 179 components, specified before they are written.Doc
- DimensionA non-account axis: entity, department, projectTerm
Elsewhere in the system
Where PlByDimension sits in the rest of the system — the live section it renders in, the calculators that compute what it displays, and the ledger payloads it is fed from.
Components are copied into your repository and meant to be edited — rounding policy, fiscal calendar and compliance copy differ per company.
Told when Metrics & analysis changes
Components are copied into your repo, so a fix here does not reach you on its own. One email when a component in this family changes in a way that needs a migration — and nothing when it does not.