{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "author": "Lopifi (https://lopifi.com)",
  "categories": [
    "metrics-analysis",
    "finance",
    "accounting"
  ],
  "dependencies": [],
  "description": "Projections dashed and labeled everywhere.",
  "files": [
    {
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { cn } from \"@/lib/ledger/cn\";\nimport { formatDate } from \"@/lib/ledger/fiscal-period\";\nimport { splitInProgress } from \"@/lib/ledger/forecast\";\nimport type { Polarity } from \"@/lib/ledger/money.types\";\nimport { InfoTip } from \"@/components/ledger/info-tip\";\nimport type { GridUnit, PeriodColumn, PeriodRow } from \"@/components/ledger/period-grid\";\nimport { PeriodGrid } from \"@/components/ledger/period-grid\";\n\nexport type ForecastRow = {\n  id: string;\n  label: string;\n  /**\n   * One entry per period, in minor units. For the in-progress period this is\n   * the **landing** — the whole period as currently expected, not the part of\n   * it that has posted.\n   */\n  values: (number | null)[];\n  polarity?: Polarity;\n  unit?: GridUnit;\n  level?: number;\n  variant?: \"line\" | \"subtotal\" | \"total\" | \"section\";\n};\n\n/**\n * The period that has started and not finished.\n *\n * The single most misread column in any forecast pack. Half of September is a\n * fact and half of it is a belief, and a grid that renders the month as one\n * figure has quietly picked one of those words for both halves — usually the\n * wrong one, because the figure sits next to eight closed months and inherits\n * their authority.\n */\nexport type InProgressPeriod = {\n  /** Index into `periods` of the period part-way through. */\n  index: number;\n  /**\n   * What has actually posted so far, per row id.\n   *\n   * **Flow rows only.** A period splits into what happened and what is left to\n   * happen for revenue, cost and headcount added — figures that accumulate.\n   * It does not split that way for a balance: cash at the 21st and cash at\n   * month end are two readings of a stock, not two parts of one, and adding\n   * them produces a number that means nothing. Leave a balance row out of this\n   * map and it renders its landing alone, which is the whole truth about it.\n   */\n  toDate: Record<string, number | null>;\n  /** The date the actuals run to. Stated, never implied. */\n  asOf: Date;\n};\n\nexport interface ForecastTableProps {\n  /**\n   * Index of the last **closed** period. Everything after it is a projection.\n   * Where a period is part-way through, it is named in `inProgress` rather\n   * than folded into either side.\n   */\n  actualThrough: number;\n  className?: string;\n  currency: string;\n  /** The period that has started but not closed, split three ways. */\n  inProgress?: InProgressPeriod;\n  onScenarioChange: (s: string) => void;\n  periods: string[];\n  rows: ForecastRow[];\n  scenario: string;\n  scenarios: string[];\n  /** The row every \"% of\" figure is taken against — usually revenue. */\n  shareBase?: string;\n  storageKey?: string;\n}\n\n/**\n * Driver-based projections, with the boundary between what happened and what\n * is believed drawn in three places rather than one.\n *\n * 1. **Between periods.** Everything after the last closed period is shaded,\n *    ruled with a dashed edge, set in the secondary tone, and headed by a\n *    column that says `forecast` under its label — so every projected figure\n *    on screen sits beneath the word, not merely near it.\n * 2. **Within a period.** The month that has started and not finished is not\n *    one column. It is three: what has posted, what is expected for the rest,\n *    and the landing they sum to. Reporting a part-elapsed month as a single\n *    figure beside eight closed ones is how a forecast gets quoted as an\n *    actual, and it is the specific error this component exists to prevent.\n * 3. **Between scenarios.** The scenario in force is named in the caption and\n *    in the toolbar. An unlabelled forecast is indistinguishable from a claim.\n *\n * It will not rank the scenarios, and it will not style one as the good case.\n * A scenario is a set of assumptions; deciding which of them to hope for is\n * the reader's job and stating it in the chrome is how a forecast review turns\n * into a negotiation about which column to show the board.\n */\nexport function ForecastTable({\n  periods,\n  actualThrough,\n  rows,\n  currency,\n  scenario,\n  scenarios,\n  onScenarioChange,\n  inProgress,\n  shareBase,\n  storageKey,\n  className,\n}: ForecastTableProps) {\n  /* The in-progress period is expanded in place into three columns. Doing it\n     here rather than asking the caller for a pre-split shape keeps `values`\n     meaning one thing — the whole period — in every row. */\n  const columns: PeriodColumn[] = React.useMemo(() => {\n    const out: PeriodColumn[] = [];\n    periods.forEach((p, i) => {\n      if (inProgress && i === inProgress.index) {\n        out.push(\n          {\n            hint: `Posted through ${formatDate(inProgress.asOf)}`,\n            id: `${p}--to-date`,\n            kind: \"actual\",\n            label: p,\n          },\n          { hint: \"Expected for the rest of the period\", id: `${p}--remaining`, kind: \"forecast\", label: p },\n          { hint: \"To date plus remaining\", id: `${p}--landing`, kind: \"derived\", label: p, locked: true },\n        );\n        return;\n      }\n      out.push({\n        id: p,\n        kind: i > actualThrough ? \"forecast\" : \"actual\",\n        label: p,\n        /* The last closed period is the anchor every projection is read\n           against, so it is the one column the reader cannot hide out from\n           under themselves. */\n        locked: i === actualThrough,\n      });\n    });\n    return out;\n  }, [periods, actualThrough, inProgress]);\n\n  /* Where the split sits in the expanded column list — the grid's own\n     `actualThrough` counts columns, not periods, and the two stop agreeing the\n     moment one period becomes three. */\n  const actualThroughColumn = React.useMemo(() => {\n    if (!inProgress) return actualThrough;\n    /* The to-date column is the last thing on screen that has posted. */\n    return inProgress.index <= actualThrough ? actualThrough : actualThrough + 1;\n  }, [actualThrough, inProgress]);\n\n  const gridRows: PeriodRow[] = React.useMemo(\n    () =>\n      rows.map((r) => {\n        const values: (number | null)[] = [];\n        periods.forEach((_, i) => {\n          const whole = r.values[i] ?? null;\n          if (inProgress && i === inProgress.index) {\n            /* A row absent from the map is a row the split does not apply to\n               — a balance, or anything else read rather than accumulated. It\n               shows its landing and nothing else. `remaining` is derived\n               inside `splitInProgress`, never supplied. */\n            const split = splitInProgress(whole, inProgress.toDate[r.id]);\n            values.push(split.toDate, split.remaining, split.landing);\n            return;\n          }\n          values.push(whole);\n        });\n        return {\n          id: r.id,\n          label: r.label,\n          level: r.level,\n          polarity: r.polarity ?? \"standard\",\n          unit: r.unit,\n          values,\n          variant: r.variant,\n        };\n      }),\n    [rows, periods, inProgress],\n  );\n\n  const inProgressLabel = inProgress ? periods[inProgress.index] : null;\n\n  return (\n    <div className={cn(\"space-y-4\", className)}>\n      <PeriodGrid\n        actualThrough={actualThroughColumn}\n        caption={`Forecast, ${scenario} scenario`}\n        columns={columns}\n        currency={currency}\n        footnote={\n          <>\n            Columns after <span className=\"font-medium\">{periods[actualThrough]}</span> are projections, not\n            actuals — they are shaded, ruled with a dashed edge, set in the secondary tone, and headed{\" \"}\n            <span className=\"font-medium\">forecast</span>, so every projected figure sits under the word.\n            Nothing after that edge has been posted.\n            {inProgress ? (\n              <>\n                {\" \"}\n                <span className=\"font-medium\">{inProgressLabel}</span> has started and not closed, so it is\n                three columns rather than one: posted through {formatDate(inProgress.asOf)}, expected for the\n                rest, and the landing they sum to. Only the first of those is a fact.\n              </>\n            ) : null}\n          </>\n        }\n        labelHeader=\"Driver\"\n        rows={gridRows}\n        shareBase={shareBase}\n        storageKey={storageKey}\n        toolbar={\n          <label className=\"flex items-center gap-2\">\n            <span className=\"text-label font-medium text-text-secondary\">Scenario</span>\n            <select\n              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\"\n              onChange={(e) => onScenarioChange(e.target.value)}\n              value={scenario}\n            >\n              {scenarios.map((s) => (\n                <option key={s}>{s}</option>\n              ))}\n            </select>\n          </label>\n        }\n      />\n\n      {inProgress ? (\n        <p className=\"text-caption text-text-tertiary\">\n          <span className=\"font-medium text-text-secondary\">\n            Why {inProgressLabel} is split\n            <InfoTip about=\"Actual-to-date and forecast-remaining\">\n              A part-elapsed period reported as one figure sits beside closed months and inherits their\n              authority. Splitting it is the difference between &ldquo;we are at 41.2 with nine days to\n              go&rdquo; and &ldquo;we did 58.2&rdquo; — the second of which nobody said, and everybody quotes.\n            </InfoTip>\n          </span>{\" \"}\n          The landing column is to-date plus remaining, computed here rather than supplied, so the three\n          figures cannot disagree.\n        </p>\n      ) : null}\n    </div>\n  );\n}\n",
      "path": "components/ledger/forecast-table.tsx",
      "target": "components/ledger/forecast-table.tsx",
      "type": "registry:component"
    }
  ],
  "name": "forecast-table",
  "registryDependencies": [
    "@lopifi/theme",
    "@lopifi/cn",
    "@lopifi/fiscal-period",
    "@lopifi/forecast",
    "@lopifi/info-tip",
    "@lopifi/money.types",
    "@lopifi/period-grid"
  ],
  "title": "ForecastTable",
  "type": "registry:component"
}
