'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 && ( )}
)}
)}
) }