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