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:
+550
-312
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user