{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "author": "Lopifi (https://lopifi.com)",
  "categories": [
    "metrics-analysis",
    "finance",
    "accounting"
  ],
  "dependencies": [],
  "description": "The P&L cut by department, entity or product.",
  "files": [
    {
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { cn } from \"@/lib/ledger/cn\";\nimport { shareOfTotal } from \"@/lib/ledger/period-change\";\nimport { InfoTip } from \"@/components/ledger/info-tip\";\nimport type { PeriodColumn, PeriodRow } from \"@/components/ledger/period-grid\";\nimport { PeriodGrid } from \"@/components/ledger/period-grid\";\n\nexport interface DimensionMember {\n  /** Members with no activity in the period are hidden by default, not dropped. */\n  hidden?: boolean;\n  id: string;\n  /** \"Sales\", \"Engineering\", \"UK entity\", \"Enterprise\". */\n  label: string;\n}\n\nexport interface PlDimension {\n  id: string;\n  /** \"Department\", \"Entity\", \"Product\", \"Region\". */\n  label: string;\n  members: DimensionMember[];\n}\n\nexport interface PlDimensionLine {\n  /**\n   * Values keyed by dimension member id. A member with no figure for this\n   * line is absent from the map, and renders `n/a` — not 0. A department that\n   * did not spend and a department whose spend has not posted are different\n   * facts.\n   */\n  byMember: Record<string, number | null>;\n  id: string;\n  label: string;\n  level?: number;\n  /** Required, no default. Expense lines are inverted — read from the account\n      type (`polarityForAccountType`), never left to fall back to `standard`. */\n  polarity: \"standard\" | \"inverted\" | \"neutral\";\n  /** Lines that are not allocated to any member — corporate, unallocated. */\n  unallocated?: number | null;\n  variant?: \"line\" | \"subtotal\" | \"total\" | \"section\" | \"ratio\";\n}\n\nexport interface PlByDimensionProps {\n  className?: string;\n  currency: string;\n  /** Which dimension is shown. Controlled when `onDimensionChange` is given. */\n  dimensionId?: string;\n  dimensions: PlDimension[];\n  lines: PlDimensionLine[];\n  onDimensionChange?: (id: string) => void;\n  /** The resolved period. \"October 2026\", never \"Current month\". */\n  periodLabel: string;\n  /** The revenue line, so every figure can be read as a share of it. */\n  revenueLineId?: string;\n  storageKey?: string;\n}\n\n/**\n * The profit and loss, cut by a dimension rather than by period.\n *\n * A monthly P&L answers \"what happened\"; this answers \"where\". They are the\n * same lines under a different question, and the second one is the one a\n * department head is actually in the room to ask.\n *\n * Three decisions carry it:\n *\n * 1. **Total is a column, and it is computed.** It is the sum of the members\n *    plus whatever is unallocated — never a separately-supplied figure that\n *    can drift from the columns beside it. A total that does not equal its\n *    parts is the fastest way to lose the room.\n * 2. **Unallocated is a column, not a rounding.** Corporate costs that belong\n *    to no department exist, and hiding them inside an allocation nobody\n *    agreed to is how department P&Ls become unarguable-with. It has its own\n *    column, and it is empty when there is nothing in it.\n * 3. **Every member column compares to total.** Switch the grid to `% of` and\n *    each cell reads as its share of the line — which is the real question\n *    behind \"is engineering heavy\", asked in one keystroke rather than in a\n *    second table.\n */\nexport function PlByDimension({\n  periodLabel,\n  dimensions,\n  dimensionId,\n  onDimensionChange,\n  lines,\n  currency,\n  revenueLineId,\n  storageKey,\n  className,\n}: PlByDimensionProps) {\n  const [uncontrolled, setUncontrolled] = React.useState(dimensions[0]?.id ?? \"\");\n  const activeId = dimensionId ?? uncontrolled;\n  const dimension = dimensions.find((d) => d.id === activeId) ?? dimensions[0];\n\n  const setDimension = (id: string) => {\n    setUncontrolled(id);\n    onDimensionChange?.(id);\n  };\n\n  const members = dimension?.members ?? [];\n  const hasUnallocated = lines.some((l) => l.unallocated != null);\n\n  const columns: PeriodColumn[] = React.useMemo(\n    () => [\n      ...members.map((m) => ({\n        hidden: m.hidden,\n        id: m.id,\n        kind: \"actual\" as const,\n        label: m.label,\n      })),\n      ...(hasUnallocated\n        ? [\n            {\n              hint: \"no owner\",\n              id: \"__unallocated\",\n              kind: \"derived\" as const,\n              label: \"Unallocated\",\n            },\n          ]\n        : []),\n      {\n        id: \"__total\",\n        kind: \"derived\" as const,\n        label: \"Total\",\n        locked: true,\n        width: 132,\n      },\n    ],\n    [members, hasUnallocated],\n  );\n\n  const rows: PeriodRow[] = React.useMemo(\n    () =>\n      lines.map((line) => {\n        const memberValues = members.map((m) => line.byMember[m.id] ?? null);\n        const unallocated = line.unallocated ?? null;\n\n        /* Summed here, from the same array the cells read. A column of\n           members and a total that came from somewhere else will eventually\n           disagree, and the disagreement will surface in a board meeting. */\n        const present = [...memberValues, unallocated].filter((v): v is number => v != null);\n        const total = present.length ? present.reduce((a, b) => a + b, 0) : null;\n\n        return {\n          derived: false,\n          id: line.id,\n          label: line.label,\n          level: line.level ?? 1,\n          polarity: line.polarity,\n          values: [...memberValues, ...(hasUnallocated ? [unallocated] : []), total],\n          variant: line.variant ?? \"line\",\n        };\n      }),\n    [lines, members, hasUnallocated],\n  );\n\n  /* The share each member takes of the revenue line, for the caption under\n     the grid — the one sentence a reader wants before they read a cell. */\n  const revenueSplit = React.useMemo(() => {\n    if (!revenueLineId) return null;\n    const line = lines.find((l) => l.id === revenueLineId);\n    const row = rows.find((r) => r.id === revenueLineId);\n    if (!line || !row) return null;\n    const total = row.values[row.values.length - 1];\n    const ranked = members\n      .map((m) => ({ label: m.label, share: shareOfTotal(line.byMember[m.id] ?? null, total) }))\n      .filter((m): m is { label: string; share: number } => m.share != null)\n      .sort((a, b) => b.share - a.share)\n      .slice(0, 3);\n    return ranked.length ? ranked : null;\n  }, [revenueLineId, lines, rows, members]);\n\n  return (\n    <div className={cn(\"space-y-3\", className)}>\n      <PeriodGrid\n        caption={`Profit and loss by ${dimension?.label.toLowerCase() ?? \"dimension\"}, ${periodLabel}`}\n        columns={columns}\n        currency={currency}\n        footnote={\n          <span className=\"flex flex-wrap items-center gap-x-4 gap-y-1\">\n            <span>{periodLabel} · Total is the sum of the columns to its left, including unallocated.</span>\n            {hasUnallocated ? (\n              <span className=\"flex items-center gap-1\">\n                Unallocated is shown, not spread\n                <InfoTip about=\"Why unallocated is its own column\">\n                  Corporate costs that belong to no member of this dimension. Spreading them across\n                  departments on a rule nobody agreed to is how a department P&L becomes something its owner\n                  cannot argue with — so they sit in their own column instead.\n                </InfoTip>\n              </span>\n            ) : null}\n            {revenueSplit ? (\n              <span>\n                Revenue concentration:{\" \"}\n                {revenueSplit.map((m) => `${m.label} ${m.share.toFixed(0)}%`).join(\" · \")}\n              </span>\n            ) : null}\n          </span>\n        }\n        labelHeader=\"Line\"\n        labelWidth={260}\n        rows={rows}\n        shareBase={revenueLineId}\n        storageKey={storageKey ? `${storageKey}:${activeId}` : undefined}\n        toolbar={\n          dimensions.length > 1 ? (\n            <label className=\"flex items-center gap-2\">\n              <span className=\"text-label font-medium text-text-secondary\">Cut by</span>\n              <select\n                className=\"h-8 rounded-sm border border-border-control bg-surface px-2 text-label outline-none focus-visible:border-accent focus-visible:ring-2 focus-visible:ring-accent-subtle\"\n                onChange={(e) => setDimension(e.target.value)}\n                value={activeId}\n              >\n                {dimensions.map((d) => (\n                  <option key={d.id} value={d.id}>\n                    {d.label}\n                  </option>\n                ))}\n              </select>\n            </label>\n          ) : null\n        }\n      />\n    </div>\n  );\n}\n",
      "path": "components/ledger/pl-by-dimension.tsx",
      "target": "components/ledger/pl-by-dimension.tsx",
      "type": "registry:component"
    }
  ],
  "meta": {
    "license": "MIT"
  },
  "name": "pl-by-dimension",
  "registryDependencies": [
    "@lopifi/theme",
    "@lopifi/cn",
    "@lopifi/info-tip",
    "@lopifi/period-change",
    "@lopifi/period-grid"
  ],
  "title": "PlByDimension",
  "type": "registry:component"
}
