From 008b4710d361bb544372b2da4805ce0d664d7ccf Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Thu, 23 Jul 2026 21:51:41 +0200 Subject: [PATCH] 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 * 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 * 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 * 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 * 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 --------- Co-authored-by: Claude Fable 5 --- app/(dashboard)/pending/page.tsx | 862 +++++++++++++------- components/bookkeeping/JournalEntryList.tsx | 24 +- messages/en.json | 18 +- messages/sv.json | 18 +- 4 files changed, 597 insertions(+), 325 deletions(-) diff --git a/app/(dashboard)/pending/page.tsx b/app/(dashboard)/pending/page.tsx index 730d81c6..d4275c3e 100644 --- a/app/(dashboard)/pending/page.tsx +++ b/app/(dashboard)/pending/page.tsx @@ -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 = { 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 ( + + + + + + + + + + + {lines.map((line, i) => ( + + + + + + + ))} + +
KontoBeskrivningDebetKredit
+ {line.account ?? line.account_number} + {line.description ?? ''} + {amount(line.debit ?? line.debit_amount)} + + {amount(line.credit ?? line.credit_amount)} +
+ ) +} + function GenericPreview({ data }: { data: Record }) { // 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 ( -
- {entries.map(([key, value]) => ( - - {key.replace(/_/g, ' ')} - - {renderPrimitive(value)} - - +
+ {konteringEntries.map(([key, value]) => ( + ))} + {rest.length > 0 && ( +
+ {rest.map(([key, value]) => ( + + {key.replace(/_/g, ' ')} + + {renderPrimitive(value)} + + + ))} +
+ )}
) } @@ -655,22 +727,22 @@ const sourceFilterLabels = ( high_risk: t('tab_high_risk'), }) -type TabStatus = Extract -type StatusCounts = Record +// 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([]) const [isLoading, setIsLoading] = useState(true) - const [activeTab, setActiveTab] = useState('pending') + const [activeTab, setActiveTab] = useState('pending') const [sourceFilter, setSourceFilter] = useState('all') const [conversationFilter, setConversationFilter] = useState(null) - const [counts, setCounts] = useState({ - pending: null, - committed: null, - rejected: null, - }) - const [expandedId, setExpandedId] = useState(null) + const [pendingCount, setPendingCount] = useState(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(null) const [selectedOp, setSelectedOp] = useState(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 (
- + {/* Page header (concept scene 11): title + Godkänn alla */} +
+

{t('title')}

+ {activeTab === 'pending' && bulkEligible.length > 0 && ( + + )} +
{conversationFilter && (
@@ -1019,180 +1130,275 @@ export default function PendingOperationsPage() {
)} -
- setActiveTab(v as PendingOperationStatus)}> - - {tabLabel(t('tab_pending'), 'pending')} - {tabLabel(t('tab_committed'), 'committed')} - {tabLabel(t('tab_rejected'), 'rejected')} - - - - - - - - - Källa - setSourceFilter(v as SourceFilter)} > - {t('tab_all')} - {t('tab_agent')} - {t('tab_high_risk')} - - - + {t(labelKey)} + {tab === 'pending' && (pendingCount ?? 0) > 0 && ( + + {pendingCount} + + )} + + ))} +
+
+ 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') }, + ]} + /> +
- - {showBulkControls && bulkEligible.length > 0 && ( - -
- toggleSelectAll()} - aria-label={t('select_all_aria')} - /> - -
- - {/* 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 && ( -
- {t('quick_pick')} - {typeCounts.map(([type, count]) => { - const label = operationLabel(type, t) - return ( - - ) - })} -
- )} - -
- {selectedCount > 0 && ( - + + {typeCounts.length >= 2 && + typeCounts.map(([type, count]) => ( + - )} - - -
-
+ {operationLabel(type, t)} ({count}) + + ))} + {!allSelected && ( + + )} + {excludedFromBulk > 0 && ( + + {t('bulk_excluded_note', { count: excludedFromBulk })} + + )} + +
)} {isLoading ? ( ) : filteredOperations.length === 0 ? ( - } - 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. */ +
+ +

{t('empty_pending_title')}

+

+ {t('empty_pending_description')} +

+
+ ) : ( + } + 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' +
+ {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 ( - setExpandedId(isExpanded ? null : op.id)} - leading={ - canBulkSelect ? ( -
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 ( +
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', + )} + > + + {isAutoExpired(op) + ? t('badge_auto_expired') + : op.status === 'committed' + ? t('badge_approved') + : op.status === 'failed_partial' + ? t('badge_failed_partial') + : t('badge_rejected')} + +
+
{op.title}
+
+ {sub} + {op.status === 'rejected' && op.rejection_reason + ? ` · ${t('history_reason', { reason: op.rejection_reason })}` + : ''} +
+ {op.status === 'failed_partial' && ( +

+ {t('failed_partial_detail')} + {failedPartialPostedIds(op) && ( + ({failedPartialPostedIds(op)}) + )} +

+ )} +
+ +
+ ) + } + + return ( +
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) */} + e.stopPropagation()} + > + {canBulkSelect && ( 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', + )} /> + )} + + {/* Actor column with the curved thread (concept op-thread): + drops from the icon and elbows toward the action row. */} + + + {isAgent ? : } + + + + + +
+
+ {sourceLine(op)}
- ) : undefined - } - trailing={ - op.status === 'pending' ? ( - <> -
+ {showHighRiskWarning && ( +

+ + {warningSentence} +

+ )} + {/* Action pills under the text (concept gact) */} +
+ - - - ) : 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' && ( -
- -
- )} - - - } - > - {op.title} - - {label} - {isAgent && ( - <> - - - - {/* The origin line doubles as the deep-link into the - originating conversation: no separate strip needed. */} - {conversationId ? ( - e.stopPropagation()} - > - {originLabel(op, t) ?? op.actor_label ?? op.actor_type} - - ) : ( - originLabel(op, t) ?? op.actor_label ?? op.actor_type - )} - - - )} - - {formatRelativeTime(op.created_at)} - {op.risk_level === 'high' && ( - - {t('badge_high_risk')} - - )} - {isAutoExpired(op) && ( - - {t('badge_auto_expired')} - - )} - {op.status === 'failed_partial' && ( - - {t('badge_failed_partial')} - - )} - - {showHighRiskWarning && ( -

- - {warningSentence} -

- )} - {op.status === 'rejected' && op.rejection_category && ( -

- Avvisad: {REJECTION_CATEGORY_LABELS[op.rejection_category]} - {op.rejection_reason ? `, "${op.rejection_reason}"` : ''} -

- )} - {/* rejection_category is always NULL on auto-expired rows, so - this never collides with the manual-rejection line above. */} - {isAutoExpired(op) && ( -

- {t('auto_expired_detail')} -

- )} - {op.status === 'failed_partial' && ( -

- {t('failed_partial_detail')} - {failedPartialPostedIds(op) && ( - ({failedPartialPostedIds(op)}) - )} -

- )} - - ) - }) + + +
+
+ {/* Risk chip (concept op-risk) */} + + {op.risk_level === 'high' + ? t('badge_high_risk') + : op.risk_level === 'medium' + ? t('badge_medium_risk') + : t('badge_low_risk')} + +
+ ) + })} +
)} - + + + {/* Detail slide-over (convention 13): the review surface. Derived from + the live list, so a realtime refetch that resolves the op closes it. */} + { + if (!open) setDetailOpId(null) + }} + > + + {detailOp && ( + <> + + + {detailPeriodLocked && detailPeriod && detailOp.status === 'pending' && ( + + )} + {/* The operation itself in its own box (concept): what the + agent is about to do, clearly framed. */} +
+ +
+ {detailOp.status === 'pending' && singleActionWarning(detailOp.operation_type) && ( +
+

+ {singleActionWarning(detailOp.operation_type)} +

+
+ )} + {detailOp.status === 'rejected' && detailOp.rejection_category && ( +
+

+ Avvisad: {REJECTION_CATEGORY_LABELS[detailOp.rejection_category]} + {detailOp.rejection_reason ? `, "${detailOp.rejection_reason}"` : ''} +

+
+ )} + {isAutoExpired(detailOp) && ( +

{t('auto_expired_detail')}

+ )} + {detailOp.status === 'failed_partial' && ( +
+

+ {t('failed_partial_detail')} + {failedPartialPostedIds(detailOp) && ( + ({failedPartialPostedIds(detailOp)}) + )} +

+
+ )} +
+ + {detailConversationId && ( + + )} + {detailOp.status === 'pending' && ( + <> + + + + )} + + + )} +
+
{/* Commit confirmation dialog */} {/* 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. */} -
+
diff --git a/messages/en.json b/messages/en.json index c9b7720e..c4893d06 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/sv.json b/messages/sv.json index 96757ab3..935d6eed 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -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",