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:
co-authored by
Claude Fable 5
parent
d35c401c0c
commit
2fd58c4125
@@ -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<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
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 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<ViewTab>('pending')
|
||||
const [sourceFilter, setSourceFilter] = useState<SourceFilter>('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<string | null>(null)
|
||||
const [pendingCount, setPendingCount] = useState<number | null>(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' && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-8 gap-1.5 px-2 text-xs text-muted-foreground"
|
||||
onClick={toggleSortOrder}
|
||||
aria-pressed={sortOrder === 'asc'}
|
||||
title={sortOrder === 'asc' ? t('sort_oldest_first') : t('sort_newest_first')}
|
||||
>
|
||||
<ArrowDownUp className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{sortOrder === 'asc' ? t('sort_oldest_first') : t('sort_newest_first')}
|
||||
</Button>
|
||||
)}
|
||||
{/* 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() {
|
||||
<div className="rounded-lg border border-border p-4">
|
||||
<OperationPreview op={detailOp} />
|
||||
</div>
|
||||
{/* 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() !== '' && (
|
||||
<div className="rounded-lg border border-border bg-secondary/25 px-3 py-2">
|
||||
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">
|
||||
{t('note_label')}
|
||||
</p>
|
||||
<p className="mt-0.5 whitespace-pre-wrap text-xs leading-snug">{detailOp.params.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
{detailOp.status === 'pending' && singleActionWarning(detailOp.operation_type) && (
|
||||
<div className="rounded-lg border border-border bg-secondary/25 px-3 py-2">
|
||||
<p className="text-xs leading-snug text-muted-foreground">
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user