diff --git a/app/(dashboard)/page.tsx b/app/(dashboard)/page.tsx index c17dd844..8407ac82 100644 --- a/app/(dashboard)/page.tsx +++ b/app/(dashboard)/page.tsx @@ -6,7 +6,8 @@ import WelcomeGate from '@/components/onboarding/WelcomeGate' import { getActiveCompanyId } from '@/lib/company/context' import { getDisplayTotal } from '@/lib/invoices/rounding' import { ensureSandboxAgentProfile } from '@/lib/sandbox/ensure-agent' -import type { Deadline, ReceiptQueueSummary, OnboardingProgress } from '@/types' +import { getWorklistCounts, listSuggestedMatches } from '@/lib/worklist' +import type { Deadline, OnboardingProgress } from '@/types' export const dynamic = 'force-dynamic' @@ -51,16 +52,6 @@ export default async function DashboardPage() { const today = now.toISOString().split('T')[0] const nextWeek = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0] - // Source types that require supporting documents - const needsDocSourceTypes = [ - 'manual', - 'bank_transaction', - 'supplier_invoice_registered', - 'supplier_invoice_paid', - 'supplier_invoice_cash_payment', - 'import', - ] - // Fetch all data in parallel const [ { data: settings }, @@ -69,22 +60,16 @@ export default async function DashboardPage() { { count: receiptCount }, { count: transactionCount }, { data: journalLines }, - { data: transactions }, { data: unpaidInvoices }, { data: bankConnections }, { data: deadlines }, - { count: pendingReviewCount }, - { count: unmatchedReceiptsCount }, - { count: unmatchedTransactionsCount }, - { count: postedEntriesCount }, - { data: entriesWithDocs }, - { data: recentReceiptActivity }, { count: sieImportCount }, { count: staleUncategorizedCount }, - { count: uncategorizedCount }, { count: skatteverketTokenCount }, { data: agentProfile }, - { data: noDocRequiredEntries }, + { count: postedEntriesCount }, + worklist, + suggestedMatches, ] = await Promise.all([ supabase.from('company_settings').select('*').eq('company_id', companyId).single(), supabase.from('customers').select('*', { count: 'exact', head: true }).eq('company_id', companyId), @@ -96,26 +81,23 @@ export default async function DashboardPage() { .eq('journal_entry.status', 'posted') .eq('journal_entry.company_id', companyId) .gte('journal_entry.entry_date', startOfYearStr), - supabase.from('transactions').select('amount, amount_sek, is_business').eq('company_id', companyId).gte('date', startOfYearStr), supabase.from('invoices').select('total, total_sek, vat_amount, vat_amount_sek, status').eq('company_id', companyId).in('status', ['sent', 'overdue']).is('credited_invoice_id', null), supabase.from('bank_connections').select('id, accounts_data, status, consent_expires, bank_name').eq('company_id', companyId).eq('status', 'active'), supabase.from('deadlines').select('*, customer:customers(id, name)').eq('company_id', companyId).eq('is_completed', false) .or(`due_date.lt.${today},due_date.lte.${nextWeek}`).order('due_date', { ascending: true }), - supabase.from('receipts').select('*', { count: 'exact', head: true }).eq('company_id', companyId).eq('status', 'extracted'), - supabase.from('receipts').select('*', { count: 'exact', head: true }).eq('company_id', companyId).eq('status', 'confirmed').is('matched_transaction_id', null), - supabase.from('transactions').select('*', { count: 'exact', head: true }).eq('company_id', companyId).lt('amount', 0).is('receipt_id', null), - supabase.from('journal_entries').select('*', { count: 'exact', head: true }).eq('company_id', companyId).eq('status', 'posted').in('source_type', needsDocSourceTypes), - supabase.from('document_attachments').select('journal_entry_id').eq('company_id', companyId).eq('is_current_version', true).not('journal_entry_id', 'is', null), - supabase.from('receipts').select('created_at').eq('company_id', companyId).eq('status', 'confirmed').order('created_at', { ascending: false }).limit(30), supabase.from('sie_imports').select('*', { count: 'exact', head: true }).eq('company_id', companyId).eq('status', 'completed'), supabase.from('transactions').select('*', { count: 'exact', head: true }).eq('company_id', companyId).is('journal_entry_id', null).eq('is_ignored', false).is('is_business', null).lt('date', new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]), - supabase.from('transactions').select('*', { count: 'exact', head: true }).eq('company_id', companyId).eq('is_ignored', false).is('is_business', null), // Skatteverket tokens are user-scoped (one BankID identity per user) but // carry the active company_id; either filter would work — we use user_id // because that's what the token-store reads/writes against. supabase.from('skatteverket_tokens').select('*', { count: 'exact', head: true }).eq('user_id', user.id), supabase.from('agent_profiles').select('verified_at').eq('company_id', companyId).maybeSingle(), - supabase.from('journal_entry_no_doc_required').select('journal_entry_id').eq('company_id', companyId), + // Any posted entry counts as "company has been used" for the hasData gate. + supabase.from('journal_entries').select('*', { count: 'exact', head: true }).eq('company_id', companyId).eq('status', 'posted'), + // Pending-work counts + suggested matches come from lib/worklist — the + // same source as the sidebar badges, so the numbers can never diverge. + getWorklistCounts(supabase, companyId), + listSuggestedMatches(supabase, companyId, 5), ]) // If onboarding is not complete, redirect to onboarding @@ -204,16 +186,6 @@ export default async function DashboardPage() { const ytdTotals = calculateTotals(journalLines, startOfYearStr) const mtdTotals = calculateTotals(journalLines, startOfMonthStr) - const uncategorizedTxns = (transactions || []).filter( - (t) => t.is_business === null - ) - const uncategorizedIncome = uncategorizedTxns - .filter((t) => t.amount > 0) - .reduce((sum, t) => sum + Number(t.amount_sek || t.amount), 0) - const uncategorizedExpenses = uncategorizedTxns - .filter((t) => t.amount < 0) - .reduce((sum, t) => sum + Math.abs(Number(t.amount_sek || t.amount)), 0) - // Mirror the per-invoice öresavrundning rule used on the invoice list/detail // pages: sum the displayed (rounded) SEK amount per invoice so the dashboard // total matches what the user sees on the invoice list when the setting is on. @@ -262,45 +234,6 @@ export default async function DashboardPage() { ), })) - const entriesWithDocsSet = new Set( - (entriesWithDocs || []).map((d) => d.journal_entry_id) - ) - - // Exempted entries that *also* have a doc are already excluded by entriesWithDocsSet, - // so subtracting only the exempt-without-doc set avoids double-counting. - let exemptedWithoutDoc = 0 - for (const row of (noDocRequiredEntries || []) as { journal_entry_id: string }[]) { - if (!entriesWithDocsSet.has(row.journal_entry_id)) exemptedWithoutDoc++ - } - - const missingUnderlagCount = Math.max( - 0, - (postedEntriesCount || 0) - entriesWithDocsSet.size - exemptedWithoutDoc - ) - - let streakCount = 0 - if (recentReceiptActivity && recentReceiptActivity.length > 0) { - const todayDate = new Date() - todayDate.setHours(0, 0, 0, 0) - - const activityDates = new Set( - recentReceiptActivity.map((r) => new Date(r.created_at).toISOString().split('T')[0]) - ) - - const checkDate = new Date(todayDate) - while (activityDates.has(checkDate.toISOString().split('T')[0])) { - streakCount++ - checkDate.setDate(checkDate.getDate() - 1) - } - } - - const receiptQueue: ReceiptQueueSummary = { - unmatched_receipts_count: unmatchedReceiptsCount || 0, - unmatched_transactions_count: unmatchedTransactionsCount || 0, - pending_review_count: pendingReviewCount || 0, - streak_count: streakCount, - } - return ( ) diff --git a/components/dashboard/AttGoraSection.tsx b/components/dashboard/AttGoraSection.tsx new file mode 100644 index 00000000..33e0d27b --- /dev/null +++ b/components/dashboard/AttGoraSection.tsx @@ -0,0 +1,385 @@ +'use client' + +import { useState } from 'react' +import Link from 'next/link' +import { useTranslations } from 'next-intl' +import { Card, CardContent } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { EmptyState } from '@/components/ui/empty-state' +import { useToast } from '@/components/ui/use-toast' +import { cn, formatCurrency, formatDate } from '@/lib/utils' +import { getErrorMessage } from '@/lib/errors/get-error-message' +import { + ArrowLeftRight, + ArrowRight, + CalendarClock, + CheckCircle2, + ChevronRight, + Eye, + FileWarning, + Inbox, + Landmark, + Loader2, + Receipt, + ShieldCheck, + Stamp, +} from 'lucide-react' +import type { SuggestedMatch, WorklistCounts } from '@/lib/worklist/types' + +/** + * AttGoraSection — the dashboard's unified worklist ("Att göra"). + * + * One flat ledger of everything actionable, grouped into three bands by + * session intent: Bokför (the daily loop), Granska & komplettera (close the + * gaps), Bevaka (time-driven). Every count comes from lib/worklist — the same + * source as the sidebar badges — so the numbers can never disagree. + * + * Suggested transaction↔invoice matches render inline with one-click confirm: + * the row posts to the existing match endpoints, fades out optimistically, + * and the counts refetch from /api/worklist/counts. + */ + +interface ExpiringBankConnection { + id: string + bank_name: string + days_left: number +} + +interface AttGoraSectionProps { + worklist: WorklistCounts + suggestedMatches: SuggestedMatch[] + expiringBankConnections?: ExpiringBankConnection[] + staleUncategorizedCount: number +} + +interface WorklistRowProps { + href: string + icon: React.ComponentType<{ className?: string }> + label: string + detail?: string + count: number + badge?: React.ReactNode +} + +function WorklistRow({ href, icon: Icon, label, detail, count, badge }: WorklistRowProps) { + return ( + + +
+

{label}

+ {detail &&

{detail}

} +
+ {badge} + {count} + + + ) +} + +function BandHeader({ children }: { children: React.ReactNode }) { + return ( +

+ {children} +

+ ) +} + +export default function AttGoraSection({ + worklist, + suggestedMatches, + expiringBankConnections = [], + staleUncategorizedCount, +}: AttGoraSectionProps) { + const t = useTranslations('dashboard') + const { toast } = useToast() + + const [counts, setCounts] = useState(worklist.counts) + const [total, setTotal] = useState(worklist.total) + const [matches, setMatches] = useState(suggestedMatches) + const [leavingIds, setLeavingIds] = useState>(new Set()) + const [confirmingId, setConfirmingId] = useState(null) + + async function refetchCounts() { + try { + const res = await fetch('/api/worklist/counts') + if (!res.ok) throw new Error(`worklist counts refetch failed: ${res.status}`) + const json = (await res.json().catch(() => ({}))) as { data?: WorklistCounts } + if (json.data) { + setCounts(json.data.counts) + setTotal(json.data.total) + } + } catch (err) { + // Stale counts self-correct on the next page load — never block the + // flow, but keep the failure observable (Sentry captures console.error) + // so a systematically broken counts endpoint doesn't hide behind + // silently frozen numbers. + console.error('[att-gora] worklist counts refetch failed', err) + } + } + + async function handleConfirmMatch(match: SuggestedMatch) { + setConfirmingId(match.transaction_id) + try { + const url = + match.kind === 'invoice' + ? `/api/transactions/${match.transaction_id}/match-invoice` + : `/api/transactions/${match.transaction_id}/match-supplier-invoice` + const body = + match.kind === 'invoice' + ? { invoice_id: match.candidate_id } + : { supplier_invoice_id: match.candidate_id } + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + const result = await res.json().catch(() => ({})) + if (!res.ok || result.error) { + toast({ + title: t('suggested_failed_toast'), + description: getErrorMessage(result, { context: 'transaction', statusCode: res.status }), + variant: 'destructive', + }) + return + } + toast({ title: t('suggested_confirmed_toast') }) + // Fade the row out, drop it, then re-sync every count from the source + // of truth (the match also booked a transaction, so several numbers move). + setLeavingIds((prev) => new Set(prev).add(match.transaction_id)) + setTimeout(() => { + setMatches((prev) => prev.filter((m) => m.transaction_id !== match.transaction_id)) + setLeavingIds((prev) => { + const next = new Set(prev) + next.delete(match.transaction_id) + return next + }) + }, 300) + void refetchCounts() + } catch { + toast({ title: t('suggested_failed_toast'), variant: 'destructive' }) + } finally { + setConfirmingId(null) + } + } + + const bokforRows = counts.book_transaction > 0 || counts.inbox_document > 0 || matches.length > 0 + const granskaRows = + counts.supplier_invoice_approval > 0 || + counts.verifikat_missing_document > 0 || + counts.pending_operations > 0 + const bevakaRows = + counts.overdue_invoice > 0 || + counts.deadline_action > 0 || + expiringBankConnections.length > 0 + const allClear = !bokforRows && !granskaRows && !bevakaRows + + // The header total must equal what the section actually shows: the worklist + // total plus expiring bank connections, which are dashboard-only (not a + // lib/worklist category). Every count that feeds this number has a row. + const displayTotal = total + expiringBankConnections.length + + return ( +
+
+

{t('att_gora_title')}

+

+ {allClear ? t('all_done') : t('att_gora_left', { count: displayTotal })} +

+
+ + + + {allClear ? ( + + ) : ( +
+ {bokforRows && ( +
+ {t('band_bokfor')} +
+ {counts.book_transaction > 0 && ( + 0 ? ( + + {t('row_book_transactions_stale', { count: staleUncategorizedCount })} + + ) : undefined + } + /> + )} + {matches.length > 0 && ( +
+

+ {t('suggested_title')} +

+
+ {matches.map((match) => { + const isLeaving = leavingIds.has(match.transaction_id) + const isConfirming = confirmingId === match.transaction_id + return ( +
+
+

+ {match.transaction_description} + + {' '} + · {formatCurrency( + Math.abs(match.transaction_amount), + match.transaction_currency, + )} + +

+

+ + {match.kind === 'invoice' + ? t('suggested_kind_invoice') + : t('suggested_kind_supplier_invoice')} + {match.candidate_number ? ` ${match.candidate_number}` : ''} + {match.counterparty_name ? ` · ${match.counterparty_name}` : ''} + {' · '} + {formatDate(match.transaction_date)} +

+
+ + + + +
+ ) + })} +
+
+ )} + {counts.inbox_document > 0 && ( + + )} +
+
+ )} + + {granskaRows && ( +
+ {t('band_granska')} +
+ {counts.supplier_invoice_approval > 0 && ( + + )} + {counts.verifikat_missing_document > 0 && ( + + )} + {counts.pending_operations > 0 && ( + + )} +
+
+ )} + + {bevakaRows && ( +
+ {t('band_bevaka')} +
+ {counts.overdue_invoice > 0 && ( + + )} + {counts.deadline_action > 0 && ( + + )} + {expiringBankConnections.length > 0 && ( + + )} +
+
+ )} +
+ )} +
+
+
+ ) +} diff --git a/components/dashboard/DashboardContent.tsx b/components/dashboard/DashboardContent.tsx index 2807a966..786988b3 100644 --- a/components/dashboard/DashboardContent.tsx +++ b/components/dashboard/DashboardContent.tsx @@ -9,19 +9,18 @@ import { cn, formatCurrency } from '@/lib/utils' import { UpcomingDeadlinesWidget } from '@/components/deadlines/UpcomingDeadlinesWidget' import { TaxTodoWidget } from '@/components/deadlines/TaxTodoWidget' import NewUserChecklist from '@/components/onboarding/NewUserChecklist' +import AttGoraSection from '@/components/dashboard/AttGoraSection' import { Receipt, ArrowLeftRight, - ChevronDown, ChevronRight, - Landmark, CheckCircle2, - FileWarning, Clock, ArrowRight, MessageCircle, } from 'lucide-react' -import type { Deadline, ReceiptQueueSummary, OnboardingProgress } from '@/types' +import type { Deadline, OnboardingProgress } from '@/types' +import type { SuggestedMatch, WorklistCounts } from '@/lib/worklist/types' import { getBranding } from '@/lib/branding/service' const setupFreshStartKey = (companyId: string) => `erp_setup_fresh_start:${companyId}` @@ -31,9 +30,6 @@ interface DashboardContentProps { summary: { ytd: { income: number; expenses: number; net: number } mtd: { income: number; expenses: number; net: number } - uncategorizedCount: number - uncategorizedIncome: number - uncategorizedExpenses: number unpaidInvoicesCount: number unpaidInvoicesTotal: number unpaidVatTotal: number @@ -41,10 +37,12 @@ interface DashboardContentProps { bankBalance: number | null expiringBankConnections?: { id: string; bank_name: string; days_left: number }[] deadlines: Deadline[] - receiptQueue: ReceiptQueueSummary | null - missingUnderlagCount: number staleUncategorizedCount: number } + /** Unified pending-work counts from lib/worklist — same source as the sidebar badges. */ + worklist: WorklistCounts + /** High-confidence transaction↔invoice matches for inline one-click confirm. */ + suggestedMatches: SuggestedMatch[] onboardingProgress?: OnboardingProgress /** * False until the company has a verified agent_profile. When false the hero @@ -55,8 +53,7 @@ interface DashboardContentProps { agentBuilt?: boolean } -export default function DashboardContent({ companyId, summary, onboardingProgress, agentBuilt = true }: DashboardContentProps) { - const [showAllAlerts, setShowAllAlerts] = useState(false) +export default function DashboardContent({ companyId, summary, worklist, suggestedMatches, onboardingProgress, agentBuilt = true }: DashboardContentProps) { const t = useTranslations('dashboard') // The setup gate exists to nudge brand-new users into a data-import step @@ -113,143 +110,10 @@ export default function DashboardContent({ companyId, summary, onboardingProgres }).format(amount) } - const alertItems: React.ReactNode[] = [] - - if (summary.overdueInvoicesCount > 0) { - alertItems.push( - - - -
- -
-

{t('overdue_invoices')}

-

- {t('overdue_invoices_count', { count: summary.overdueInvoicesCount })} -

-
-
-
-
- - ) - } - - if (summary.unpaidInvoicesCount > 0 && summary.overdueInvoicesCount < summary.unpaidInvoicesCount) { - alertItems.push( - - - -
- -
-

{t('unpaid_invoices')}

-

- {t('unpaid_invoices_detail', { - count: summary.unpaidInvoicesCount - summary.overdueInvoicesCount, - amount: formatCurrency(summary.unpaidInvoicesTotal), - })} -

-
-
-
-
- - ) - } - - if (summary.uncategorizedCount > 0) { - alertItems.push( - - - -
- -
-

{t('transactions')}

-

- {t('uncategorized_count', { count: summary.uncategorizedCount })} -

-
-
-
-
- - ) - } - - if (summary.missingUnderlagCount > 0) { - alertItems.push( - - - -
- -
-

{t('missing_underlag')}

-

- {t('missing_underlag_detail', { count: summary.missingUnderlagCount })} -

-
-
-
-
- - ) - } - - if (summary.staleUncategorizedCount > 0) { - alertItems.push( - - - -
- -
-

{t('stale_transactions')}

-

- {t('stale_transactions_detail', { count: summary.staleUncategorizedCount })} -

-
-
-
-
- - ) - } - - if (summary.expiringBankConnections && summary.expiringBankConnections.length > 0) { - const conn = summary.expiringBankConnections[0] - alertItems.push( - - - -
- -
-

{t('bank_consent_expiring')}

-

- {conn.days_left === 1 - ? t('bank_consent_detail_one', { bank: conn.bank_name, days: conn.days_left }) - : t('bank_consent_detail_other', { bank: conn.bank_name, days: conn.days_left })} -

-
-
-
-
- - ) - } - - const MAX_VISIBLE_ALERTS = 3 - const visibleAlerts = showAllAlerts ? alertItems : alertItems.slice(0, MAX_VISIBLE_ALERTS) - const hasMoreAlerts = alertItems.length > MAX_VISIBLE_ALERTS - - const passedDeadlinesCount = summary.deadlines.filter(d => !d.is_completed && new Date(d.due_date) <= new Date()).length - const pendingReceiptsCount = summary.receiptQueue - ? summary.receiptQueue.pending_review_count + summary.receiptQueue.unmatched_receipts_count - : 0 - const todoCount = summary.uncategorizedCount + summary.overdueInvoicesCount + pendingReceiptsCount + passedDeadlinesCount + // One number, one source: the worklist total plus expiring bank connections + // (dashboard-only, not a lib/worklist category). Must match AttGoraSection's + // header so the tile and the section never disagree. + const todoCount = worklist.total + (summary.expiringBankConnections?.length ?? 0) const slim = getBranding().navDensity === 'slim' @@ -277,11 +141,12 @@ export default function DashboardContent({ companyId, summary, onboardingProgres icon: Receipt, } } - if (summary.uncategorizedCount > 0) { + if (worklist.counts.book_transaction > 0) { + const n = worklist.counts.book_transaction return { href: '/transactions', title: 'Transaktioner att bokföra', - body: `${summary.uncategorizedCount} obokförd${summary.uncategorizedCount === 1 ? '' : 'a'} transaktion${summary.uncategorizedCount === 1 ? '' : 'er'}.`, + body: `${n} obokförd${n === 1 ? '' : 'a'} transaktion${n === 1 ? '' : 'er'}.`, cta: 'Bokför nu', tone: 'primary' as const, icon: ArrowLeftRight, @@ -452,6 +317,15 @@ export default function DashboardContent({ companyId, summary, onboardingProgres + {/* Att göra — the unified worklist. One section, every actionable item, + same counts as the sidebar badges (lib/worklist). */} + + {/* Result — revenue / expenses (always visible) */}
@@ -487,27 +361,6 @@ export default function DashboardContent({ companyId, summary, onboardingProgres
- {/* Att hantera — hidden in slim mode; the hero card already surfaces the top action */} - {!slim && alertItems.length > 0 && ( -
-

{t('alerts_title')}

-
- {visibleAlerts} -
- {hasMoreAlerts && ( - - )} -
- )} - {/* Upcoming deadlines */} {summary.deadlines && summary.deadlines.length > 0 && (
diff --git a/messages/en.json b/messages/en.json index 46aedaa7..37e88c09 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3704,7 +3704,30 @@ "anomalies_detail": "{count} to address", "bank_consent_expiring": "Bank consent expiring", "bank_consent_detail_one": "{bank} — {days} day left", - "bank_consent_detail_other": "{bank} — {days} days left" + "bank_consent_detail_other": "{bank} — {days} days left", + "att_gora_title": "To do", + "att_gora_left": "{count} left", + "att_gora_empty_title": "All caught up!", + "att_gora_empty_body": "No transactions to record and no documents to handle.", + "band_bokfor": "Record", + "band_granska": "Review & complete", + "band_bevaka": "Monitor", + "row_book_transactions": "Transactions to record", + "row_book_transactions_stale": "{count} older than 14 days", + "row_inbox_documents": "Documents to handle", + "row_inbox_documents_detail": "Match to a transaction or record directly", + "row_supplier_approval": "Supplier invoices to approve", + "row_missing_underlag": "Vouchers missing documents", + "row_pending_ops": "To review", + "row_overdue_invoices": "Overdue customer invoices", + "suggested_title": "Suggested matches", + "suggested_kind_invoice": "Invoice", + "suggested_kind_supplier_invoice": "Supplier invoice", + "suggested_confirm": "Confirm", + "suggested_view": "View transaction", + "suggested_confirmed_toast": "Match recorded", + "suggested_failed_toast": "Match failed", + "row_deadlines": "VAT and tax deadlines" }, "reports": { "title": "Reports", diff --git a/messages/sv.json b/messages/sv.json index 3ea9dd34..4a9a0f00 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -3704,7 +3704,30 @@ "anomalies_detail": "{count} att åtgärda", "bank_consent_expiring": "Banksamtycke löper ut", "bank_consent_detail_one": "{bank} — {days} dag kvar", - "bank_consent_detail_other": "{bank} — {days} dagar kvar" + "bank_consent_detail_other": "{bank} — {days} dagar kvar", + "att_gora_title": "Att göra", + "att_gora_left": "{count} kvar", + "att_gora_empty_title": "Allt klart!", + "att_gora_empty_body": "Inga transaktioner att bokföra och inga underlag att hantera.", + "band_bokfor": "Bokför", + "band_granska": "Granska & komplettera", + "band_bevaka": "Bevaka", + "row_book_transactions": "Bokföra transaktioner", + "row_book_transactions_stale": "{count} äldre än 14 dagar", + "row_inbox_documents": "Underlag att hantera", + "row_inbox_documents_detail": "Matcha mot transaktion eller bokför direkt", + "row_supplier_approval": "Leverantörsfakturor att attestera", + "row_missing_underlag": "Verifikat utan underlag", + "row_pending_ops": "Att granska", + "row_overdue_invoices": "Förfallna kundfakturor", + "suggested_title": "Föreslagna matchningar", + "suggested_kind_invoice": "Faktura", + "suggested_kind_supplier_invoice": "Leverantörsfaktura", + "suggested_confirm": "Bekräfta", + "suggested_view": "Visa transaktionen", + "suggested_confirmed_toast": "Matchning bokförd", + "suggested_failed_toast": "Matchningen misslyckades", + "row_deadlines": "Moms- och skattedeadlines" }, "reports": { "title": "Rapporter",