{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "author": "Lopifi (https://lopifi.com)",
  "categories": [
    "accounting-core-gl",
    "finance",
    "accounting"
  ],
  "dependencies": [],
  "description": "Line grid with live balance check and dimension capture.",
  "files": [
    {
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport type { Account, DimensionKey } from \"@/lib/ledger/accounting.types\";\nimport { cn } from \"@/lib/ledger/cn\";\nimport { formatMoneyInput, parseMoneyInput } from \"@/lib/ledger/format-money\";\nimport { AccountPicker } from \"@/components/ledger/account-picker\";\nimport { Button } from \"@/components/ledger/button\";\nimport { CutoffWarning } from \"@/components/ledger/cutoff-warning\";\nimport { DimensionPicker } from \"@/components/ledger/dimension-picker\";\nimport { CheckIcon, ExclamationTriangleIcon, PlusIcon, TrashIcon } from \"@/components/ledger/icons\";\nimport { Money } from \"@/components/ledger/money\";\nimport { Rule } from \"@/components/ledger/rule\";\n\ntype DraftLine = {\n  key: string;\n  account: Account | null;\n  debit: number | null;\n  credit: number | null;\n  memo: string;\n  dimensions: Partial<Record<DimensionKey, string>>;\n};\n\nexport interface JournalEntryFormProps {\n  accounts: Account[];\n  className?: string;\n  /** Posting into a closed period gets the cutoff explanation, not a bare\n      rejection. */\n  closedThrough?: Date;\n  currency: string;\n  onPost?: (lines: DraftLine[], date: string, description: string) => void;\n}\n\nconst emptyLine = (): DraftLine => ({\n  account: null,\n  credit: null,\n  debit: null,\n  dimensions: {},\n  key: Math.random().toString(36).slice(2),\n  memo: \"\",\n});\n\n/**\n * Tab moves across the row, Enter adds a line, and the amount fields follow\n * the AmountInput rules. `requiredDimensions` is read from the account the\n * moment it is picked, and those fields appear inline.\n */\nexport function JournalEntryForm({\n  accounts,\n  currency,\n  closedThrough,\n  onPost,\n  className,\n}: JournalEntryFormProps) {\n  const [lines, setLines] = React.useState<DraftLine[]>([emptyLine(), emptyLine()]);\n  const [date, setDate] = React.useState(() => new Date().toISOString().slice(0, 10));\n  const [description, setDescription] = React.useState(\"\");\n\n  const totalDebit = lines.reduce((s, l) => s + (l.debit ?? 0), 0);\n  const totalCredit = lines.reduce((s, l) => s + (l.credit ?? 0), 0);\n  const balanced = totalDebit === totalCredit && totalDebit > 0;\n\n  const entryDate = new Date(`${date}T00:00:00`);\n  const inClosedPeriod = closedThrough != null && entryDate <= closedThrough;\n\n  const missingDimensions = lines.some(\n    (l) => l.account?.requiredDimensions.some((d) => !l.dimensions[d]) ?? false,\n  );\n\n  const update = (key: string, patch: Partial<DraftLine>) =>\n    setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l)));\n\n  return (\n    <div className={cn(\"space-y-4\", className)}>\n      <div className=\"grid gap-3 sm:grid-cols-[160px_1fr]\">\n        <label className=\"block\">\n          <span className=\"mb-2 block text-label font-medium text-text-secondary\">Date</span>\n          <input\n            className=\"numeric h-10 w-full rounded-sm border border-border bg-surface px-3 text-body outline-none focus-visible:border-accent\"\n            onChange={(e) => setDate(e.target.value)}\n            type=\"date\"\n            value={date}\n          />\n        </label>\n        <label className=\"block\">\n          <span className=\"mb-2 block text-label font-medium text-text-secondary\">Description</span>\n          <input\n            className=\"h-10 w-full rounded-sm border border-border bg-surface px-3 text-body outline-none focus-visible:border-accent\"\n            onChange={(e) => setDescription(e.target.value)}\n            placeholder=\"Accrue September professional fees\"\n            value={description}\n          />\n        </label>\n      </div>\n\n      {inClosedPeriod && closedThrough ? (\n        <CutoffWarning closedThrough={closedThrough} entryDate={entryDate} />\n      ) : null}\n\n      <div className=\"overflow-x-auto rounded-xl border border-border bg-surface\">\n        <table className=\"w-full min-w-[820px]\">\n          <caption className=\"sr-only\">Journal entry lines</caption>\n          <thead>\n            <tr className=\"border-b border-border-subtle\">\n              <th className=\"px-4 py-2 text-left text-label font-medium text-text-secondary\" scope=\"col\">\n                Account\n              </th>\n              <th className=\"px-3 py-2 text-left text-label font-medium text-text-secondary\" scope=\"col\">\n                Dimensions\n              </th>\n              <th className=\"px-3 py-2 text-right text-label font-medium text-text-secondary\" scope=\"col\">\n                Debit\n              </th>\n              <th className=\"px-3 py-2 text-right text-label font-medium text-text-secondary\" scope=\"col\">\n                Credit\n              </th>\n              <th className=\"w-10 px-3 py-2\" scope=\"col\">\n                <span className=\"sr-only\">Remove</span>\n              </th>\n            </tr>\n          </thead>\n          <tbody>\n            {lines.map((line) => (\n              <tr className=\"border-b border-border-subtle align-top\" key={line.key}>\n                <td className=\"w-[280px] px-4 py-2\">\n                  <AccountPicker\n                    accounts={accounts}\n                    onValueChange={(account) => update(line.key, { account })}\n                    value={line.account?.id ?? null}\n                  />\n                  <input\n                    aria-label=\"Line memo\"\n                    className=\"mt-1 h-8 w-full rounded-sm border border-border bg-surface px-2 text-caption outline-none focus-visible:border-accent\"\n                    onChange={(e) => update(line.key, { memo: e.target.value })}\n                    placeholder=\"Line memo\"\n                    value={line.memo}\n                  />\n                </td>\n                <td className=\"px-3 py-2\">\n                  <DimensionPicker\n                    onValueChange={(dimensions) => update(line.key, { dimensions })}\n                    required={line.account?.requiredDimensions ?? [\"entityId\"]}\n                    value={line.dimensions}\n                  />\n                </td>\n                <AmountCell\n                  currency={currency}\n                  label=\"Debit\"\n                  onChange={(v) => update(line.key, { credit: v == null ? line.credit : null, debit: v })}\n                  value={line.debit}\n                />\n                <AmountCell\n                  currency={currency}\n                  label=\"Credit\"\n                  onChange={(v) => update(line.key, { credit: v, debit: v == null ? line.debit : null })}\n                  value={line.credit}\n                />\n                <td className=\"px-3 py-2\">\n                  <button\n                    aria-label=\"Remove line\"\n                    className=\"press grid size-8 place-items-center rounded-sm text-text-tertiary hover:bg-surface-hover disabled:opacity-30\"\n                    disabled={lines.length <= 2}\n                    onClick={() => setLines((prev) => prev.filter((l) => l.key !== line.key))}\n                    type=\"button\"\n                  >\n                    <TrashIcon aria-hidden=\"true\" className=\"size-4\" />\n                  </button>\n                </td>\n              </tr>\n            ))}\n          </tbody>\n          <tfoot>\n            <tr>\n              <td colSpan={2} />\n              <td className=\"px-3\">\n                <Rule variant=\"subtotal\" />\n              </td>\n              <td className=\"px-3\">\n                <Rule variant=\"subtotal\" />\n              </td>\n              <td />\n            </tr>\n            <tr>\n              <th className=\"px-4 py-3 text-left text-label font-medium\" colSpan={2} scope=\"row\">\n                Totals\n              </th>\n              <td className=\"px-3 py-3 text-right\">\n                <Money accounting size=\"md\" value={{ amount: totalDebit, currency }} />\n              </td>\n              <td className=\"px-3 py-3 text-right\">\n                <Money accounting size=\"md\" value={{ amount: totalCredit, currency }} />\n              </td>\n              <td />\n            </tr>\n          </tfoot>\n        </table>\n      </div>\n\n      <div className=\"flex flex-wrap items-center gap-3\">\n        <Button onClick={() => setLines((prev) => [...prev, emptyLine()])} size=\"sm\" variant=\"secondary\">\n          <PlusIcon aria-hidden=\"true\" className=\"size-3.5\" />\n          Add line\n        </Button>\n\n        <p\n          className={cn(\n            \"flex items-center gap-2 rounded-sm px-3 py-2 text-body\",\n            balanced ? \"bg-positive-subtle text-positive\" : \"bg-surface-sunken text-text-secondary\",\n          )}\n          role={balanced ? undefined : \"status\"}\n        >\n          {balanced ? (\n            <CheckIcon aria-hidden=\"true\" className=\"size-4\" />\n          ) : (\n            <ExclamationTriangleIcon aria-hidden=\"true\" className=\"size-4\" />\n          )}\n          {balanced ? (\n            \"Balanced.\"\n          ) : (\n            <>\n              Out of balance by{\" \"}\n              <Money tone=\"inherit\" value={{ amount: Math.abs(totalDebit - totalCredit), currency }} />\n            </>\n          )}\n        </p>\n\n        <Button\n          className=\"ml-auto\"\n          disabled={!balanced || missingDimensions || !description.trim()}\n          onClick={() => onPost?.(lines, date, description)}\n          variant=\"primary\"\n        >\n          Post entry\n        </Button>\n      </div>\n\n      {missingDimensions ? (\n        <p className=\"text-caption text-negative\" role=\"alert\">\n          One or more lines are missing a dimension their account requires.\n        </p>\n      ) : null}\n    </div>\n  );\n}\n\nfunction AmountCell({\n  value,\n  currency,\n  label,\n  onChange,\n}: {\n  value: number | null;\n  currency: string;\n  label: string;\n  onChange: (v: number | null) => void;\n}) {\n  const [draft, setDraft] = React.useState(\"\");\n  const [focused, setFocused] = React.useState(false);\n\n  React.useEffect(() => {\n    if (focused) return;\n    setDraft(value == null || value === 0 ? \"\" : formatMoneyInput(value, currency));\n  }, [value, currency, focused]);\n\n  return (\n    <td className=\"px-3 py-2\">\n      <input\n        aria-label={label}\n        className=\"numeric h-10 w-full rounded-sm border border-border bg-surface px-2 text-right text-body outline-none focus-visible:border-accent\"\n        inputMode=\"decimal\"\n        onBlur={() => {\n          setFocused(false);\n          onChange(parseMoneyInput(draft, currency));\n        }}\n        onChange={(e) => {\n          setDraft(e.target.value);\n          onChange(parseMoneyInput(e.target.value, currency));\n        }}\n        onFocus={() => setFocused(true)}\n        type=\"text\"\n        value={draft}\n      />\n    </td>\n  );\n}\n",
      "path": "components/ledger/journal-entry-form.tsx",
      "target": "components/ledger/journal-entry-form.tsx",
      "type": "registry:component"
    }
  ],
  "name": "journal-entry-form",
  "registryDependencies": [
    "@lopifi/theme",
    "@lopifi/account-picker",
    "@lopifi/accounting.types",
    "@lopifi/button",
    "@lopifi/cn",
    "@lopifi/cutoff-warning",
    "@lopifi/dimension-picker",
    "@lopifi/format-money",
    "@lopifi/icons",
    "@lopifi/money",
    "@lopifi/rule"
  ],
  "title": "JournalEntryForm",
  "type": "registry:component"
}
