feat(dashboard): unified "Att göra" worklist section on Hem (#674)
* feat(dashboard): unified "Att göra" worklist section on Hem The pilot's core complaint: pending work was scattered across Transaktioner, Underlag and Ny verifikation with no single starting point. Hem now carries one flat Att göra ledger — three bands by session intent (Bokför / Granska & komplettera / Bevaka), every count read from lib/worklist (the same source as the sidebar badges, so the numbers can never disagree), and an "Allt klart!" empty state. Suggested transaction↔invoice matches render inline with one-click Bekräfta posting to the existing match endpoints; rows fade out optimistically and counts re-sync from /api/worklist/counts. Replaces the "Att hantera" alert-card grid — whose warning/destructive chrome borders violated the design system — with neutral hairline rows; urgency is now carried by Badge variants only. The "Att göra" KPI tile switches to the worklist total, and the home page drops eight inline pending-work queries (incl. the legacy receipts queue, superseded by the inbox category) in favour of getWorklistCounts(). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(dashboard): address PR #674 review — count/visibility consistency greptile found two real contradictions in the Att göra section: - Expiring bank connections rendered a Bevaka row without counting toward the header total — a user with only an expiring consent read "0 kvar" next to a visible action row. The section header and the KPI tile now both show worklist.total + expiring connections. - deadline_action counted toward the total but had no row, so deadline-only users saw "Allt klart!" under a non-zero tile. Bevaka gains a "Moms- och skattedeadlines" row linking to /deadlines. Invariant after this commit: every count that feeds a displayed total has a visible row, and the tile and section header always agree. Also per compliance review: a failed counts refetch after a confirmed match now logs via console.error (Sentry-observable) instead of being silently swallowed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
f59da07fc0
commit
076bb169f8
+13
-83
@@ -6,7 +6,8 @@ import WelcomeGate from '@/components/onboarding/WelcomeGate'
|
|||||||
import { getActiveCompanyId } from '@/lib/company/context'
|
import { getActiveCompanyId } from '@/lib/company/context'
|
||||||
import { getDisplayTotal } from '@/lib/invoices/rounding'
|
import { getDisplayTotal } from '@/lib/invoices/rounding'
|
||||||
import { ensureSandboxAgentProfile } from '@/lib/sandbox/ensure-agent'
|
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'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
@@ -51,16 +52,6 @@ export default async function DashboardPage() {
|
|||||||
const today = now.toISOString().split('T')[0]
|
const today = now.toISOString().split('T')[0]
|
||||||
const nextWeek = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000).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
|
// Fetch all data in parallel
|
||||||
const [
|
const [
|
||||||
{ data: settings },
|
{ data: settings },
|
||||||
@@ -69,22 +60,16 @@ export default async function DashboardPage() {
|
|||||||
{ count: receiptCount },
|
{ count: receiptCount },
|
||||||
{ count: transactionCount },
|
{ count: transactionCount },
|
||||||
{ data: journalLines },
|
{ data: journalLines },
|
||||||
{ data: transactions },
|
|
||||||
{ data: unpaidInvoices },
|
{ data: unpaidInvoices },
|
||||||
{ data: bankConnections },
|
{ data: bankConnections },
|
||||||
{ data: deadlines },
|
{ data: deadlines },
|
||||||
{ count: pendingReviewCount },
|
|
||||||
{ count: unmatchedReceiptsCount },
|
|
||||||
{ count: unmatchedTransactionsCount },
|
|
||||||
{ count: postedEntriesCount },
|
|
||||||
{ data: entriesWithDocs },
|
|
||||||
{ data: recentReceiptActivity },
|
|
||||||
{ count: sieImportCount },
|
{ count: sieImportCount },
|
||||||
{ count: staleUncategorizedCount },
|
{ count: staleUncategorizedCount },
|
||||||
{ count: uncategorizedCount },
|
|
||||||
{ count: skatteverketTokenCount },
|
{ count: skatteverketTokenCount },
|
||||||
{ data: agentProfile },
|
{ data: agentProfile },
|
||||||
{ data: noDocRequiredEntries },
|
{ count: postedEntriesCount },
|
||||||
|
worklist,
|
||||||
|
suggestedMatches,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
supabase.from('company_settings').select('*').eq('company_id', companyId).single(),
|
supabase.from('company_settings').select('*').eq('company_id', companyId).single(),
|
||||||
supabase.from('customers').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
|
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.status', 'posted')
|
||||||
.eq('journal_entry.company_id', companyId)
|
.eq('journal_entry.company_id', companyId)
|
||||||
.gte('journal_entry.entry_date', startOfYearStr),
|
.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('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('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)
|
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 }),
|
.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('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).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
|
// Skatteverket tokens are user-scoped (one BankID identity per user) but
|
||||||
// carry the active company_id; either filter would work — we use user_id
|
// carry the active company_id; either filter would work — we use user_id
|
||||||
// because that's what the token-store reads/writes against.
|
// 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('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('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
|
// If onboarding is not complete, redirect to onboarding
|
||||||
@@ -204,16 +186,6 @@ export default async function DashboardPage() {
|
|||||||
const ytdTotals = calculateTotals(journalLines, startOfYearStr)
|
const ytdTotals = calculateTotals(journalLines, startOfYearStr)
|
||||||
const mtdTotals = calculateTotals(journalLines, startOfMonthStr)
|
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
|
// Mirror the per-invoice öresavrundning rule used on the invoice list/detail
|
||||||
// pages: sum the displayed (rounded) SEK amount per invoice so the dashboard
|
// 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.
|
// 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 (
|
return (
|
||||||
<DashboardContent
|
<DashboardContent
|
||||||
companyId={companyId}
|
companyId={companyId}
|
||||||
@@ -308,9 +241,6 @@ export default async function DashboardPage() {
|
|||||||
summary={{
|
summary={{
|
||||||
ytd: ytdTotals,
|
ytd: ytdTotals,
|
||||||
mtd: mtdTotals,
|
mtd: mtdTotals,
|
||||||
uncategorizedCount: uncategorizedCount || 0,
|
|
||||||
uncategorizedIncome,
|
|
||||||
uncategorizedExpenses,
|
|
||||||
unpaidInvoicesCount: (unpaidInvoices || []).length,
|
unpaidInvoicesCount: (unpaidInvoices || []).length,
|
||||||
unpaidInvoicesTotal: unpaidTotal,
|
unpaidInvoicesTotal: unpaidTotal,
|
||||||
unpaidVatTotal,
|
unpaidVatTotal,
|
||||||
@@ -318,10 +248,10 @@ export default async function DashboardPage() {
|
|||||||
bankBalance,
|
bankBalance,
|
||||||
expiringBankConnections,
|
expiringBankConnections,
|
||||||
deadlines: (deadlines || []) as Deadline[],
|
deadlines: (deadlines || []) as Deadline[],
|
||||||
receiptQueue,
|
|
||||||
missingUnderlagCount,
|
|
||||||
staleUncategorizedCount: staleUncategorizedCount || 0,
|
staleUncategorizedCount: staleUncategorizedCount || 0,
|
||||||
}}
|
}}
|
||||||
|
worklist={worklist}
|
||||||
|
suggestedMatches={suggestedMatches}
|
||||||
onboardingProgress={onboardingProgress}
|
onboardingProgress={onboardingProgress}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className="flex items-center gap-3 px-4 py-3 hover:bg-secondary/60 transition-colors duration-150"
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm truncate">{label}</p>
|
||||||
|
{detail && <p className="text-xs text-muted-foreground mt-0.5 truncate">{detail}</p>}
|
||||||
|
</div>
|
||||||
|
{badge}
|
||||||
|
<span className="font-display text-base tabular-nums shrink-0">{count}</span>
|
||||||
|
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground/50 shrink-0" />
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BandHeader({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<p className="px-4 pt-4 pb-1 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Set<string>>(new Set())
|
||||||
|
const [confirmingId, setConfirmingId] = useState<string | null>(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 (
|
||||||
|
<section aria-label={t('att_gora_title')}>
|
||||||
|
<div className="flex items-baseline justify-between mb-4">
|
||||||
|
<h2 className="font-display text-lg">{t('att_gora_title')}</h2>
|
||||||
|
<p className="text-sm text-muted-foreground tabular-nums" role="status" aria-live="polite">
|
||||||
|
{allClear ? t('all_done') : t('att_gora_left', { count: displayTotal })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{allClear ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={CheckCircle2}
|
||||||
|
title={t('att_gora_empty_title')}
|
||||||
|
description={t('att_gora_empty_body')}
|
||||||
|
className="py-10"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="pb-2">
|
||||||
|
{bokforRows && (
|
||||||
|
<div>
|
||||||
|
<BandHeader>{t('band_bokfor')}</BandHeader>
|
||||||
|
<div className="divide-y divide-border/50">
|
||||||
|
{counts.book_transaction > 0 && (
|
||||||
|
<WorklistRow
|
||||||
|
href="/transactions"
|
||||||
|
icon={ArrowLeftRight}
|
||||||
|
label={t('row_book_transactions')}
|
||||||
|
count={counts.book_transaction}
|
||||||
|
badge={
|
||||||
|
staleUncategorizedCount > 0 ? (
|
||||||
|
<Badge variant="warning" className="shrink-0">
|
||||||
|
{t('row_book_transactions_stale', { count: staleUncategorizedCount })}
|
||||||
|
</Badge>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{matches.length > 0 && (
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<p className="text-xs text-muted-foreground mb-2">
|
||||||
|
{t('suggested_title')}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{matches.map((match) => {
|
||||||
|
const isLeaving = leavingIds.has(match.transaction_id)
|
||||||
|
const isConfirming = confirmingId === match.transaction_id
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={match.transaction_id}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 rounded bg-secondary/40 px-3 py-2 transition-opacity duration-300',
|
||||||
|
isLeaving && 'opacity-0',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm truncate">
|
||||||
|
{match.transaction_description}
|
||||||
|
<span className="text-muted-foreground tabular-nums">
|
||||||
|
{' '}
|
||||||
|
· {formatCurrency(
|
||||||
|
Math.abs(match.transaction_amount),
|
||||||
|
match.transaction_currency,
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5 truncate tabular-nums">
|
||||||
|
<ArrowRight className="inline h-3 w-3 mr-1" aria-hidden />
|
||||||
|
{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)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href={`/transactions?highlight=${match.transaction_id}`}
|
||||||
|
aria-label={t('suggested_view')}
|
||||||
|
title={t('suggested_view')}
|
||||||
|
className="shrink-0 h-10 w-10 inline-flex items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-secondary/60 transition-colors"
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="shrink-0"
|
||||||
|
disabled={!!confirmingId || isLeaving}
|
||||||
|
onClick={() => void handleConfirmMatch(match)}
|
||||||
|
>
|
||||||
|
{isConfirming ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
|
||||||
|
{t('suggested_confirm')}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
t('suggested_confirm')
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{counts.inbox_document > 0 && (
|
||||||
|
<WorklistRow
|
||||||
|
href="/e/general/invoice-inbox"
|
||||||
|
icon={Inbox}
|
||||||
|
label={t('row_inbox_documents')}
|
||||||
|
detail={t('row_inbox_documents_detail')}
|
||||||
|
count={counts.inbox_document}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{granskaRows && (
|
||||||
|
<div>
|
||||||
|
<BandHeader>{t('band_granska')}</BandHeader>
|
||||||
|
<div className="divide-y divide-border/50">
|
||||||
|
{counts.supplier_invoice_approval > 0 && (
|
||||||
|
<WorklistRow
|
||||||
|
href="/supplier-invoices"
|
||||||
|
icon={Stamp}
|
||||||
|
label={t('row_supplier_approval')}
|
||||||
|
count={counts.supplier_invoice_approval}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{counts.verifikat_missing_document > 0 && (
|
||||||
|
<WorklistRow
|
||||||
|
href="/bookkeeping?missingUnderlag=true"
|
||||||
|
icon={FileWarning}
|
||||||
|
label={t('row_missing_underlag')}
|
||||||
|
count={counts.verifikat_missing_document}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{counts.pending_operations > 0 && (
|
||||||
|
<WorklistRow
|
||||||
|
href="/pending"
|
||||||
|
icon={ShieldCheck}
|
||||||
|
label={t('row_pending_ops')}
|
||||||
|
count={counts.pending_operations}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{bevakaRows && (
|
||||||
|
<div>
|
||||||
|
<BandHeader>{t('band_bevaka')}</BandHeader>
|
||||||
|
<div className="divide-y divide-border/50">
|
||||||
|
{counts.overdue_invoice > 0 && (
|
||||||
|
<WorklistRow
|
||||||
|
href="/invoices?status=unpaid"
|
||||||
|
icon={Receipt}
|
||||||
|
label={t('row_overdue_invoices')}
|
||||||
|
count={counts.overdue_invoice}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{counts.deadline_action > 0 && (
|
||||||
|
<WorklistRow
|
||||||
|
href="/deadlines"
|
||||||
|
icon={CalendarClock}
|
||||||
|
label={t('row_deadlines')}
|
||||||
|
count={counts.deadline_action}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{expiringBankConnections.length > 0 && (
|
||||||
|
<WorklistRow
|
||||||
|
href="/settings/banking"
|
||||||
|
icon={Landmark}
|
||||||
|
label={t('bank_consent_expiring')}
|
||||||
|
detail={
|
||||||
|
expiringBankConnections[0].days_left === 1
|
||||||
|
? t('bank_consent_detail_one', {
|
||||||
|
bank: expiringBankConnections[0].bank_name,
|
||||||
|
days: expiringBankConnections[0].days_left,
|
||||||
|
})
|
||||||
|
: t('bank_consent_detail_other', {
|
||||||
|
bank: expiringBankConnections[0].bank_name,
|
||||||
|
days: expiringBankConnections[0].days_left,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
count={expiringBankConnections.length}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -9,19 +9,18 @@ import { cn, formatCurrency } from '@/lib/utils'
|
|||||||
import { UpcomingDeadlinesWidget } from '@/components/deadlines/UpcomingDeadlinesWidget'
|
import { UpcomingDeadlinesWidget } from '@/components/deadlines/UpcomingDeadlinesWidget'
|
||||||
import { TaxTodoWidget } from '@/components/deadlines/TaxTodoWidget'
|
import { TaxTodoWidget } from '@/components/deadlines/TaxTodoWidget'
|
||||||
import NewUserChecklist from '@/components/onboarding/NewUserChecklist'
|
import NewUserChecklist from '@/components/onboarding/NewUserChecklist'
|
||||||
|
import AttGoraSection from '@/components/dashboard/AttGoraSection'
|
||||||
import {
|
import {
|
||||||
Receipt,
|
Receipt,
|
||||||
ArrowLeftRight,
|
ArrowLeftRight,
|
||||||
ChevronDown,
|
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Landmark,
|
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
FileWarning,
|
|
||||||
Clock,
|
Clock,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
} from 'lucide-react'
|
} 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'
|
import { getBranding } from '@/lib/branding/service'
|
||||||
|
|
||||||
const setupFreshStartKey = (companyId: string) => `erp_setup_fresh_start:${companyId}`
|
const setupFreshStartKey = (companyId: string) => `erp_setup_fresh_start:${companyId}`
|
||||||
@@ -31,9 +30,6 @@ interface DashboardContentProps {
|
|||||||
summary: {
|
summary: {
|
||||||
ytd: { income: number; expenses: number; net: number }
|
ytd: { income: number; expenses: number; net: number }
|
||||||
mtd: { income: number; expenses: number; net: number }
|
mtd: { income: number; expenses: number; net: number }
|
||||||
uncategorizedCount: number
|
|
||||||
uncategorizedIncome: number
|
|
||||||
uncategorizedExpenses: number
|
|
||||||
unpaidInvoicesCount: number
|
unpaidInvoicesCount: number
|
||||||
unpaidInvoicesTotal: number
|
unpaidInvoicesTotal: number
|
||||||
unpaidVatTotal: number
|
unpaidVatTotal: number
|
||||||
@@ -41,10 +37,12 @@ interface DashboardContentProps {
|
|||||||
bankBalance: number | null
|
bankBalance: number | null
|
||||||
expiringBankConnections?: { id: string; bank_name: string; days_left: number }[]
|
expiringBankConnections?: { id: string; bank_name: string; days_left: number }[]
|
||||||
deadlines: Deadline[]
|
deadlines: Deadline[]
|
||||||
receiptQueue: ReceiptQueueSummary | null
|
|
||||||
missingUnderlagCount: number
|
|
||||||
staleUncategorizedCount: 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
|
onboardingProgress?: OnboardingProgress
|
||||||
/**
|
/**
|
||||||
* False until the company has a verified agent_profile. When false the hero
|
* False until the company has a verified agent_profile. When false the hero
|
||||||
@@ -55,8 +53,7 @@ interface DashboardContentProps {
|
|||||||
agentBuilt?: boolean
|
agentBuilt?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DashboardContent({ companyId, summary, onboardingProgress, agentBuilt = true }: DashboardContentProps) {
|
export default function DashboardContent({ companyId, summary, worklist, suggestedMatches, onboardingProgress, agentBuilt = true }: DashboardContentProps) {
|
||||||
const [showAllAlerts, setShowAllAlerts] = useState(false)
|
|
||||||
const t = useTranslations('dashboard')
|
const t = useTranslations('dashboard')
|
||||||
|
|
||||||
// The setup gate exists to nudge brand-new users into a data-import step
|
// 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)
|
}).format(amount)
|
||||||
}
|
}
|
||||||
|
|
||||||
const alertItems: React.ReactNode[] = []
|
// One number, one source: the worklist total plus expiring bank connections
|
||||||
|
// (dashboard-only, not a lib/worklist category). Must match AttGoraSection's
|
||||||
if (summary.overdueInvoicesCount > 0) {
|
// header so the tile and the section never disagree.
|
||||||
alertItems.push(
|
const todoCount = worklist.total + (summary.expiringBankConnections?.length ?? 0)
|
||||||
<Link key="overdue" href="/invoices?status=unpaid" className="group">
|
|
||||||
<Card className="h-full border-destructive/30 hover:bg-destructive/[0.03] transition-colors">
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Receipt className="h-4 w-4 text-destructive flex-shrink-0" />
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-sm">{t('overdue_invoices')}</p>
|
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
|
||||||
{t('overdue_invoices_count', { count: summary.overdueInvoicesCount })}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (summary.unpaidInvoicesCount > 0 && summary.overdueInvoicesCount < summary.unpaidInvoicesCount) {
|
|
||||||
alertItems.push(
|
|
||||||
<Link key="unpaid" href="/invoices?status=unpaid" className="group">
|
|
||||||
<Card className="h-full border-warning/30 hover:bg-warning/[0.03] transition-colors">
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Receipt className="h-4 w-4 text-warning-foreground flex-shrink-0" />
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-sm">{t('unpaid_invoices')}</p>
|
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
|
||||||
{t('unpaid_invoices_detail', {
|
|
||||||
count: summary.unpaidInvoicesCount - summary.overdueInvoicesCount,
|
|
||||||
amount: formatCurrency(summary.unpaidInvoicesTotal),
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (summary.uncategorizedCount > 0) {
|
|
||||||
alertItems.push(
|
|
||||||
<Link key="transactions" href="/transactions" className="group">
|
|
||||||
<Card className="h-full border-warning/30 hover:bg-warning/[0.03] transition-colors">
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<ArrowLeftRight className="h-4 w-4 text-warning-foreground flex-shrink-0" />
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-sm">{t('transactions')}</p>
|
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
|
||||||
{t('uncategorized_count', { count: summary.uncategorizedCount })}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (summary.missingUnderlagCount > 0) {
|
|
||||||
alertItems.push(
|
|
||||||
<Link key="missing-underlag" href="/bookkeeping?missingUnderlag=true" className="group">
|
|
||||||
<Card className="h-full border-warning/30 hover:bg-warning/[0.03] transition-colors">
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<FileWarning className="h-4 w-4 text-warning-foreground flex-shrink-0" />
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-sm">{t('missing_underlag')}</p>
|
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
|
||||||
{t('missing_underlag_detail', { count: summary.missingUnderlagCount })}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (summary.staleUncategorizedCount > 0) {
|
|
||||||
alertItems.push(
|
|
||||||
<Link key="stale-transactions" href="/transactions" className="group">
|
|
||||||
<Card className="h-full border-destructive/30 hover:bg-destructive/[0.03] transition-colors">
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Clock className="h-4 w-4 text-destructive flex-shrink-0" />
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-sm">{t('stale_transactions')}</p>
|
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
|
||||||
{t('stale_transactions_detail', { count: summary.staleUncategorizedCount })}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (summary.expiringBankConnections && summary.expiringBankConnections.length > 0) {
|
|
||||||
const conn = summary.expiringBankConnections[0]
|
|
||||||
alertItems.push(
|
|
||||||
<Link key="bank-expiry" href="/settings/banking" className="group">
|
|
||||||
<Card className="h-full border-warning/30 hover:bg-warning/[0.03] transition-colors">
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Landmark className="h-4 w-4 text-warning-foreground flex-shrink-0" />
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-sm">{t('bank_consent_expiring')}</p>
|
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
|
||||||
{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 })}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
const slim = getBranding().navDensity === 'slim'
|
const slim = getBranding().navDensity === 'slim'
|
||||||
|
|
||||||
@@ -277,11 +141,12 @@ export default function DashboardContent({ companyId, summary, onboardingProgres
|
|||||||
icon: Receipt,
|
icon: Receipt,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (summary.uncategorizedCount > 0) {
|
if (worklist.counts.book_transaction > 0) {
|
||||||
|
const n = worklist.counts.book_transaction
|
||||||
return {
|
return {
|
||||||
href: '/transactions',
|
href: '/transactions',
|
||||||
title: 'Transaktioner att bokföra',
|
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',
|
cta: 'Bokför nu',
|
||||||
tone: 'primary' as const,
|
tone: 'primary' as const,
|
||||||
icon: ArrowLeftRight,
|
icon: ArrowLeftRight,
|
||||||
@@ -452,6 +317,15 @@ export default function DashboardContent({ companyId, summary, onboardingProgres
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Att göra — the unified worklist. One section, every actionable item,
|
||||||
|
same counts as the sidebar badges (lib/worklist). */}
|
||||||
|
<AttGoraSection
|
||||||
|
worklist={worklist}
|
||||||
|
suggestedMatches={suggestedMatches}
|
||||||
|
expiringBankConnections={summary.expiringBankConnections}
|
||||||
|
staleUncategorizedCount={summary.staleUncategorizedCount}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Result — revenue / expenses (always visible) */}
|
{/* Result — revenue / expenses (always visible) */}
|
||||||
<section>
|
<section>
|
||||||
<div className="grid md:grid-cols-2 gap-4">
|
<div className="grid md:grid-cols-2 gap-4">
|
||||||
@@ -487,27 +361,6 @@ export default function DashboardContent({ companyId, summary, onboardingProgres
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Att hantera — hidden in slim mode; the hero card already surfaces the top action */}
|
|
||||||
{!slim && alertItems.length > 0 && (
|
|
||||||
<section id="alerts-section">
|
|
||||||
<h2 className="font-display text-lg font-medium mb-4">{t('alerts_title')}</h2>
|
|
||||||
<div id="alerts-list" className="grid gap-4 md:grid-cols-2">
|
|
||||||
{visibleAlerts}
|
|
||||||
</div>
|
|
||||||
{hasMoreAlerts && (
|
|
||||||
<button
|
|
||||||
onClick={() => setShowAllAlerts(!showAllAlerts)}
|
|
||||||
aria-expanded={showAllAlerts}
|
|
||||||
aria-controls="alerts-list"
|
|
||||||
className="mt-3 py-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1"
|
|
||||||
>
|
|
||||||
{showAllAlerts ? t('show_less') : t('show_all', { count: alertItems.length })}
|
|
||||||
<ChevronDown className={cn('h-3 w-3 transition-transform', showAllAlerts && 'rotate-180')} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Upcoming deadlines */}
|
{/* Upcoming deadlines */}
|
||||||
{summary.deadlines && summary.deadlines.length > 0 && (
|
{summary.deadlines && summary.deadlines.length > 0 && (
|
||||||
<section>
|
<section>
|
||||||
|
|||||||
+24
-1
@@ -3704,7 +3704,30 @@
|
|||||||
"anomalies_detail": "{count} to address",
|
"anomalies_detail": "{count} to address",
|
||||||
"bank_consent_expiring": "Bank consent expiring",
|
"bank_consent_expiring": "Bank consent expiring",
|
||||||
"bank_consent_detail_one": "{bank} — {days} day left",
|
"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": {
|
"reports": {
|
||||||
"title": "Reports",
|
"title": "Reports",
|
||||||
|
|||||||
+24
-1
@@ -3704,7 +3704,30 @@
|
|||||||
"anomalies_detail": "{count} att åtgärda",
|
"anomalies_detail": "{count} att åtgärda",
|
||||||
"bank_consent_expiring": "Banksamtycke löper ut",
|
"bank_consent_expiring": "Banksamtycke löper ut",
|
||||||
"bank_consent_detail_one": "{bank} — {days} dag kvar",
|
"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": {
|
"reports": {
|
||||||
"title": "Rapporter",
|
"title": "Rapporter",
|
||||||
|
|||||||
Reference in New Issue
Block a user