From 2fd58c4125790fda9a2e79289143c98f5ef08261 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Sun, 23 Aug 2026 04:54:23 +0200 Subject: [PATCH] feat(pending): queue order toggle, entry date + notes in review, account names everywhere (#1812) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 --------- Co-authored-by: Claude Fable 5 --- app/(dashboard)/pending/page.tsx | 81 ++++++++++++------- .../__tests__/route.test.ts | 35 +++++++- app/api/pending-operations/route.ts | 4 +- components/agent/ApprovalCard.tsx | 21 +++-- .../pending-operations/OperationPreview.tsx | 29 ++++++- .../pending-operations/use-account-names.ts | 39 +++++++++ extensions/general/mcp-server/server.ts | 4 + lib/api/schemas.ts | 3 + messages/en.json | 3 + messages/sv.json | 3 + 10 files changed, 177 insertions(+), 45 deletions(-) create mode 100644 components/pending-operations/use-account-names.ts diff --git a/app/(dashboard)/pending/page.tsx b/app/(dashboard)/pending/page.tsx index 141b6c88..148543ca 100644 --- a/app/(dashboard)/pending/page.tsx +++ b/app/(dashboard)/pending/page.tsx @@ -46,12 +46,14 @@ import { MessageSquare, AlertTriangle, X, + ArrowDownUp, } from 'lucide-react' import type { PendingOperation, PendingOperationRejectionCategory, } from '@/types' import { OperationPreview, AccountNamesContext } from '@/components/pending-operations/OperationPreview' +import { useAccountNamesSource } from '@/components/pending-operations/use-account-names' import { operationLabel, singleActionWarning, @@ -109,6 +111,7 @@ function getPeriodStatus(op: PendingOperation): PeriodStatusShape | null { const GACT_CLASS = 'inline-flex items-center gap-1.5 rounded-full border px-3.5 py-[5px] text-xs transition-colors duration-150 disabled:pointer-events-none disabled:opacity-50' const GACT_OK_CLASS = 'border-success/40 text-success hover:bg-success/10' +const SORT_ORDER_STORAGE_KEY = 'pending.sortOrder' const GACT_NO_CLASS = 'border-destructive/40 text-destructive hover:bg-destructive/10' const GACT_NEUTRAL_CLASS = 'border-border text-muted-foreground hover:bg-secondary/40 hover:text-foreground' @@ -191,34 +194,6 @@ function formatRelativeTime(dateStr: string): string { * names after a switch. A failed fetch leaves the map empty, which shows the * bare number rather than a wrong name, and retries on the next mount. */ -function useAccountNamesSource(): Record { - const [names, setNames] = useState>({}) - 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 -} - /** * Inline period-lock banner. Renders when the staged operation touches a @@ -270,6 +245,28 @@ export default function PendingOperationsPage() { const [isLoading, setIsLoading] = useState(true) const [activeTab, setActiveTab] = useState('pending') const [sourceFilter, setSourceFilter] = useState('all') + // Queue order. Newest first by default; a bokslut batch is approved oldest + // first, so the choice is remembered per browser. + const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc') + useEffect(() => { + try { + const stored = window.localStorage.getItem(SORT_ORDER_STORAGE_KEY) + if (stored === 'asc' || stored === 'desc') setSortOrder(stored) + } catch { + // Storage blocked: keep the default. + } + }, []) + const toggleSortOrder = () => { + setSortOrder((prev) => { + const next = prev === 'desc' ? 'asc' : 'desc' + try { + window.localStorage.setItem(SORT_ORDER_STORAGE_KEY, next) + } catch { + // Storage blocked: the toggle still applies for this session. + } + return next + }) + } const [conversationFilter, setConversationFilter] = useState(null) const [pendingCount, setPendingCount] = useState(null) // Detail slide-over (convention 13): id rather than the row object, so the @@ -354,7 +351,7 @@ export default function PendingOperationsPage() { else setIsRefreshing(true) try { if (activeTab === 'pending') { - const res = await fetch('/api/pending-operations?status=pending') + const res = await fetch(`/api/pending-operations?status=pending&order=${sortOrder}`) // A JSON error body parses fine but carries no data: without this // check a failed refresh would blank the list and zero the badge // instead of keeping current rows and surfacing the error toast. @@ -397,7 +394,7 @@ export default function PendingOperationsPage() { if (!isCurrent()) return setIsLoading(false) setIsRefreshing(false) - }, [activeTab, toast]) + }, [activeTab, sortOrder, toast]) useEffect(() => { fetchOperations() @@ -790,6 +787,19 @@ export default function PendingOperationsPage() { count: tab === 'pending' ? pendingCount ?? 0 : undefined, }))} /> + {activeTab === 'pending' && ( + + )} {/* Quiet cue that a background reconcile is running (post-action or realtime): the list itself never swaps to a spinner for it. */} {isRefreshing && !isLoading && ( @@ -1130,6 +1140,17 @@ export default function PendingOperationsPage() {
+ {/* The agent's audit-trail note for this operation (the + `notes` tool input): the approver should read why, not + only what. */} + {typeof detailOp.params?.notes === 'string' && detailOp.params.notes.trim() !== '' && ( +
+

+ {t('note_label')} +

+

{detailOp.params.notes}

+
+ )} {detailOp.status === 'pending' && singleActionWarning(detailOp.operation_type) && (

diff --git a/app/api/pending-operations/__tests__/route.test.ts b/app/api/pending-operations/__tests__/route.test.ts index 8c128251..284a89aa 100644 --- a/app/api/pending-operations/__tests__/route.test.ts +++ b/app/api/pending-operations/__tests__/route.test.ts @@ -6,7 +6,7 @@ import { parseJsonResponse, } from '@/tests/helpers' -const { supabase, enqueue, reset } = createQueuedMockSupabase() +const { supabase, enqueue, reset, findCalls } = createQueuedMockSupabase() const requireAuthMock = vi.fn() vi.mock('@/lib/auth/require-auth', () => ({ @@ -79,6 +79,39 @@ describe('GET /api/pending-operations', () => { expect(supabase.from).toHaveBeenCalledTimes(3) }) + it('orders the pending queue oldest-first when order=asc is given', async () => { + enqueue({ data: [{ id: 'operation-old', status: 'pending' }], count: 1 }) + enqueue({ count: 0 }) + enqueue({ count: 0 }) + + const response = await GET( + createMockRequest('/api/pending-operations', { searchParams: { status: 'pending', order: 'asc' } }), + { params: Promise.resolve({}) }, + ) + expect(response.status).toBe(200) + const orderCalls = findCalls('pending_operations', 'order') + expect(orderCalls[0]).toEqual(['created_at', { ascending: true, nullsFirst: false }]) + }) + + it('defaults to newest-first and rejects an unknown order with 400', async () => { + enqueue({ data: [], count: 0 }) + enqueue({ count: 0 }) + enqueue({ count: 0 }) + await GET(createMockRequest('/api/pending-operations'), { params: Promise.resolve({}) }) + expect(findCalls('pending_operations', 'order')[0]).toEqual([ + 'created_at', + { ascending: false, nullsFirst: false }, + ]) + + reset() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase }) + const bad = await GET( + createMockRequest('/api/pending-operations', { searchParams: { order: 'sideways' } }), + { params: Promise.resolve({}) }, + ) + expect(bad.status).toBe(400) + }) + it('returns 500 when the list query fails', async () => { enqueue({ error: { message: 'database unavailable' } }) enqueue({ count: 0 }) diff --git a/app/api/pending-operations/route.ts b/app/api/pending-operations/route.ts index 1da5d86d..5cb3b938 100644 --- a/app/api/pending-operations/route.ts +++ b/app/api/pending-operations/route.ts @@ -15,7 +15,7 @@ export const GET = withRouteContext( async (request, { supabase, companyId }) => { const result = validateQuery(request, PendingOperationsQuerySchema) if (!result.success) return result.response - const { status, limit, offset } = result.data + const { status, limit, offset, order } = result.data // Terminal tabs (Godkända/Avvisade) order by when the op was RESOLVED, not // created: auto-expired ops are ≥30 days old by construction, so a @@ -36,7 +36,7 @@ export const GET = withRouteContext( .select('*', { count: 'exact' }) .eq('company_id', companyId) .in('status', statusesFor(status)) - .order(orderColumn, { ascending: false, nullsFirst: false }) + .order(orderColumn, { ascending: order === 'asc', nullsFirst: false }) .range(offset, offset + limit - 1) // Return every tab count with the active list. The browser previously diff --git a/components/agent/ApprovalCard.tsx b/components/agent/ApprovalCard.tsx index 802231e6..e59e2552 100644 --- a/components/agent/ApprovalCard.tsx +++ b/components/agent/ApprovalCard.tsx @@ -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('pending') const [errorMessage, setErrorMessage] = useState(null) const [confirmText, setConfirmText] = useState('') @@ -353,13 +356,15 @@ export default function ApprovalCard({ {preview != null && typeof preview === 'object' && (

- , - params, - }} - /> + + , + params, + }} + /> +
)} diff --git a/components/pending-operations/OperationPreview.tsx b/components/pending-operations/OperationPreview.tsx index 77f0c378..20689dfd 100644 --- a/components/pending-operations/OperationPreview.tsx +++ b/components/pending-operations/OperationPreview.tsx @@ -54,6 +54,14 @@ function CategorizePreview({ data }: { data: Record }) { const txAmount = typeof data.amount === 'number' && Number.isFinite(data.amount) ? data.amount : null return (
+ {/* 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 && ( +
+ Datum + {data.date} +
+ )}

Verifikat

{txCurrency !== 'SEK' && txAmount !== null && (
@@ -253,13 +261,22 @@ type VoucherLine = { } function VoucherLinesTable({ lines, currency }: { lines: VoucherLine[]; currency?: string }) { + const accountNames = useContext(AccountNamesContext) return (
- {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 (
{line.account_number} - {line.account_name || line.line_description || '-'} + {name || text || '-'} + {name && text && text !== name ? ( + · {text} + ) : null} {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') : ''}
- ))} + ) + })}
) } @@ -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[] }) { {line.account ?? line.account_number} - {line.description ?? ''} + + {line.description || accountNames[String(line.account ?? line.account_number ?? '')] || ''} + {amount(line.debit ?? line.debit_amount)} diff --git a/components/pending-operations/use-account-names.ts b/components/pending-operations/use-account-names.ts new file mode 100644 index 00000000..71d8a3d7 --- /dev/null +++ b/components/pending-operations/use-account-names.ts @@ -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 { + const [names, setNames] = useState>({}) + 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 +} diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index 07fc182e..19f7e11a 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -4606,6 +4606,10 @@ export const tools: McpTool[] = [ ...(accountOverride ? { account_override: accountOverride } : {}), amount: result.amount, currency: result.currency, + // Entry date, so the review queue can show which fiscal year a + // categorization belongs to (two open years are indistinguishable + // from the title alone). + date: tx?.date ?? null, // Exact journal lines the approval will post (net cost line, VAT // line, gross bank line, SEK). The summary fields above pair the // GROSS amount with the cost account — read alone they misled diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index 5b58fe51..87e7c5a1 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -2491,6 +2491,9 @@ export const PendingOperationsQuerySchema = z.object({ status: z.enum(['pending', 'committed', 'rejected', 'failed_partial']).default('pending'), limit: z.coerce.number().int().min(1).max(100).default(50), offset: z.coerce.number().int().nonnegative().default(0), + // Newest first by default; a bokslut batch of fifty operations is worked + // oldest first, so the queue can be flipped. + order: z.enum(['asc', 'desc']).default('desc'), }) export const PendingOperationsBulkSchema = z.object({ diff --git a/messages/en.json b/messages/en.json index 763bfc5a..a555eeef 100644 --- a/messages/en.json +++ b/messages/en.json @@ -620,6 +620,9 @@ "pending": { "title": "Review", "subtitle": "Operations waiting for approval", + "sort_oldest_first": "Oldest first", + "sort_newest_first": "Newest first", + "note_label": "Note", "refreshing": "Refreshing…", "tab_pending": "Waiting", "tab_committed": "Approved", diff --git a/messages/sv.json b/messages/sv.json index e792c3f2..a400dd61 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -620,6 +620,9 @@ "pending": { "title": "Granskning", "subtitle": "Operationer som väntar på godkännande", + "sort_oldest_first": "Äldst först", + "sort_newest_first": "Nyast först", + "note_label": "Anteckning", "refreshing": "Uppdaterar…", "tab_pending": "Väntar", "tab_committed": "Godkända",