components/ledger/journal-entry.tsx Copy Copied Copy import * as React from "react";
import type { JournalEntryRecord } from "@/lib/ledger/accounting.types";
import { cn } from "@/lib/ledger/cn";
import { formatDate } from "@/lib/ledger/fiscal-period";
import type { MoneyValue } from "@/lib/ledger/money.types";
import { ArrowUturnLeftIcon, CheckIcon, ExclamationTriangleIcon, PaperClipIcon } from "@/components/ledger/icons";
import { InfoTip } from "@/components/ledger/info-tip";
import { Money } from "@/components/ledger/money";
import { Reference } from "@/components/ledger/reference";
import { Rule } from "@/components/ledger/rule";
import { Tooltip } from "@/components/ledger/tooltip";
export interface JournalEntryProps extends React.ComponentPropsWithoutRef<"article"> {
actions?: React.ReactNode;
currency: string;
entry: JournalEntryRecord;
totalCredit: MoneyValue;
/** Totals passed in, already computed. */
totalDebit: MoneyValue;
}
const SOURCE_LABEL: Record<string, string> = {
adjusting: "Adjusting",
elimination: "Elimination",
import: "Import",
manual: "Manual",
recurring: "Recurring",
schedule: "Schedule",
subledger: "Subledger",
translation: "Translation",
};
/**
* Debit and credit as two columns. There is no single-signed-column variant.
*
* A posted entry is never edited or deleted — it is reversed. The delete
* action is **absent**, not disabled.
*
* Server-safe: a 400-line entry renders on the server.
*/
export const JournalEntry = React.forwardRef<HTMLElement, JournalEntryProps>(
({ className, entry, currency, totalDebit, totalCredit, actions, ...props }, ref) => {
const balanced = Number(totalDebit.amount) === Number(totalCredit.amount);
const difference = Number(totalDebit.amount) - Number(totalCredit.amount);
return (
<article className={cn("rounded-xl border border-border bg-surface", className)} ref={ref} {...props}>
<header className="flex flex-wrap items-start justify-between gap-4 border-b border-border-subtle px-6 py-4">
<div>
<p className="flex flex-wrap items-center gap-2 text-caption text-text-secondary">
<Reference truncate={false} value={entry.entryNumber} />
<span aria-hidden="true">·</span>
<span className="numeric">{formatDate(entry.date)}</span>
<span aria-hidden="true">·</span>
{SOURCE_LABEL[entry.source] ?? entry.source}
<span aria-hidden="true">·</span>
<span className="capitalize">{entry.status}</span>
</p>
<p className="mt-1 text-body font-medium">{entry.description}</p>
</div>
{/* No delete. Absent, not disabled. */}
<div className="flex gap-2">{actions}</div>
</header>
{/* Never sortable: the lines of one entry read debits first, then
credits, and that order is the double-entry itself rather than a
default the reader is free to change. */}
<table className="w-full" data-sortable="false">
{/* A width *preference*, not a fixed layout. Auto layout still honours
each money column's content minimum, so a figure can never be
clipped; the percentages only stop the account column from being
squeezed to three wrapped lines while the credit column sits half
empty. `table-fixed` does the opposite and clips the money. */}
<colgroup>
<col className="w-[44%]" />
<col className="w-[20%]" />
<col className="w-[18%]" />
<col className="w-[18%]" />
</colgroup>
<caption className="sr-only">
Journal entry {entry.entryNumber}, {entry.lines.length} lines
</caption>
<thead>
<tr className="border-b border-border-subtle">
<th className="px-6 py-2 text-left text-label font-medium text-text-secondary" scope="col">
Account
</th>
<th className="px-3 py-2 text-left text-label font-medium text-text-secondary" scope="col">
Dimensions
</th>
<th className="px-3 py-2 text-right text-label font-medium text-text-secondary" scope="col">
Debit
</th>
<th className="px-6 py-2 text-right text-label font-medium text-text-secondary" scope="col">
Credit
</th>
</tr>
</thead>
<tbody>
{entry.lines.map((line) => (
<tr className="border-b border-border-subtle" key={line.id}>
<th className="px-6 py-2 text-left font-normal" scope="row">
<span className="reference-numeric text-caption text-text-tertiary">
{line.accountNumber}
</span>{" "}
<span className="text-body">{line.accountName}</span>
{line.memo ? (
// A line memo is auditor-relevant but rarely the thing being
// scanned. It keeps its own line and truncates; the full text
// is one hover away rather than three wrapped lines deep.
<Tooltip content={line.memo} display="block">
<span className="block truncate text-caption text-text-secondary">{line.memo}</span>
</Tooltip>
) : null}
</th>
<td className="px-3 py-2 text-left text-caption text-text-secondary">
{dimensionSummary(line.dimensions)}
</td>
<td className="px-3 py-2 text-right align-top">
{line.debit ? <Money accounting value={line.debit} /> : null}
</td>
<td className="px-6 py-2 text-right align-top">
{line.credit ? <Money accounting value={line.credit} /> : null}
</td>
</tr>
))}
</tbody>
<tfoot>
<tr>
<td colSpan={2} />
<td className="px-3">
<Rule variant="subtotal" />
</td>
<td className="px-6">
<Rule variant="subtotal" />
</td>
</tr>
<tr>
<th className="px-6 py-2 text-left text-label font-medium" colSpan={2} scope="row">
Totals
</th>
<td className="px-3 py-2 text-right">
<Money accounting size="md" value={totalDebit} />
</td>
<td className="px-6 py-2 text-right">
<Money accounting size="md" value={totalCredit} />
</td>
</tr>
<tr>
<td colSpan={2} />
<td className="px-3">
<Rule variant="total" />
</td>
<td className="px-6">
<Rule variant="total" />
</td>
</tr>
</tfoot>
</table>
<div className="space-y-2 px-6 py-4">
<p
className={cn(
"flex items-center gap-2 rounded-sm px-3 py-2 text-body",
balanced ? "bg-positive-subtle text-positive" : "bg-negative-subtle text-negative",
)}
role={balanced ? undefined : "alert"}
>
{balanced ? (
<CheckIcon aria-hidden="true" className="size-4 shrink-0" />
) : (
<ExclamationTriangleIcon aria-hidden="true" className="size-4 shrink-0" />
)}
{balanced ? (
"Balanced."
) : (
<>
Out of balance by <Money tone="inherit" value={{ amount: Math.abs(difference), currency }} />
<InfoTip
about="What an out-of-balance entry means"
label="This entry cannot post"
triggerClassName="text-negative"
>
Debits and credits must agree to the cent before an entry reaches the ledger. Correct a
line, or add the balancing one.
</InfoTip>
</>
)}
</p>
{/* One reversal line, not three stacked sentences. Each fact is short
enough to sit beside the others; the one piece of doctrine —
that reversal never removes the original — is the tooltip. */}
{entry.autoReverseOn || entry.reversalOf || entry.reversedBy ? (
<p className="flex flex-wrap items-center gap-x-2 gap-y-1 text-caption text-text-secondary">
<ArrowUturnLeftIcon aria-hidden="true" className="size-3.5 shrink-0" />
{entry.autoReverseOn ? (
<span>
Reverses <span className="numeric">{formatDate(entry.autoReverseOn)}</span>
</span>
) : null}
{entry.reversalOf ? (
<>
{entry.autoReverseOn ? <span aria-hidden="true">·</span> : null}
<span>Reverses {entry.reversalOf}</span>
</>
) : null}
{entry.reversedBy ? (
<>
{entry.autoReverseOn || entry.reversalOf ? <span aria-hidden="true">·</span> : null}
<span>Reversed by {entry.reversedBy}</span>
</>
) : null}
<InfoTip about="How reversal works" label="Reversal, not deletion">
A posted entry is never edited or removed. The original and its reversal both stay in the
ledger, and the audit trail shows both.
</InfoTip>
</p>
) : null}
{entry.evidence?.length ? (
<ul className="flex flex-wrap gap-2">
{entry.evidence.map((e) => (
<li
className="inline-flex items-center gap-1.5 rounded-sm border border-border px-2 py-1 text-caption"
key={e.name}
>
<PaperClipIcon aria-hidden="true" className="size-3 text-text-tertiary" />
{e.name}
<span className="reference-numeric text-text-tertiary">{e.hash.slice(0, 8)}</span>
</li>
))}
</ul>
) : null}
</div>
</article>
);
},
);
JournalEntry.displayName = "JournalEntry";
function dimensionSummary(dimensions: Record<string, string | undefined>): string {
return Object.entries(dimensions)
.filter(([, v]) => v)
.map(([, v]) => v)
.join(" · ");
}
import * as React from "react";
import type { JournalEntryRecord } from "@/lib/ledger/accounting.types";
import { cn } from "@/lib/ledger/cn";
import { formatDate } from "@/lib/ledger/fiscal-period";
import type { MoneyValue } from "@/lib/ledger/money.types";
import { ArrowUturnLeftIcon, CheckIcon, ExclamationTriangleIcon, PaperClipIcon } from "@/components/ledger/icons";
import { InfoTip } from "@/components/ledger/info-tip";
import { Money } from "@/components/ledger/money";
import { Reference } from "@/components/ledger/reference";
import { Rule } from "@/components/ledger/rule";
import { Tooltip } from "@/components/ledger/tooltip";
export interface JournalEntryProps extends React.ComponentPropsWithoutRef<"article"> {
actions?: React.ReactNode;
currency: string;
entry: JournalEntryRecord;
totalCredit: MoneyValue;
/** Totals passed in, already computed. */
totalDebit: MoneyValue;
}
const SOURCE_LABEL: Record<string, string> = {
adjusting: "Adjusting",
elimination: "Elimination",
import: "Import",
manual: "Manual",
recurring: "Recurring",
schedule: "Schedule",
subledger: "Subledger",
translation: "Translation",
};
/**
* Debit and credit as two columns. There is no single-signed-column variant.
*
* A posted entry is never edited or deleted — it is reversed. The delete
* action is **absent**, not disabled.
*
* Server-safe: a 400-line entry renders on the server.
*/
export const JournalEntry = React.forwardRef<HTMLElement, JournalEntryProps>(
({ className, entry, currency, totalDebit, totalCredit, actions, ...props }, ref) => {
const balanced = Number(totalDebit.amount) === Number(totalCredit.amount);
const difference = Number(totalDebit.amount) - Number(totalCredit.amount);
return (
<article className={cn("rounded-xl border border-border bg-surface", className)} ref={ref} {...props}>
<header className="flex flex-wrap items-start justify-between gap-4 border-b border-border-subtle px-6 py-4">
<div>
<p className="flex flex-wrap items-center gap-2 text-caption text-text-secondary">
<Reference truncate={false} value={entry.entryNumber} />
<span aria-hidden="true">·</span>
<span className="numeric">{formatDate(entry.date)}</span>
<span aria-hidden="true">·</span>
{SOURCE_LABEL[entry.source] ?? entry.source}
<span aria-hidden="true">·</span>
<span className="capitalize">{entry.status}</span>
</p>
<p className="mt-1 text-body font-medium">{entry.description}</p>
</div>
{/* No delete. Absent, not disabled. */}
<div className="flex gap-2">{actions}</div>
</header>
{/* Never sortable: the lines of one entry read debits first, then
credits, and that order is the double-entry itself rather than a
default the reader is free to change. */}
<table className="w-full" data-sortable="false">
{/* A width *preference*, not a fixed layout. Auto layout still honours
each money column's content minimum, so a figure can never be
clipped; the percentages only stop the account column from being
squeezed to three wrapped lines while the credit column sits half
empty. `table-fixed` does the opposite and clips the money. */}
<colgroup>
<col className="w-[44%]" />
<col className="w-[20%]" />
<col className="w-[18%]" />
<col className="w-[18%]" />
</colgroup>
<caption className="sr-only">
Journal entry {entry.entryNumber}, {entry.lines.length} lines
</caption>
<thead>
<tr className="border-b border-border-subtle">
<th className="px-6 py-2 text-left text-label font-medium text-text-secondary" scope="col">
Account
</th>
<th className="px-3 py-2 text-left text-label font-medium text-text-secondary" scope="col">
Dimensions
</th>
<th className="px-3 py-2 text-right text-label font-medium text-text-secondary" scope="col">
Debit
</th>
<th className="px-6 py-2 text-right text-label font-medium text-text-secondary" scope="col">
Credit
</th>
</tr>
</thead>
<tbody>
{entry.lines.map((line) => (
<tr className="border-b border-border-subtle" key={line.id}>
<th className="px-6 py-2 text-left font-normal" scope="row">
<span className="reference-numeric text-caption text-text-tertiary">
{line.accountNumber}
</span>{" "}
<span className="text-body">{line.accountName}</span>
{line.memo ? (
// A line memo is auditor-relevant but rarely the thing being
// scanned. It keeps its own line and truncates; the full text
// is one hover away rather than three wrapped lines deep.
<Tooltip content={line.memo} display="block">
<span className="block truncate text-caption text-text-secondary">{line.memo}</span>
</Tooltip>
) : null}
</th>
<td className="px-3 py-2 text-left text-caption text-text-secondary">
{dimensionSummary(line.dimensions)}
</td>
<td className="px-3 py-2 text-right align-top">
{line.debit ? <Money accounting value={line.debit} /> : null}
</td>
<td className="px-6 py-2 text-right align-top">
{line.credit ? <Money accounting value={line.credit} /> : null}
</td>
</tr>
))}
</tbody>
<tfoot>
<tr>
<td colSpan={2} />
<td className="px-3">
<Rule variant="subtotal" />
</td>
<td className="px-6">
<Rule variant="subtotal" />
</td>
</tr>
<tr>
<th className="px-6 py-2 text-left text-label font-medium" colSpan={2} scope="row">
Totals
</th>
<td className="px-3 py-2 text-right">
<Money accounting size="md" value={totalDebit} />
</td>
<td className="px-6 py-2 text-right">
<Money accounting size="md" value={totalCredit} />
</td>
</tr>
<tr>
<td colSpan={2} />
<td className="px-3">
<Rule variant="total" />
</td>
<td className="px-6">
<Rule variant="total" />
</td>
</tr>
</tfoot>
</table>
<div className="space-y-2 px-6 py-4">
<p
className={cn(
"flex items-center gap-2 rounded-sm px-3 py-2 text-body",
balanced ? "bg-positive-subtle text-positive" : "bg-negative-subtle text-negative",
)}
role={balanced ? undefined : "alert"}
>
{balanced ? (
<CheckIcon aria-hidden="true" className="size-4 shrink-0" />
) : (
<ExclamationTriangleIcon aria-hidden="true" className="size-4 shrink-0" />
)}
{balanced ? (
"Balanced."
) : (
<>
Out of balance by <Money tone="inherit" value={{ amount: Math.abs(difference), currency }} />
<InfoTip
about="What an out-of-balance entry means"
label="This entry cannot post"
triggerClassName="text-negative"
>
Debits and credits must agree to the cent before an entry reaches the ledger. Correct a
line, or add the balancing one.
</InfoTip>
</>
)}
</p>
{/* One reversal line, not three stacked sentences. Each fact is short
enough to sit beside the others; the one piece of doctrine —
that reversal never removes the original — is the tooltip. */}
{entry.autoReverseOn || entry.reversalOf || entry.reversedBy ? (
<p className="flex flex-wrap items-center gap-x-2 gap-y-1 text-caption text-text-secondary">
<ArrowUturnLeftIcon aria-hidden="true" className="size-3.5 shrink-0" />
{entry.autoReverseOn ? (
<span>
Reverses <span className="numeric">{formatDate(entry.autoReverseOn)}</span>
</span>
) : null}
{entry.reversalOf ? (
<>
{entry.autoReverseOn ? <span aria-hidden="true">·</span> : null}
<span>Reverses {entry.reversalOf}</span>
</>
) : null}
{entry.reversedBy ? (
<>
{entry.autoReverseOn || entry.reversalOf ? <span aria-hidden="true">·</span> : null}
<span>Reversed by {entry.reversedBy}</span>
</>
) : null}
<InfoTip about="How reversal works" label="Reversal, not deletion">
A posted entry is never edited or removed. The original and its reversal both stay in the
ledger, and the audit trail shows both.
</InfoTip>
</p>
) : null}
{entry.evidence?.length ? (
<ul className="flex flex-wrap gap-2">
{entry.evidence.map((e) => (
<li
className="inline-flex items-center gap-1.5 rounded-sm border border-border px-2 py-1 text-caption"
key={e.name}
>
<PaperClipIcon aria-hidden="true" className="size-3 text-text-tertiary" />
{e.name}
<span className="reference-numeric text-text-tertiary">{e.hash.slice(0, 8)}</span>
</li>
))}
</ul>
) : null}
</div>
</article>
);
},
);
JournalEntry.displayName = "JournalEntry";
function dimensionSummary(dimensions: Record<string, string | undefined>): string {
return Object.entries(dimensions)
.filter(([, v]) => v)
.map(([, v]) => v)
.join(" · ");
}