The stagger is the standard content entry on every migrated page (convention 11), so its budget matters more than any single surface. It ran 500ms per item in 80ms steps, putting the 10th row at 1220ms against a 300ms UI budget, and it only defined delays for children 1-10: on any list longer than ten rows, children 11+ inherited delay 0 and arrived BEFORE the middle of the list. Now 300ms per item in 40ms steps with the delay capped at 360ms, so the tail lands at ~660ms instead of ~1220ms. The cap is on the delay, never on the animation: the `both` fill holds a child at opacity 0 until its delay elapses, so excluding rows 11+ from the animation would paint them while rows 1-10 were still invisible. Adds --ease-emphasized (the strong ease-out) rather than retiming --ease-out, which is unlayered in :root and therefore shadows Tailwind's own token: changing it would retime every ease-out utility in the app. Foldout rows in JournalEntryList, periodiseringar and TransactionInboxCard sit directly inside a staggered tbody, so expanding a verifikat fired the inherited slideUp (with an invisible pre-roll of up to 320ms) on top of the foldout's own transition. They opt out via data-no-stagger. Also: the confirmed match on Hem faded at full height and then vanished in one frame, jumping everything below it 52px; it now grid-collapses over 200ms with the gap inside the collapsing area. The assistant sheet slid nothing while the page panel animated 300ms to make room for it; it now arrives along the same edge on the same curve, gated to first mount so re-expanding a collapsed session stays instant. Chat history no longer replays 20 simultaneous 500ms page-entry slides on resume: only genuinely new messages animate. Payroll wizard segments transition their colour. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
400 lines
16 KiB
TypeScript
400 lines
16 KiB
TypeScript
'use client'
|
|
|
|
import { useState } from 'react'
|
|
import Link from 'next/link'
|
|
import { useTranslations } from 'next-intl'
|
|
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 { useCapability } from '@/contexts/CompanyContext'
|
|
import { CAPABILITY } from '@/lib/entitlements/keys'
|
|
import { visibleWorklistTotal } from '@/lib/worklist/visible-total'
|
|
import {
|
|
ArrowLeftRight,
|
|
ArrowRight,
|
|
CalendarClock,
|
|
CheckCircle2,
|
|
ChevronRight,
|
|
Eye,
|
|
FileWarning,
|
|
Inbox,
|
|
Landmark,
|
|
Loader2,
|
|
ReceiptText,
|
|
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[]
|
|
}
|
|
|
|
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="group flex w-full items-start gap-3 border-b border-border px-1 py-3.5 transition-colors duration-150 hover:bg-secondary/30"
|
|
>
|
|
<span className="mt-px w-[18px] shrink-0 text-muted-foreground" aria-hidden>
|
|
<Icon className="h-[15px] w-[15px]" />
|
|
</span>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="truncate text-[13.5px]">{label}</p>
|
|
{detail && <p className="mt-0.5 truncate text-xs text-muted-foreground">{detail}</p>}
|
|
</div>
|
|
<span className="ml-auto flex shrink-0 items-center gap-2.5 pt-px">
|
|
{badge}
|
|
<Badge variant="secondary" className="font-normal tabular-nums">
|
|
{count}
|
|
</Badge>
|
|
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground opacity-0 transition-opacity duration-150 group-hover:opacity-100" />
|
|
</span>
|
|
</Link>
|
|
)
|
|
}
|
|
|
|
function BandHeader({ children }: { children: React.ReactNode }) {
|
|
return (
|
|
<p className="px-1 pt-5 pb-1 text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground/80">
|
|
{children}
|
|
</p>
|
|
)
|
|
}
|
|
|
|
export default function AttGoraSection({
|
|
worklist,
|
|
suggestedMatches,
|
|
expiringBankConnections = [],
|
|
}: AttGoraSectionProps) {
|
|
const t = useTranslations('dashboard')
|
|
const { toast } = useToast()
|
|
// The Dokumentinkorg is a paid (AI) surface: a non-payer's home to-do list
|
|
// must not offer a row that jumps to the gated workspace. Mirrors the sidebar
|
|
// + command palette gate; the page itself enforces it server-side.
|
|
const hasAi = useCapability(CAPABILITY.ai)
|
|
|
|
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. Note this is a browser console.error and nothing collects it:
|
|
// the observability sink (lib/observability) is server-side and has no
|
|
// client adapter, so a systematically broken counts endpoint currently
|
|
// hides behind silently frozen numbers until someone reports it.
|
|
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
|
|
})
|
|
}, 200)
|
|
void refetchCounts()
|
|
} catch {
|
|
toast({ title: t('suggested_failed_toast'), variant: 'destructive' })
|
|
} finally {
|
|
setConfirmingId(null)
|
|
}
|
|
}
|
|
|
|
const showInboxDocuments = hasAi && counts.inbox_document > 0
|
|
const bokforRows = counts.book_transaction > 0 || showInboxDocuments || 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, computed off
|
|
// the same visibleWorklistTotal helper as the dashboard KPI tile so the two
|
|
// can never drift: the hidden paid inbox row is subtracted for non-payers,
|
|
// else the header would count work the section no longer renders.
|
|
const displayTotal = visibleWorklistTotal({
|
|
total,
|
|
inboxDocumentCount: counts.inbox_document,
|
|
hasAi,
|
|
extra: expiringBankConnections.length,
|
|
})
|
|
|
|
return (
|
|
<section aria-label={t('att_gora_title')}>
|
|
{/* Pane header: Geist title + quiet count over a hairline */}
|
|
<div className="flex items-baseline justify-between border-b border-border px-1 pb-2.5">
|
|
<h2 className="font-sans text-sm font-medium">{t('att_gora_title')}</h2>
|
|
<p className="text-xs text-muted-foreground tabular-nums" role="status" aria-live="polite">
|
|
{allClear ? t('all_done') : t('att_gora_left', { count: displayTotal })}
|
|
</p>
|
|
</div>
|
|
|
|
<div>
|
|
{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>
|
|
{counts.book_transaction > 0 && (
|
|
<WorklistRow
|
|
href="/transactions"
|
|
icon={ArrowLeftRight}
|
|
label={t('row_book_transactions')}
|
|
count={counts.book_transaction}
|
|
/>
|
|
)}
|
|
{matches.length > 0 && (
|
|
<div className="px-4 py-3">
|
|
<p className="text-xs text-muted-foreground mb-2">
|
|
{t('suggested_title')}
|
|
</p>
|
|
<div>
|
|
{matches.map((match) => {
|
|
const isLeaving = leavingIds.has(match.transaction_id)
|
|
const isConfirming = confirmingId === match.transaction_id
|
|
return (
|
|
<div
|
|
key={match.transaction_id}
|
|
className={cn(
|
|
'grid transition-[grid-template-rows,opacity] duration-200 motion-reduce:transition-none',
|
|
isLeaving ? 'grid-rows-[0fr] opacity-0' : 'grid-rows-[1fr]',
|
|
)}
|
|
>
|
|
<div className="overflow-hidden pb-1">
|
|
<div className="flex items-center gap-3 rounded bg-secondary/40 px-3 py-2">
|
|
<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>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{showInboxDocuments && (
|
|
<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>
|
|
{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>
|
|
{counts.overdue_invoice > 0 && (
|
|
<WorklistRow
|
|
href="/invoices?status=unpaid"
|
|
icon={ReceiptText}
|
|
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>
|
|
)}
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|