feat(pending): queue order toggle, entry date + notes in review, account names everywhere (#1812)

* feat(pending): queue order toggle, entry date + notes in review, account names everywhere

Four review-queue gaps reported by a customer approving bokslut batches:

- Oldest-first toggle: /api/pending-operations accepts order=asc|desc
  (default desc); the queue header gets an Äldst först / Nyast först
  button, remembered per browser (localStorage pending.sortOrder).
- Fiscal year visible: categorize previews now carry the transaction date
  (preview_data.date) and render a Datum row, so two open years are
  distinguishable.
- The agent's `notes` (audit-trail context) is shown in the detail panel
  as Anteckning; before, it was stored in params and never rendered.
- Account names: VoucherLinesTable and PreviewKonteringTable fall back to
  the chart name from AccountNamesContext (6110 Kontorsmateriel · AMAZON
  PRIME instead of the bank text alone); useAccountNamesSource moves to a
  shared hook so the chat ApprovalCard provides the same names.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(agent): call useAccountNamesSource in ApprovalCard

The provider referenced accountNames without the hook call; the core build
(tsc) caught it. Local tsc had not, so this also re-runs the full check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-23 04:54:23 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent d35c401c0c
commit 2fd58c4125
10 changed files with 177 additions and 45 deletions
+13 -8
View File
@@ -11,7 +11,8 @@ import { CAPABILITY } from '@/lib/entitlements/keys'
import type { PendingOperationRejectionCategory } from '@/types'
import { cn } from '@/lib/utils'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
import { OperationPreview } from '@/components/pending-operations/OperationPreview'
import { OperationPreview, AccountNamesContext } from '@/components/pending-operations/OperationPreview'
import { useAccountNamesSource } from '@/components/pending-operations/use-account-names'
import { REJECTION_CATEGORY_LABELS } from '@/components/pending-operations/vocabulary'
import { operationTypeFromToolName } from '@/lib/pending-operations/tool-name'
@@ -97,6 +98,8 @@ export default function ApprovalCard({
// What's paid is feeding a rejection back so the agent generates a *new*
// proposal (an LLM call): that's suppressed when the company lacks `ai`.
const hasAi = useCapability(CAPABILITY.ai)
// Chart names for the preview lines (same source as /pending).
const accountNames = useAccountNamesSource()
const [state, setState] = useState<State>('pending')
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [confirmText, setConfirmText] = useState('')
@@ -353,13 +356,15 @@ export default function ApprovalCard({
{preview != null && typeof preview === 'object' && (
<div className="rounded-lg border border-border bg-muted/30 px-3 py-2">
<OperationPreview
op={{
operation_type: previewOperationType,
preview_data: preview as Record<string, unknown>,
params,
}}
/>
<AccountNamesContext.Provider value={accountNames}>
<OperationPreview
op={{
operation_type: previewOperationType,
preview_data: preview as Record<string, unknown>,
params,
}}
/>
</AccountNamesContext.Provider>
</div>
)}
@@ -54,6 +54,14 @@ function CategorizePreview({ data }: { data: Record<string, unknown> }) {
const txAmount = typeof data.amount === 'number' && Number.isFinite(data.amount) ? data.amount : null
return (
<div className="space-y-1 text-sm">
{/* Entry date first: with two open fiscal years the approver could
not tell which year a categorization belonged to. */}
{typeof data.date === 'string' && data.date && (
<div className="flex justify-between gap-4 text-xs mb-1">
<span className="text-muted-foreground">Datum</span>
<span className="font-mono tabular-nums">{data.date}</span>
</div>
)}
<p className="text-xs text-muted-foreground mb-1">Verifikat</p>
{txCurrency !== 'SEK' && txAmount !== null && (
<div className="flex justify-between gap-4 text-xs text-muted-foreground mb-1">
@@ -253,13 +261,22 @@ type VoucherLine = {
}
function VoucherLinesTable({ lines, currency }: { lines: VoucherLine[]; currency?: string }) {
const accountNames = useContext(AccountNamesContext)
return (
<div className="border-t pt-2 space-y-1">
{lines.map((line, i) => (
{lines.map((line, i) => {
// The account's own name first (staged account_name, else the chart
// name); the line text only when it adds something.
const name = line.account_name || accountNames[line.account_number] || ''
const text = line.line_description || ''
return (
<div key={i} className="grid grid-cols-[auto_1fr_auto_auto] gap-x-3 text-xs items-baseline">
<span className="font-mono text-muted-foreground">{line.account_number}</span>
<span className="truncate">
{line.account_name || line.line_description || '-'}
{name || text || '-'}
{name && text && text !== name ? (
<span className="text-muted-foreground"> · {text}</span>
) : null}
</span>
<span className="font-mono tabular-nums text-right w-24">
{line.debit_amount > 0 ? formatCurrency(line.debit_amount, currency || 'SEK') : ''}
@@ -268,7 +285,8 @@ function VoucherLinesTable({ lines, currency }: { lines: VoucherLine[]; currency
{line.credit_amount > 0 ? formatCurrency(line.credit_amount, currency || 'SEK') : ''}
</span>
</div>
))}
)
})}
</div>
)
}
@@ -466,6 +484,7 @@ function isKonteringLines(value: unknown): value is PreviewKonteringLine[] {
}
function PreviewKonteringTable({ lines }: { lines: PreviewKonteringLine[] }) {
const accountNames = useContext(AccountNamesContext)
const amount = (n: number | undefined) =>
n && n > 0 ? n.toLocaleString('sv-SE', { minimumFractionDigits: 2 }) : ''
return (
@@ -484,7 +503,9 @@ function PreviewKonteringTable({ lines }: { lines: PreviewKonteringLine[] }) {
<td className={cn(VTD_CLASS, 'whitespace-nowrap font-mono tabular-nums')}>
{line.account ?? line.account_number}
</td>
<td className={cn(VTD_CLASS, 'text-muted-foreground')}>{line.description ?? ''}</td>
<td className={cn(VTD_CLASS, 'text-muted-foreground')}>
{line.description || accountNames[String(line.account ?? line.account_number ?? '')] || ''}
</td>
<td className={cn(VTD_CLASS, 'whitespace-nowrap text-right tabular-nums')}>
{amount(line.debit ?? line.debit_amount)}
</td>
@@ -0,0 +1,39 @@
'use client'
import { useEffect, useState } from 'react'
/**
* Account number -> account name for the proposal previews, fetched once per
* mount for the active company. Provide the result through
* AccountNamesContext (OperationPreview) so every preview surface (the
* /pending queue, the chat ApprovalCard) shows "6110 Kontorsmateriel" and
* not just the number or the bank's raw text. Display-only: a failed fetch
* leaves the map empty and previews fall back to the number.
*/
export function useAccountNamesSource(): Record<string, string> {
const [names, setNames] = useState<Record<string, string>>({})
useEffect(() => {
let alive = true
void fetch('/api/bookkeeping/accounts')
.then((r) => r.json())
.then(({ data }) => {
if (!alive) return
setNames(
Object.fromEntries(
((data ?? []) as Array<{ account_number: string; account_name: string }>).map((a) => [
a.account_number,
a.account_name,
]),
),
)
})
.catch(() => {
// Display-only: the number still shows, so a failure is not worth
// surfacing as an error the user cannot act on.
})
return () => {
alive = false
}
}, [])
return names
}