Commit d2bca486465876d2d732f9cff7bb160ca4ab0c71

Authored by zichun
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.
web/src/pages/JournalEntriesPage.tsx
@@ -4,13 +4,13 @@ import type { JournalEntry } from '@/types/api' @@ -4,13 +4,13 @@ import type { JournalEntry } from '@/types/api'
4 import { PageHeader } from '@/components/PageHeader' 4 import { PageHeader } from '@/components/PageHeader'
5 import { Loading } from '@/components/Loading' 5 import { Loading } from '@/components/Loading'
6 import { ErrorBox } from '@/components/ErrorBox' 6 import { ErrorBox } from '@/components/ErrorBox'
7 -import { DataTable, type Column } from '@/components/DataTable'  
8 import { StatusBadge } from '@/components/StatusBadge' 7 import { StatusBadge } from '@/components/StatusBadge'
9 8
10 export function JournalEntriesPage() { 9 export function JournalEntriesPage() {
11 const [rows, setRows] = useState<JournalEntry[]>([]) 10 const [rows, setRows] = useState<JournalEntry[]>([])
12 const [error, setError] = useState<Error | null>(null) 11 const [error, setError] = useState<Error | null>(null)
13 const [loading, setLoading] = useState(true) 12 const [loading, setLoading] = useState(true)
  13 + const [expanded, setExpanded] = useState<Set<string>>(new Set())
14 14
15 useEffect(() => { 15 useEffect(() => {
16 finance 16 finance
@@ -22,38 +22,100 @@ export function JournalEntriesPage() { @@ -22,38 +22,100 @@ export function JournalEntriesPage() {
22 .finally(() => setLoading(false)) 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 return ( 34 return (
49 <div> 35 <div>
50 <PageHeader 36 <PageHeader
51 title="Journal Entries" 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 {loading && <Loading />} 40 {loading && <Loading />}
55 {error && <ErrorBox error={error} />} 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 </div> 119 </div>
58 ) 120 )
59 } 121 }
web/src/types/api.ts
@@ -264,6 +264,14 @@ export interface Account { @@ -264,6 +264,14 @@ export interface Account {
264 export type JournalEntryType = 'AR' | 'AP' 264 export type JournalEntryType = 'AR' | 'AP'
265 export type JournalEntryStatus = 'POSTED' | 'SETTLED' | 'REVERSED' 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 export interface JournalEntry { 275 export interface JournalEntry {
268 id: string 276 id: string
269 code: string 277 code: string
@@ -274,4 +282,5 @@ export interface JournalEntry { @@ -274,4 +282,5 @@ export interface JournalEntry {
274 amount: string | number 282 amount: string | number
275 currencyCode: string 283 currencyCode: string
276 postedAt: string 284 postedAt: string
  285 + lines: JournalEntryLine[]
277 } 286 }