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
+51 -30
View File
@@ -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 })
+2 -2
View File
@@ -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