feat(granskning): concept review queue (UI migration PR 6) (#1126)

* feat(granskning): concept scene 11 for the review queue

Reskins /pending to the concept: serif header with a Godkann alla primary,
Vantar/Godkanda/Avvisade seg with a count chip on the queue, source filter
as the far-right context picker, and op rows in the concept's row language
(source line, title, risk chip, inline Godkann pill + quiet Avvisa, hover
checkbox). Row click opens the detail as a right slide-over (convention 13)
with the operation preview, period-lock banner, and approve/reject in the
footer. The select-all header becomes the standard bulkbar that pops in on
first selection, carrying the type quick-picks as quiet links. All existing
functionality is preserved: bulk approve/reject with confirm dialogs,
rejection category + feedback, high-risk warnings, period-lock gating,
conversation deep-link filter, realtime refetch, auto-expired markers.

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

* fix(bookkeeping): concept seg styling for the Verifikat/Utkast toggle

The toggle used the old bordered container with a beige active tab: the
inverse of the concept seg used on every other page (muted container,
card-white active with hairline border). Founder feedback.

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

* feat(granskning): match concept scene 11 row language

Founder feedback against the rendered concept: the seg becomes
Vantar/Historik (Historik merges committed + rejected via two parallel
fetches, newest resolution first, distinguished by Godkand/Avvisad chips
with the rejection reason in the sub line); op rows get the concept's
actor circle with a thread line, uppercase source line, and the three
tinted action pills under the text (Godkann sage, Avvisa terracotta,
Visa detaljer neutral) instead of right-aligned hover actions. Risk chip
stays on the right edge; hover checkboxes and the bulkbar are unchanged.

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

* fix(granskning): curved actor thread + boxed, legible detail panel

Founder feedback vs the rendered concept: the thread under the actor icon
now elbows toward the action row (rounded border corner) instead of a
straight line, and the slide-over frames the operation in its own bordered
box with the warning/rejection notes as separate boxed lines. preview_data
arrays shaped like a kontering (account/debit/credit rows, e.g.
preview_lines) now render as a proper Konto/Beskrivning/Debet/Kredit table
instead of the generic '3 rader' dump, so the panel actually says what the
agent is about to post.

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

* fix(granskning): carry #842 failed_partial rendering through the rework

The failed_partial status (irreversible posting followed by a failed
step) landed on main while the review-queue rework was in flight; the
Historik rows and the detail slide-over now render its warning chip,
explanation, and posted_ids like main did.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-23 21:51:41 +02:00
committed by GitHub
parent 7a6f9dd167
commit 008b4710d3
4 changed files with 597 additions and 325 deletions
+550 -312
View File
@@ -2,29 +2,19 @@
import { useState, useEffect, useCallback, useMemo, Fragment } from 'react'
import { useTranslations } from 'next-intl'
import { PageHeader } from '@/components/ui/page-header'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { DataListEmpty, DataListLoading } from '@/components/ui/data-list'
import { ContextPicker } from '@/components/common/ContextPicker'
import { QUIET_LINK_CLASS, VTH_CLASS, VTD_CLASS } from '@/components/ui/dry-table'
import {
DataList,
DataListHeader,
DataListRow,
DataListPrimary,
DataListMeta,
DataListMetaSeparator,
DataListEmpty,
DataListLoading,
} from '@/components/ui/data-list'
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuLabel,
} from '@/components/ui/dropdown-menu'
SlideOver,
SlideOverContent,
SlideOverHeader,
SlideOverBody,
SlideOverFooter,
} from '@/components/ui/slide-over'
import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
import {
Dialog,
@@ -37,21 +27,23 @@ import {
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { useToast } from '@/components/ui/use-toast'
import { formatCurrency, formatDate } from '@/lib/utils'
import { cn, formatCurrency, formatDate } from '@/lib/utils'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { createClient } from '@/lib/supabase/client'
import {
ClipboardCheck,
Bot,
ChevronDown,
Check,
ChevronRight,
Info,
Loader2,
Lock,
MessageSquare,
AlertTriangle,
X,
} from 'lucide-react'
import type {
PendingOperation,
PendingOperationStatus,
PendingOperationRejectionCategory,
} from '@/types'
import { AttachDocumentPreview } from '@/components/bookkeeping/AttachDocumentPreview'
@@ -229,6 +221,16 @@ function getPeriodStatus(op: PendingOperation): PeriodStatusShape | null {
}
}
// Concept gact buttons (scene 11): tinted outline pills under the op text.
// The tint is sanctioned here by the concept spec: approve reads sage,
// reject terracotta, details neutral.
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 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'
const REJECTION_CATEGORY_LABELS: Record<PendingOperationRejectionCategory, string> = {
wrong_category: 'Fel kategori / konto',
wrong_amount: 'Fel belopp',
@@ -270,11 +272,6 @@ function originLabel(
* Strict on reason === 'expired' so commit-time auto-rejects (404/409, where
* reason is the error text) do NOT read as "expired".
*/
function isAutoExpired(op: PendingOperation): boolean {
const rd = op.result_data as { auto_rejected?: boolean; reason?: string } | null
return op.status === 'rejected' && rd?.auto_rejected === true && rd?.reason === 'expired'
}
/**
* failed_partial rows (issue #842): the executor posted an irreversible
* voucher/credit note and then failed a later step. The dispatcher persisted
@@ -288,6 +285,11 @@ function failedPartialPostedIds(op: PendingOperation): string | null {
return entries.map(([key, value]) => `${key}: ${value}`).join(', ')
}
function isAutoExpired(op: PendingOperation): boolean {
const rd = op.result_data as { auto_rejected?: boolean; reason?: string } | null
return op.status === 'rejected' && rd?.auto_rejected === true && rd?.reason === 'expired'
}
function formatRelativeTime(dateStr: string): string {
const now = new Date()
const date = new Date(dateStr)
@@ -573,20 +575,90 @@ function renderPrimitive(value: unknown): string {
return String(value)
}
// A preview_data value that is a kontering (array of account/debit/credit
// rows). Several staged op types carry one under keys like `preview_lines`
// without a dedicated preview component; rendering it as the actual
// verifikat rows is what makes the detail panel say what the agent will do.
interface PreviewKonteringLine {
account?: string
account_number?: string
description?: string
debit?: number
credit?: number
debit_amount?: number
credit_amount?: number
}
function isKonteringLines(value: unknown): value is PreviewKonteringLine[] {
return (
Array.isArray(value) &&
value.length > 0 &&
value.every(
(line) =>
line != null &&
typeof line === 'object' &&
('account' in line || 'account_number' in line) &&
('debit' in line || 'credit' in line || 'debit_amount' in line || 'credit_amount' in line),
)
)
}
function PreviewKonteringTable({ lines }: { lines: PreviewKonteringLine[] }) {
const amount = (n: number | undefined) =>
n && n > 0 ? n.toLocaleString('sv-SE', { minimumFractionDigits: 2 }) : ''
return (
<table className="w-full border-collapse text-[12.5px]" aria-label="Föreslagen kontering">
<thead>
<tr>
<th className={cn(VTH_CLASS, 'w-[70px]')}>Konto</th>
<th className={VTH_CLASS}>Beskrivning</th>
<th className={cn(VTH_CLASS, 'text-right')}>Debet</th>
<th className={cn(VTH_CLASS, 'text-right')}>Kredit</th>
</tr>
</thead>
<tbody>
{lines.map((line, i) => (
<tr key={i}>
<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, 'whitespace-nowrap text-right tabular-nums')}>
{amount(line.debit ?? line.debit_amount)}
</td>
<td className={cn(VTD_CLASS, 'whitespace-nowrap text-right tabular-nums')}>
{amount(line.credit ?? line.credit_amount)}
</td>
</tr>
))}
</tbody>
</table>
)
}
function GenericPreview({ data }: { data: Record<string, unknown> }) {
// Skip period_status here: it's surfaced in the dedicated banner, not the
// generic key-value dump (otherwise the approver sees the same fact twice).
const entries = Object.entries(data).filter(([k, v]) => v != null && v !== '' && k !== 'period_status')
const konteringEntries = entries.filter(([, v]) => isKonteringLines(v))
const rest = entries.filter(([, v]) => !isKonteringLines(v))
return (
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
{entries.map(([key, value]) => (
<Fragment key={key}>
<span className="text-muted-foreground">{key.replace(/_/g, ' ')}</span>
<span className={typeof value === 'number' ? 'font-mono tabular-nums' : ''}>
{renderPrimitive(value)}
</span>
</Fragment>
<div className="space-y-3">
{konteringEntries.map(([key, value]) => (
<PreviewKonteringTable key={key} lines={value as PreviewKonteringLine[]} />
))}
{rest.length > 0 && (
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
{rest.map(([key, value]) => (
<Fragment key={key}>
<span className="text-muted-foreground">{key.replace(/_/g, ' ')}</span>
<span className={typeof value === 'number' ? 'font-mono tabular-nums' : ''}>
{renderPrimitive(value)}
</span>
</Fragment>
))}
</div>
)}
</div>
)
}
@@ -655,22 +727,22 @@ const sourceFilterLabels = (
high_risk: t('tab_high_risk'),
})
type TabStatus = Extract<PendingOperationStatus, 'pending' | 'committed' | 'rejected'>
type StatusCounts = Record<TabStatus, number | null>
// Concept scene 11: two views. Historik merges committed + rejected,
// distinguished per row by a status chip.
type ViewTab = 'pending' | 'history'
export default function PendingOperationsPage() {
const t = useTranslations('pending')
const [operations, setOperations] = useState<PendingOperation[]>([])
const [isLoading, setIsLoading] = useState(true)
const [activeTab, setActiveTab] = useState<PendingOperationStatus>('pending')
const [activeTab, setActiveTab] = useState<ViewTab>('pending')
const [sourceFilter, setSourceFilter] = useState<SourceFilter>('all')
const [conversationFilter, setConversationFilter] = useState<string | null>(null)
const [counts, setCounts] = useState<StatusCounts>({
pending: null,
committed: null,
rejected: null,
})
const [expandedId, setExpandedId] = useState<string | null>(null)
const [pendingCount, setPendingCount] = useState<number | null>(null)
// Detail slide-over (convention 13): id rather than the row object, so the
// panel tracks realtime refetches and closes itself when the op leaves the
// current list (approved elsewhere, tab switch, filter change).
const [detailOpId, setDetailOpId] = useState<string | null>(null)
const [selectedOp, setSelectedOp] = useState<PendingOperation | null>(null)
const [showCommitDialog, setShowCommitDialog] = useState(false)
const [isCommitting, setIsCommitting] = useState(false)
@@ -698,14 +770,25 @@ export default function PendingOperationsPage() {
const fetchOperations = useCallback(async () => {
setIsLoading(true)
try {
const res = await fetch(`/api/pending-operations?status=${activeTab}`)
const json = await res.json()
setOperations(json.data ?? [])
setCounts((prev) => ({
...prev,
...(json.counts ?? {}),
[activeTab]: json.count ?? json.data?.length ?? 0,
}))
if (activeTab === 'pending') {
const res = await fetch('/api/pending-operations?status=pending')
const json = await res.json()
setOperations(json.data ?? [])
setPendingCount(json.count ?? json.data?.length ?? 0)
} else {
// The API is single-status per fetch: Historik merges godkända and
// avvisade, newest resolution first.
const [committed, rejected] = await Promise.all([
fetch('/api/pending-operations?status=committed').then((r) => r.json()),
fetch('/api/pending-operations?status=rejected').then((r) => r.json()),
])
const merged = ([...(committed.data ?? []), ...(rejected.data ?? [])] as PendingOperation[]).sort(
(a, b) => (b.resolved_at ?? b.created_at).localeCompare(a.resolved_at ?? a.created_at),
)
setOperations(merged)
const pc = committed.counts?.pending ?? rejected.counts?.pending
if (typeof pc === 'number') setPendingCount(pc)
}
} catch {
toast({ title: 'Kunde inte ladda operationer', variant: 'destructive' })
}
@@ -927,7 +1010,6 @@ export default function PendingOperationsPage() {
const bulkEligibleIds = useMemo(() => bulkEligible.map((op) => op.id), [bulkEligible])
const allSelected =
bulkEligibleIds.length > 0 && bulkEligibleIds.every((id) => selectedIds.has(id))
const someSelected = bulkEligibleIds.some((id) => selectedIds.has(id))
const pendingTotal = filteredOperations.filter((op) => op.status === 'pending').length
const excludedFromBulk = pendingTotal - bulkEligible.length
@@ -978,19 +1060,48 @@ export default function PendingOperationsPage() {
return Array.from(counts.entries()).map(([type, count]) => ({ type, count }))
}, [bulkEligible, selectedIds])
const tabLabel = (label: string, status: TabStatus) => {
const count = counts[status]
return count == null ? label : `${label} (${count})`
// Source/kicker line for a row and the detail panel: operation type,
// origin (when an agent staged it) and relative age.
const sourceLine = (op: PendingOperation) => {
const isAgent = op.actor_type && op.actor_type !== 'user'
return [
operationLabel(op.operation_type, t),
isAgent ? (originLabel(op, t) ?? op.actor_label ?? op.actor_type) : null,
formatRelativeTime(op.created_at),
]
.filter(Boolean)
.join(' · ')
}
const showFilterDot = sourceFilter !== 'all'
const detailOp = detailOpId
? filteredOperations.find((op) => op.id === detailOpId) ?? null
: null
const detailPeriod = detailOp ? getPeriodStatus(detailOp) : null
const detailPeriodLocked = detailPeriod != null && detailPeriod.status !== 'open'
const detailConversationId = detailOp?.agent_metadata?.conversation_id ?? null
const SEG_TABS: Array<{ tab: ViewTab; labelKey: string }> = [
{ tab: 'pending', labelKey: 'tab_pending' },
{ tab: 'history', labelKey: 'tab_history' },
]
return (
<div className="space-y-8">
<PageHeader
title={t('title')}
description={t('subtitle')}
/>
{/* Page header (concept scene 11): title + Godkänn alla */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<h1 className="font-display text-2xl leading-8 tracking-tight">{t('title')}</h1>
{activeTab === 'pending' && bulkEligible.length > 0 && (
<Button
disabled={isBulkCommitting || isRejecting}
onClick={() => {
setSelectedIds(new Set(bulkEligibleIds))
setShowBulkDialog(true)
}}
>
{t('approve_all', { count: bulkEligible.length })}
</Button>
)}
</div>
{conversationFilter && (
<div className="flex items-center justify-between rounded-md border bg-muted/30 px-3 py-2 text-sm">
@@ -1019,180 +1130,275 @@ export default function PendingOperationsPage() {
</div>
)}
<div className="flex flex-wrap items-center justify-between gap-3">
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as PendingOperationStatus)}>
<TabsList>
<TabsTrigger value="pending">{tabLabel(t('tab_pending'), 'pending')}</TabsTrigger>
<TabsTrigger value="committed">{tabLabel(t('tab_committed'), 'committed')}</TabsTrigger>
<TabsTrigger value="rejected">{tabLabel(t('tab_rejected'), 'rejected')}</TabsTrigger>
</TabsList>
</Tabs>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-9 gap-2">
<span className="text-xs uppercase tracking-wider text-muted-foreground">
Filter
</span>
<span>{sourceFilterLabels(t)[sourceFilter]}</span>
{showFilterDot && (
<span className="h-1.5 w-1.5 rounded-full bg-primary" aria-hidden />
{/* Toolbar (concept): status seg left, source picker (convention 8)
far right. The count chip rides only on Väntar: it is the queue. */}
<div className="flex flex-wrap items-center gap-2">
<div className="inline-flex shrink-0 gap-0.5 rounded-lg bg-muted/70 p-[3px]" role="tablist">
{SEG_TABS.map(({ tab, labelKey }) => (
<button
key={tab}
type="button"
role="tab"
aria-selected={activeTab === tab}
onClick={() => setActiveTab(tab)}
className={cn(
'inline-flex items-center gap-1.5 rounded-md px-3.5 py-[5px] text-[12.5px] transition-colors duration-150',
activeTab === tab
? 'border border-border bg-card font-medium text-foreground'
: 'text-muted-foreground hover:text-foreground',
)}
<ChevronDown className="h-3.5 w-3.5 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[12rem]">
<DropdownMenuLabel>Källa</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={sourceFilter}
onValueChange={(v) => setSourceFilter(v as SourceFilter)}
>
<DropdownMenuRadioItem value="all">{t('tab_all')}</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="agent">{t('tab_agent')}</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="high_risk">{t('tab_high_risk')}</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
{t(labelKey)}
{tab === 'pending' && (pendingCount ?? 0) > 0 && (
<span className="rounded-full bg-secondary px-1.5 text-[10px] font-medium tabular-nums">
{pendingCount}
</span>
)}
</button>
))}
</div>
<div className="ml-auto">
<ContextPicker
value={sourceFilter}
onChange={(id) => setSourceFilter(id as SourceFilter)}
triggerLabel={sourceFilterLabels(t)[sourceFilter]}
items={[
{ id: 'all', label: t('tab_all') },
{ id: 'agent', label: t('tab_agent') },
{ id: 'high_risk', label: t('tab_high_risk') },
]}
/>
</div>
</div>
<DataList className="stagger-enter">
{showBulkControls && bulkEligible.length > 0 && (
<DataListHeader>
<div className="flex items-center gap-2">
<Checkbox
id="select-all"
checked={allSelected ? true : someSelected ? 'indeterminate' : false}
onCheckedChange={() => toggleSelectAll()}
aria-label={t('select_all_aria')}
/>
<label htmlFor="select-all" className="text-sm cursor-pointer">
{selectedCount > 0
? t('selected_count', { count: selectedCount })
: excludedFromBulk > 0
? t('select_all_count_partial', {
eligible: bulkEligible.length,
total: pendingTotal,
excluded: excludedFromBulk,
})
: t('select_all_count', { count: bulkEligible.length })}
</label>
</div>
{/* Only worth showing when there's more than one type to pick from:
with a single type it just duplicates "Markera alla". */}
{typeCounts.length >= 2 && selectedCount === 0 && (
<div className="flex flex-wrap items-center gap-1">
<span className="text-xs text-muted-foreground">{t('quick_pick')}</span>
{typeCounts.map(([type, count]) => {
const label = operationLabel(type, t)
return (
<Button
key={type}
size="sm"
variant="outline"
className="h-7 px-2 text-xs"
onClick={() => selectAllOfType(type)}
>
{label} ({count})
</Button>
)
})}
</div>
)}
<div className="ml-auto flex items-center gap-2">
{selectedCount > 0 && (
<Button
size="sm"
variant="ghost"
className="h-8 px-3 text-xs"
onClick={() => setSelectedIds(new Set())}
<div>
{/* Bulkbar (concept): hidden until at least one operation is selected
via the hover checkboxes, then it pops in with the count, the batch
actions, and the selection shortcuts as quiet links. */}
{showBulkControls && selectedCount > 0 && (
<div className="flex flex-wrap items-center gap-x-5 gap-y-2 border-b border-border px-1 py-2.5 text-[12.5px] animate-fade-in">
<span className="whitespace-nowrap">
<strong className="font-semibold tabular-nums">{selectedCount}</strong>{' '}
{t('bulkbar_selected', { count: selectedCount })}
</span>
<Button
size="sm"
disabled={isBulkCommitting || isRejecting}
onClick={() => setShowBulkDialog(true)}
>
{t('approve_count', { count: selectedCount })}
</Button>
<Button
size="sm"
variant="outline"
disabled={isRejecting || isBulkCommitting}
onClick={() => openRejectDialog('bulk')}
>
{t('reject_count', { count: selectedCount })}
</Button>
{typeCounts.length >= 2 &&
typeCounts.map(([type, count]) => (
<button
key={type}
type="button"
className={QUIET_LINK_CLASS}
onClick={() => selectAllOfType(type)}
>
{t('deselect')}
</Button>
)}
<Button
size="sm"
variant="outline"
className="h-8 px-3 text-xs"
disabled={selectedCount === 0 || isRejecting || isBulkCommitting}
onClick={() => openRejectDialog('bulk')}
>
{selectedCount > 0
? t('reject_selected', { count: selectedCount })
: t('reject_selected_none')}
</Button>
<Button
size="sm"
className="h-8 px-3 text-xs"
disabled={selectedCount === 0 || isBulkCommitting || isRejecting}
onClick={() => setShowBulkDialog(true)}
>
{selectedCount > 0
? t('approve_selected', { count: selectedCount })
: t('approve_selected_none')}
</Button>
</div>
</DataListHeader>
{operationLabel(type, t)} ({count})
</button>
))}
{!allSelected && (
<button type="button" className={QUIET_LINK_CLASS} onClick={toggleSelectAll}>
{t('select_all_count', { count: bulkEligible.length })}
</button>
)}
{excludedFromBulk > 0 && (
<span className="text-muted-foreground">
{t('bulk_excluded_note', { count: excludedFromBulk })}
</span>
)}
<button
type="button"
className={QUIET_LINK_CLASS}
onClick={() => setSelectedIds(new Set())}
>
{t('deselect')}
</button>
</div>
)}
{isLoading ? (
<DataListLoading />
) : filteredOperations.length === 0 ? (
<DataListEmpty
icon={<ClipboardCheck className="h-6 w-6" />}
title={
activeTab === 'pending'
? t('empty_pending_title')
: activeTab === 'committed'
? t('empty_committed_title')
: t('empty_rejected_title')
}
description={
activeTab === 'pending'
? t('empty_pending_description')
: t('empty_finished_description')
}
/>
activeTab === 'pending' ? (
/* Concept empty state: the queue is the good news. */
<div className="flex flex-col items-center px-6 py-16 text-center">
<Check className="h-9 w-9 text-success" strokeWidth={2.5} aria-hidden />
<p className="mt-3 font-display text-xl">{t('empty_pending_title')}</p>
<p className="mt-1.5 max-w-[44ch] text-[13px] text-muted-foreground">
{t('empty_pending_description')}
</p>
</div>
) : (
<DataListEmpty
icon={<ClipboardCheck className="h-6 w-6" />}
title={t('empty_history_title')}
description={t('empty_finished_description')}
/>
)
) : (
filteredOperations.map((op) => {
const label = operationLabel(op.operation_type, t)
const isExpanded = expandedId === op.id
const period = getPeriodStatus(op)
const periodLocked = period != null && period.status !== 'open'
const canBulkSelect =
showBulkControls && op.status === 'pending' && op.risk_level !== 'high' && !periodLocked
const isSelected = selectedIds.has(op.id)
const isAgent = op.actor_type && op.actor_type !== 'user'
const conversationId = op.agent_metadata?.conversation_id ?? null
const warningSentence = singleActionWarning(op.operation_type)
const showHighRiskWarning =
op.risk_level === 'high' && warningSentence && op.status === 'pending'
<div className="stagger-enter">
{filteredOperations.map((op) => {
const period = getPeriodStatus(op)
const periodLocked = period != null && period.status !== 'open'
const canBulkSelect =
showBulkControls && op.status === 'pending' && op.risk_level !== 'high' && !periodLocked
const isSelected = selectedIds.has(op.id)
const isAgent = op.actor_type && op.actor_type !== 'user'
const warningSentence = singleActionWarning(op.operation_type)
const showHighRiskWarning =
op.risk_level === 'high' && warningSentence && op.status === 'pending'
return (
<DataListRow
key={op.id}
selected={isSelected}
expanded={isExpanded}
onClick={() => setExpandedId(isExpanded ? null : op.id)}
leading={
canBulkSelect ? (
<div onClick={(e) => e.stopPropagation()}>
if (op.status !== 'pending') {
// Historik row (concept k-row): status chip + text + sub line.
const resolvedAt = op.resolved_at ?? op.created_at
const sub = [
formatRelativeTime(resolvedAt),
operationLabel(op.operation_type, t),
originLabel(op, t),
]
.filter(Boolean)
.join(' · ')
return (
<div
key={op.id}
role="button"
tabIndex={0}
aria-label={op.title}
onClick={() => setDetailOpId(op.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setDetailOpId(op.id)
}
}}
className={cn(
'group flex cursor-pointer items-start gap-3 border-b border-border px-1 py-3 transition-colors duration-150',
detailOpId === op.id ? 'bg-secondary/25' : 'hover:bg-secondary/35',
)}
>
<Badge
variant={
isAutoExpired(op)
? 'secondary'
: op.status === 'committed'
? 'success'
: op.status === 'failed_partial'
? 'warning'
: 'destructive'
}
className="mt-0.5 shrink-0 font-normal"
>
{isAutoExpired(op)
? t('badge_auto_expired')
: op.status === 'committed'
? t('badge_approved')
: op.status === 'failed_partial'
? t('badge_failed_partial')
: t('badge_rejected')}
</Badge>
<div className="min-w-0 flex-1">
<div className="text-[13.5px] leading-snug">{op.title}</div>
<div className="mt-0.5 text-xs text-muted-foreground">
{sub}
{op.status === 'rejected' && op.rejection_reason
? ` · ${t('history_reason', { reason: op.rejection_reason })}`
: ''}
</div>
{op.status === 'failed_partial' && (
<p className="mt-1 text-xs text-muted-foreground">
{t('failed_partial_detail')}
{failedPartialPostedIds(op) && (
<span className="font-mono"> ({failedPartialPostedIds(op)})</span>
)}
</p>
)}
</div>
<ChevronRight
className={cn(
'mt-1 h-3.5 w-3.5 shrink-0 text-muted-foreground transition-all duration-200',
detailOpId === op.id ? 'opacity-100' : 'opacity-0 group-hover:opacity-100',
)}
/>
</div>
)
}
return (
<div
key={op.id}
role="button"
tabIndex={0}
aria-label={op.title}
onClick={() => setDetailOpId(op.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setDetailOpId(op.id)
}
}}
className={cn(
'group flex cursor-pointer items-start gap-3 border-b border-border px-1 py-4 transition-colors duration-150',
detailOpId === op.id ? 'bg-secondary/25' : 'hover:bg-secondary/35',
isSelected && 'bg-secondary/40',
)}
>
{/* Hover-revealed selection checkbox (concept .cb) */}
<span
className="w-[18px] shrink-0 pt-1.5"
onClick={(e) => e.stopPropagation()}
>
{canBulkSelect && (
<Checkbox
checked={isSelected}
onCheckedChange={() => toggleSelected(op.id)}
aria-label={t('select_operation_aria')}
className={cn(
'transition-opacity duration-150',
isSelected
? 'opacity-100'
: 'opacity-0 group-hover:opacity-100 focus-visible:opacity-100',
)}
/>
)}
</span>
{/* Actor column with the curved thread (concept op-thread):
drops from the icon and elbows toward the action row. */}
<span className="flex w-7 shrink-0 flex-col self-stretch" aria-hidden>
<span className="flex h-7 w-7 items-center justify-center rounded-full border border-border text-muted-foreground">
{isAgent ? <Bot className="h-3.5 w-3.5" /> : <ClipboardCheck className="h-3.5 w-3.5" />}
</span>
<span className="relative min-h-0 flex-1">
<span className="absolute bottom-[11px] left-1/2 top-1 w-3 rounded-bl-lg border-b border-l border-border" />
</span>
</span>
<div className="min-w-0 flex-1">
<div className="pt-0.5 text-[11px] uppercase tracking-[0.07em] text-muted-foreground">
{sourceLine(op)}
</div>
) : undefined
}
trailing={
op.status === 'pending' ? (
<>
<Button
size="sm"
className="h-8 px-3 text-xs"
disabled={periodLocked}
<div className="mt-1 text-[13.5px] leading-snug">{op.title}</div>
{showHighRiskWarning && (
<p className="mt-1 flex items-start gap-1 text-xs text-destructive">
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0" />
<span>{warningSentence}</span>
</p>
)}
{/* Action pills under the text (concept gact) */}
<div className="mt-2.5 flex flex-wrap items-center gap-2">
<button
type="button"
className={cn(GACT_CLASS, GACT_OK_CLASS)}
disabled={periodLocked || isCommitting || isBulkCommitting}
title={periodLocked ? 'Perioden är låst' : undefined}
onClick={(e) => {
e.stopPropagation()
@@ -1201,109 +1407,141 @@ export default function PendingOperationsPage() {
setShowCommitDialog(true)
}}
>
<Check className="h-3.5 w-3.5" />
{t('approve')}
</Button>
<Button
size="sm"
variant="ghost"
className="h-8 px-3 text-xs"
</button>
<button
type="button"
className={cn(GACT_CLASS, GACT_NO_CLASS)}
disabled={isRejecting}
onClick={(e) => {
e.stopPropagation()
openRejectDialog(op)
}}
>
<X className="h-3.5 w-3.5" />
{t('reject')}
</Button>
</>
) : undefined
}
expandedContent={
<>
{/* Period-lock banner sits ABOVE the preview so the reviewer
sees the blocker as soon as they expand the row. */}
{periodLocked && period && op.status === 'pending' && (
<div className="mb-3">
<PeriodLockBanner period={period} />
</div>
)}
<OperationPreview op={op} />
</>
}
>
<DataListPrimary>{op.title}</DataListPrimary>
<DataListMeta>
<span className="font-medium text-foreground/70">{label}</span>
{isAgent && (
<>
<DataListMetaSeparator />
<span className="inline-flex items-center gap-1">
<Bot className="h-3 w-3" />
{/* The origin line doubles as the deep-link into the
originating conversation: no separate strip needed. */}
{conversationId ? (
<a
href={`/pending?conversation=${conversationId}`}
className="hover:underline"
onClick={(e) => e.stopPropagation()}
>
{originLabel(op, t) ?? op.actor_label ?? op.actor_type}
</a>
) : (
originLabel(op, t) ?? op.actor_label ?? op.actor_type
)}
</span>
</>
)}
<DataListMetaSeparator />
<span>{formatRelativeTime(op.created_at)}</span>
{op.risk_level === 'high' && (
<Badge variant="destructive" className="ml-1 h-4 px-1.5 py-0 text-[10px]">
{t('badge_high_risk')}
</Badge>
)}
{isAutoExpired(op) && (
<Badge variant="secondary" className="ml-1 h-4 px-1.5 py-0 text-[10px]">
{t('badge_auto_expired')}
</Badge>
)}
{op.status === 'failed_partial' && (
<Badge variant="warning" className="ml-1 h-4 px-1.5 py-0 text-[10px]">
{t('badge_failed_partial')}
</Badge>
)}
</DataListMeta>
{showHighRiskWarning && (
<p className="mt-1 flex items-start gap-1 text-xs text-destructive">
<AlertTriangle className="h-3 w-3 mt-0.5 shrink-0" />
<span>{warningSentence}</span>
</p>
)}
{op.status === 'rejected' && op.rejection_category && (
<p className="mt-1 text-xs text-muted-foreground">
Avvisad: {REJECTION_CATEGORY_LABELS[op.rejection_category]}
{op.rejection_reason ? `, "${op.rejection_reason}"` : ''}
</p>
)}
{/* rejection_category is always NULL on auto-expired rows, so
this never collides with the manual-rejection line above. */}
{isAutoExpired(op) && (
<p className="mt-1 text-xs text-muted-foreground">
{t('auto_expired_detail')}
</p>
)}
{op.status === 'failed_partial' && (
<p className="mt-1 text-xs text-muted-foreground">
{t('failed_partial_detail')}
{failedPartialPostedIds(op) && (
<span className="font-mono"> ({failedPartialPostedIds(op)})</span>
)}
</p>
)}
</DataListRow>
)
})
</button>
<button
type="button"
className={cn(GACT_CLASS, GACT_NEUTRAL_CLASS)}
onClick={(e) => {
e.stopPropagation()
setDetailOpId(op.id)
}}
>
<Info className="h-3.5 w-3.5" />
{t('details_btn')}
</button>
</div>
</div>
{/* Risk chip (concept op-risk) */}
<Badge
variant={op.risk_level === 'high' ? 'destructive' : 'outline'}
className="mt-1 shrink-0 font-normal"
>
{op.risk_level === 'high'
? t('badge_high_risk')
: op.risk_level === 'medium'
? t('badge_medium_risk')
: t('badge_low_risk')}
</Badge>
</div>
)
})}
</div>
)}
</DataList>
</div>
{/* Detail slide-over (convention 13): the review surface. Derived from
the live list, so a realtime refetch that resolves the op closes it. */}
<SlideOver
open={detailOp != null}
onOpenChange={(open) => {
if (!open) setDetailOpId(null)
}}
>
<SlideOverContent aria-describedby={undefined}>
{detailOp && (
<>
<SlideOverHeader kicker={sourceLine(detailOp)} title={detailOp.title} />
<SlideOverBody className="space-y-4">
{detailPeriodLocked && detailPeriod && detailOp.status === 'pending' && (
<PeriodLockBanner period={detailPeriod} />
)}
{/* The operation itself in its own box (concept): what the
agent is about to do, clearly framed. */}
<div className="rounded-lg border border-border p-4">
<OperationPreview op={detailOp} />
</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">
{singleActionWarning(detailOp.operation_type)}
</p>
</div>
)}
{detailOp.status === 'rejected' && detailOp.rejection_category && (
<div className="rounded-lg border border-border bg-secondary/25 px-3 py-2">
<p className="text-xs leading-snug text-muted-foreground">
Avvisad: {REJECTION_CATEGORY_LABELS[detailOp.rejection_category]}
{detailOp.rejection_reason ? `, "${detailOp.rejection_reason}"` : ''}
</p>
</div>
)}
{isAutoExpired(detailOp) && (
<p className="text-xs text-muted-foreground">{t('auto_expired_detail')}</p>
)}
{detailOp.status === 'failed_partial' && (
<div className="rounded-lg border border-border bg-secondary/25 px-3 py-2">
<p className="text-xs leading-snug text-muted-foreground">
{t('failed_partial_detail')}
{failedPartialPostedIds(detailOp) && (
<span className="font-mono"> ({failedPartialPostedIds(detailOp)})</span>
)}
</p>
</div>
)}
</SlideOverBody>
<SlideOverFooter>
{detailConversationId && (
<button
type="button"
className={cn(QUIET_LINK_CLASS, 'mr-auto')}
onClick={() => {
setConversationFilter(detailConversationId)
setDetailOpId(null)
}}
>
{t('show_conversation')}
</button>
)}
{detailOp.status === 'pending' && (
<>
<Button
variant="outline"
onClick={() => openRejectDialog(detailOp)}
disabled={isRejecting}
>
{t('reject')}
</Button>
<Button
disabled={detailPeriodLocked || isCommitting}
title={detailPeriodLocked ? 'Perioden är låst' : undefined}
onClick={() => {
setSelectedOp(detailOp)
setShowCommitDialog(true)
}}
>
{t('approve')}
</Button>
</>
)}
</SlideOverFooter>
</>
)}
</SlideOverContent>
</SlideOver>
{/* Commit confirmation dialog */}
<ConfirmationDialog
+19 -5
View File
@@ -693,24 +693,38 @@ export default function JournalEntryList() {
<div className="flex flex-wrap items-center gap-2">
{/* Verifikat vs Utkast. Drafts live in their own view with a count badge so
they don't sink to the last page of the committed list. */}
<div className="inline-flex shrink-0 rounded-md border border-border p-0.5">
<div className="inline-flex shrink-0 gap-0.5 rounded-lg bg-muted/70 p-[3px]" role="tablist">
<button
type="button"
role="tab"
aria-selected={listMode === 'committed'}
onClick={() => switchMode('committed')}
className={`h-7 rounded px-3 text-xs font-medium transition-colors ${listMode === 'committed' ? 'bg-secondary text-foreground' : 'text-muted-foreground hover:text-foreground'}`}
className={cn(
'rounded-md px-3.5 py-[5px] text-[12.5px] transition-colors duration-150',
listMode === 'committed'
? 'border border-border bg-card font-medium text-foreground'
: 'text-muted-foreground hover:text-foreground',
)}
>
{t('mode_vouchers')}
</button>
<button
type="button"
role="tab"
aria-selected={listMode === 'drafts'}
onClick={() => switchMode('drafts')}
className={`inline-flex h-7 items-center gap-1.5 rounded px-3 text-xs font-medium transition-colors ${listMode === 'drafts' ? 'bg-secondary text-foreground' : 'text-muted-foreground hover:text-foreground'}`}
className={cn(
'inline-flex items-center gap-1.5 rounded-md px-3.5 py-[5px] text-[12.5px] transition-colors duration-150',
listMode === 'drafts'
? 'border border-border bg-card font-medium text-foreground'
: 'text-muted-foreground hover:text-foreground',
)}
>
{t('mode_drafts')}
{draftCount > 0 && (
<Badge variant="secondary" className="h-4 min-w-4 justify-center px-1 text-[10px] tabular-nums">
<span className="rounded-full bg-secondary px-1.5 text-[10px] font-medium tabular-nums">
{draftCount}
</Badge>
</span>
)}
</button>
</div>
+14 -4
View File
@@ -415,7 +415,7 @@
"pending": {
"title": "Review",
"subtitle": "Operations waiting for approval",
"tab_pending": "Pending",
"tab_pending": "Waiting",
"tab_committed": "Approved",
"tab_rejected": "Rejected",
"tab_all": "All",
@@ -445,10 +445,10 @@
"badge_high_risk": "High risk",
"badge_approved": "Approved",
"badge_rejected": "Rejected",
"empty_pending_title": "No pending operations",
"empty_pending_title": "All clear.",
"empty_committed_title": "No approved operations",
"empty_rejected_title": "No rejected operations",
"empty_pending_description": "When an operation needs approval it will appear here for review.",
"empty_pending_description": "Nothing is waiting for your review. Operations that need approval appear here.",
"empty_finished_description": "Operations you have approved or rejected appear here.",
"bulk_confirm_intro": "Confirming will perform the following:",
"bulk_confirm_footer": "Operations run in order. Failed ones are skipped and reported afterwards.",
@@ -515,7 +515,17 @@
"badge_auto_expired": "Expired automatically",
"auto_expired_detail": "Expired automatically after 30 days without action. Nothing was booked.",
"badge_failed_partial": "Partially completed",
"failed_partial_detail": "The action was interrupted after an irreversible posting had already been made. What was posted remains and may need manual correction."
"failed_partial_detail": "The action was interrupted after an irreversible posting had already been made. What was posted remains and may need manual correction.",
"approve_all": "Approve all ({count})",
"bulkbar_selected": "selected",
"bulk_excluded_note": "{count, plural, one {1 needs individual review} other {# need individual review}}",
"badge_medium_risk": "Medium risk",
"badge_low_risk": "Low risk",
"show_conversation": "Show the conversation",
"tab_history": "History",
"details_btn": "Show details",
"empty_history_title": "No history yet",
"history_reason": "reason: \"{reason}\""
},
"deadlines": {
"title": "Deadlines",
+14 -4
View File
@@ -415,7 +415,7 @@
"pending": {
"title": "Granskning",
"subtitle": "Operationer som väntar på godkännande",
"tab_pending": "Väntande",
"tab_pending": "Väntar",
"tab_committed": "Godkända",
"tab_rejected": "Avvisade",
"tab_all": "Alla",
@@ -445,10 +445,10 @@
"badge_high_risk": "Hög risk",
"badge_approved": "Godkänd",
"badge_rejected": "Avvisad",
"empty_pending_title": "Inga väntande operationer",
"empty_pending_title": "Allt klart.",
"empty_committed_title": "Inga godkända operationer",
"empty_rejected_title": "Inga avvisade operationer",
"empty_pending_description": "När en operation kräver godkännande visas den här för granskning.",
"empty_pending_description": "Inget väntar på din granskning. När en operation kräver godkännande visas den här.",
"empty_finished_description": "Operationer du har godkänt eller avvisat visas här.",
"bulk_confirm_intro": "Genom att bekräfta utförs följande:",
"bulk_confirm_footer": "Operationerna körs i ordning. Misslyckade hoppas över och rapporteras efteråt.",
@@ -515,7 +515,17 @@
"badge_auto_expired": "Utgick automatiskt",
"auto_expired_detail": "Utgick automatiskt efter 30 dagar utan åtgärd. Inget bokfördes.",
"badge_failed_partial": "Delvis genomförd",
"failed_partial_detail": "Åtgärden avbröts efter att en oåterkallelig bokföring redan hade skett. Det som bokfördes står kvar och kan behöva rättas manuellt."
"failed_partial_detail": "Åtgärden avbröts efter att en oåterkallelig bokföring redan hade skett. Det som bokfördes står kvar och kan behöva rättas manuellt.",
"approve_all": "Godkänn alla ({count})",
"bulkbar_selected": "{count, plural, one {markerad} other {markerade}}",
"bulk_excluded_note": "{count, plural, one {1 kräver enskild granskning} other {# kräver enskild granskning}}",
"badge_medium_risk": "Medel risk",
"badge_low_risk": "Låg risk",
"show_conversation": "Visa konversationen",
"tab_history": "Historik",
"details_btn": "Visa detaljer",
"empty_history_title": "Ingen historik ännu",
"history_reason": "orsak: \"{reason}\""
},
"deadlines": {
"title": "Deadlines",