Commit d2bca486465876d2d732f9cff7bb160ca4ab0c71
1 parent
3745de3a
feat(web): journal entries page shows double-entry debit/credit lines
JournalEntriesPage now renders expandable debit/credit lines for each entry. Click a row to toggle the line detail view showing account code, DR/CR amounts, and description. Types updated: JournalEntry now includes lines array with JournalEntryLine (lineNo, accountCode, debit, credit, description). This makes the GL growth visible in the demo — confirming an SO shows the AR entry with DR 1100 / CR 4100 balanced lines inline.
Showing
2 changed files
with
96 additions
and
25 deletions
web/src/pages/JournalEntriesPage.tsx
| ... | ... | @@ -4,13 +4,13 @@ import type { JournalEntry } from '@/types/api' |
| 4 | 4 | import { PageHeader } from '@/components/PageHeader' |
| 5 | 5 | import { Loading } from '@/components/Loading' |
| 6 | 6 | import { ErrorBox } from '@/components/ErrorBox' |
| 7 | -import { DataTable, type Column } from '@/components/DataTable' | |
| 8 | 7 | import { StatusBadge } from '@/components/StatusBadge' |
| 9 | 8 | |
| 10 | 9 | export function JournalEntriesPage() { |
| 11 | 10 | const [rows, setRows] = useState<JournalEntry[]>([]) |
| 12 | 11 | const [error, setError] = useState<Error | null>(null) |
| 13 | 12 | const [loading, setLoading] = useState(true) |
| 13 | + const [expanded, setExpanded] = useState<Set<string>>(new Set()) | |
| 14 | 14 | |
| 15 | 15 | useEffect(() => { |
| 16 | 16 | finance |
| ... | ... | @@ -22,38 +22,100 @@ export function JournalEntriesPage() { |
| 22 | 22 | .finally(() => setLoading(false)) |
| 23 | 23 | }, []) |
| 24 | 24 | |
| 25 | - const columns: Column<JournalEntry>[] = [ | |
| 26 | - { | |
| 27 | - header: 'Posted', | |
| 28 | - key: 'postedAt', | |
| 29 | - render: (r) => (r.postedAt ? new Date(r.postedAt).toLocaleString() : '—'), | |
| 30 | - }, | |
| 31 | - { header: 'Code', key: 'code', render: (r) => <span className="font-mono text-xs">{r.code}</span> }, | |
| 32 | - { header: 'Type', key: 'type' }, | |
| 33 | - { header: 'Status', key: 'status', render: (r) => <StatusBadge status={r.status} /> }, | |
| 34 | - { header: 'Order', key: 'orderCode', render: (r) => <span className="font-mono">{r.orderCode}</span> }, | |
| 35 | - { header: 'Partner', key: 'partnerCode', render: (r) => <span className="font-mono">{r.partnerCode}</span> }, | |
| 36 | - { | |
| 37 | - header: 'Amount', | |
| 38 | - key: 'amount', | |
| 39 | - render: (r) => ( | |
| 40 | - <span className="font-mono tabular-nums"> | |
| 41 | - {Number(r.amount).toLocaleString(undefined, { minimumFractionDigits: 2 })}{' '} | |
| 42 | - {r.currencyCode} | |
| 43 | - </span> | |
| 44 | - ), | |
| 45 | - }, | |
| 46 | - ] | |
| 25 | + const toggle = (id: string) => { | |
| 26 | + setExpanded((prev) => { | |
| 27 | + const next = new Set(prev) | |
| 28 | + if (next.has(id)) next.delete(id) | |
| 29 | + else next.add(id) | |
| 30 | + return next | |
| 31 | + }) | |
| 32 | + } | |
| 47 | 33 | |
| 48 | 34 | return ( |
| 49 | 35 | <div> |
| 50 | 36 | <PageHeader |
| 51 | 37 | title="Journal Entries" |
| 52 | - subtitle="AR / AP entries created reactively by pbc-finance from order lifecycle events." | |
| 38 | + subtitle="Double-entry GL entries posted by pbc-finance from order lifecycle events. Click a row to see debit/credit lines." | |
| 53 | 39 | /> |
| 54 | 40 | {loading && <Loading />} |
| 55 | 41 | {error && <ErrorBox error={error} />} |
| 56 | - {!loading && !error && <DataTable rows={rows} columns={columns} />} | |
| 42 | + {!loading && !error && rows.length === 0 && ( | |
| 43 | + <div className="card p-6 text-sm text-slate-400">No journal entries yet.</div> | |
| 44 | + )} | |
| 45 | + {!loading && !error && rows.length > 0 && ( | |
| 46 | + <div className="card overflow-x-auto"> | |
| 47 | + <table className="table-base"> | |
| 48 | + <thead className="bg-slate-50"> | |
| 49 | + <tr> | |
| 50 | + <th>Posted</th> | |
| 51 | + <th>Type</th> | |
| 52 | + <th>Status</th> | |
| 53 | + <th>Order</th> | |
| 54 | + <th>Partner</th> | |
| 55 | + <th>Amount</th> | |
| 56 | + <th>Lines</th> | |
| 57 | + </tr> | |
| 58 | + </thead> | |
| 59 | + <tbody className="divide-y divide-slate-100"> | |
| 60 | + {rows.map((je) => { | |
| 61 | + const isOpen = expanded.has(je.id) | |
| 62 | + return ( | |
| 63 | + <> | |
| 64 | + <tr | |
| 65 | + key={je.id} | |
| 66 | + className="hover:bg-slate-50 cursor-pointer" | |
| 67 | + onClick={() => toggle(je.id)} | |
| 68 | + > | |
| 69 | + <td>{je.postedAt ? new Date(je.postedAt).toLocaleString() : '—'}</td> | |
| 70 | + <td>{je.type}</td> | |
| 71 | + <td><StatusBadge status={je.status} /></td> | |
| 72 | + <td className="font-mono">{je.orderCode}</td> | |
| 73 | + <td className="font-mono">{je.partnerCode}</td> | |
| 74 | + <td className="font-mono tabular-nums"> | |
| 75 | + {Number(je.amount).toLocaleString(undefined, { minimumFractionDigits: 2 })}{' '} | |
| 76 | + {je.currencyCode} | |
| 77 | + </td> | |
| 78 | + <td className="text-slate-500">{je.lines?.length ?? 0}</td> | |
| 79 | + </tr> | |
| 80 | + {isOpen && je.lines?.length > 0 && ( | |
| 81 | + <tr key={`${je.id}-lines`}> | |
| 82 | + <td colSpan={7} className="bg-slate-50 px-6 py-3"> | |
| 83 | + <table className="min-w-full text-xs"> | |
| 84 | + <thead> | |
| 85 | + <tr className="text-slate-400"> | |
| 86 | + <th className="text-left px-2 py-1">#</th> | |
| 87 | + <th className="text-left px-2 py-1">Account</th> | |
| 88 | + <th className="text-right px-2 py-1">Debit</th> | |
| 89 | + <th className="text-right px-2 py-1">Credit</th> | |
| 90 | + <th className="text-left px-2 py-1">Description</th> | |
| 91 | + </tr> | |
| 92 | + </thead> | |
| 93 | + <tbody> | |
| 94 | + {je.lines.map((l) => ( | |
| 95 | + <tr key={l.lineNo}> | |
| 96 | + <td className="px-2 py-1">{l.lineNo}</td> | |
| 97 | + <td className="px-2 py-1 font-mono">{l.accountCode}</td> | |
| 98 | + <td className="px-2 py-1 font-mono tabular-nums text-right"> | |
| 99 | + {Number(l.debit) > 0 ? Number(l.debit).toLocaleString(undefined, { minimumFractionDigits: 2 }) : ''} | |
| 100 | + </td> | |
| 101 | + <td className="px-2 py-1 font-mono tabular-nums text-right"> | |
| 102 | + {Number(l.credit) > 0 ? Number(l.credit).toLocaleString(undefined, { minimumFractionDigits: 2 }) : ''} | |
| 103 | + </td> | |
| 104 | + <td className="px-2 py-1 text-slate-500">{l.description ?? ''}</td> | |
| 105 | + </tr> | |
| 106 | + ))} | |
| 107 | + </tbody> | |
| 108 | + </table> | |
| 109 | + </td> | |
| 110 | + </tr> | |
| 111 | + )} | |
| 112 | + </> | |
| 113 | + ) | |
| 114 | + })} | |
| 115 | + </tbody> | |
| 116 | + </table> | |
| 117 | + </div> | |
| 118 | + )} | |
| 57 | 119 | </div> |
| 58 | 120 | ) |
| 59 | 121 | } | ... | ... |
web/src/types/api.ts
| ... | ... | @@ -264,6 +264,14 @@ export interface Account { |
| 264 | 264 | export type JournalEntryType = 'AR' | 'AP' |
| 265 | 265 | export type JournalEntryStatus = 'POSTED' | 'SETTLED' | 'REVERSED' |
| 266 | 266 | |
| 267 | +export interface JournalEntryLine { | |
| 268 | + lineNo: number | |
| 269 | + accountCode: string | |
| 270 | + debit: string | number | |
| 271 | + credit: string | number | |
| 272 | + description: string | null | |
| 273 | +} | |
| 274 | + | |
| 267 | 275 | export interface JournalEntry { |
| 268 | 276 | id: string |
| 269 | 277 | code: string |
| ... | ... | @@ -274,4 +282,5 @@ export interface JournalEntry { |
| 274 | 282 | amount: string | number |
| 275 | 283 | currencyCode: string |
| 276 | 284 | postedAt: string |
| 285 | + lines: JournalEntryLine[] | |
| 277 | 286 | } | ... | ... |
-
mentioned in commit 1f6482be