Files
accounted/components/transactions/ExpenseClaimPickerDialog.tsx
Jakob Wennberg 3b9daf2606 fix(expenses): review follow-ups from #2333 (#2352)
- The owner's claimant key is trimmed and lower-cased, the same rule the
  payout RPC applies, so "Jakob" and "jakob " are one person with one
  Att göra row and one exact-amount match.
- The inbox pages through every registered claim (fetchAllRows) before
  pairing, so a long backlog can never understate a person's debt.
- A foreign receipt's VAT field is locked at 0 and 0 is what is submitted.
- Transport failures in the one-click and picker confirms show the
  destructive toast instead of failing silently.
- The open-claims flag is set only after the stale-fetch guard, and a
  payout match decrements the inbox count like every other row exit.
- Test: reset the live-link mock before the bank_line junction case.
- Wording: "Återbetalning av utlägg".

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 20:19:18 +02:00

231 lines
8.4 KiB
TypeScript

'use client'
import { useEffect, useMemo, useState } from 'react'
import { useTranslations } from 'next-intl'
import { Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { formatCurrency, formatDate } from '@/lib/utils'
import { roundOre } from '@/lib/money'
import { expenseClaimantKey, groupExpenseClaimsByPerson } from '@/lib/expenses/expense-payout-candidates'
import type { ExpensePayoutDue } from '@/lib/worklist/types'
import type { TransactionWithInvoice } from './transaction-types'
interface ClaimRow {
id: string
employee_id: string | null
claimant_name: string
liability_account: string
amount_sek: number | string
expense_date: string
description: string
}
interface Props {
open: boolean
onOpenChange: (open: boolean) => void
transaction: TransactionWithInvoice | null
onMatched: (transactionId: string, journalEntryId: string, personKey: string) => void
}
/**
* Manual "Matcha mot utlägg": for an outflow that the exact-amount suggestion
* did not pair (two receipts of three were paid, or the amount covers a
* subset), the user picks the person and the receipts the transfer covers.
* The sum must equal the transfer to the öre: the same RPC as the one-click
* path books it, so a partial receipt can never be marked paid.
*/
export default function ExpenseClaimPickerDialog({ open, onOpenChange, transaction, onMatched }: Props) {
const t = useTranslations('tx_expense_claim_picker')
const { toast } = useToast()
const [claims, setClaims] = useState<ClaimRow[]>([])
const [loading, setLoading] = useState(false)
const [personKey, setPersonKey] = useState<string>('')
const [selected, setSelected] = useState<Set<string>>(new Set())
const [submitting, setSubmitting] = useState(false)
const transferOre = transaction ? Math.round(Math.abs(transaction.amount) * 100) : 0
useEffect(() => {
if (!open) return
let cancelled = false
setLoading(true)
setSelected(new Set())
fetch('/api/expense-claims?status=registered')
.then((res) => (res.ok ? res.json() : { data: [] }))
.then((json) => {
if (cancelled) return
const rows = ((json?.data ?? []) as ClaimRow[]).filter((r) => r.liability_account !== '2018')
setClaims(rows)
const people = groupExpenseClaimsByPerson(rows)
// Preselect the person (and all their receipts) when one person's
// total equals the transfer; otherwise the first person, nothing ticked.
const exact = people.find((p) => Math.round(p.total_sek * 100) === transferOre)
const first = exact ?? people[0]
setPersonKey(first?.key ?? '')
setSelected(new Set(exact ? exact.claim_ids : []))
})
.catch(() => {
if (!cancelled) setClaims([])
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [open, transferOre])
const people: ExpensePayoutDue[] = useMemo(() => groupExpenseClaimsByPerson(claims), [claims])
const personClaims = useMemo(
() =>
claims
.filter((c) => expenseClaimantKey(c) === personKey)
.sort((a, b) => a.expense_date.localeCompare(b.expense_date)),
[claims, personKey],
)
const selectedOre = personClaims
.filter((c) => selected.has(c.id))
.reduce((sum, c) => sum + Math.round((Number(c.amount_sek) || 0) * 100), 0)
const diff = roundOre((selectedOre - transferOre) / 100)
const canConfirm = !submitting && !loading && selected.size > 0 && selectedOre === transferOre
const toggle = (id: string, checked: boolean) => {
setSelected((prev) => {
const next = new Set(prev)
if (checked) next.add(id)
else next.delete(id)
return next
})
}
const handleConfirm = async () => {
if (!transaction || !canConfirm) return
setSubmitting(true)
try {
const res = await fetch(`/api/transactions/${transaction.id}/match-expense-payout`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ claim_ids: [...selected] }),
})
const result = await res.json().catch(() => ({}))
if (!res.ok) {
toast({
title: t('failed_title'),
description: getErrorMessage(result, { context: 'transaction', statusCode: res.status }),
variant: 'destructive',
})
return
}
onMatched(transaction.id, result.journal_entry_id, personKey)
} catch (error) {
toast({
title: t('failed_title'),
description: getErrorMessage(error, { context: 'transaction' }),
variant: 'destructive',
})
} finally {
setSubmitting(false)
}
}
return (
<Dialog open={open} onOpenChange={(next) => !submitting && onOpenChange(next)}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{t('title')}</DialogTitle>
<DialogDescription>{t('description')}</DialogDescription>
</DialogHeader>
{loading ? (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
</div>
) : people.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">{t('no_claims')}</p>
) : (
<div className="space-y-4">
{people.length > 1 && (
<div className="space-y-1.5">
<Label>{t('person_label')}</Label>
<Select
value={personKey}
onValueChange={(next) => {
setPersonKey(next)
setSelected(new Set())
}}
>
<SelectTrigger className="h-9 text-[13px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{people.map((p) => (
<SelectItem key={p.key} value={p.key}>
{p.claimant_name} · {formatCurrency(p.total_sek)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="rounded-lg border border-border">
{personClaims.map((c) => (
<label
key={c.id}
className="flex cursor-pointer items-center gap-3 border-b border-border px-3 py-2.5 text-[13px] last:border-b-0 hover:bg-secondary/30"
>
<Checkbox
checked={selected.has(c.id)}
onCheckedChange={(checked) => toggle(c.id, checked === true)}
disabled={submitting}
/>
<span className="w-[86px] shrink-0 tabular-nums text-muted-foreground">{formatDate(c.expense_date)}</span>
<span className="min-w-0 flex-1 truncate">{c.description}</span>
<span className="shrink-0 tabular-nums">{formatCurrency(Number(c.amount_sek))}</span>
</label>
))}
</div>
<div className="flex items-baseline justify-between text-xs text-muted-foreground tabular-nums">
<span>{t('selected_sum', { amount: formatCurrency(selectedOre / 100) })}</span>
<span>{t('transfer_sum', { amount: formatCurrency(transferOre / 100) })}</span>
</div>
{selected.size > 0 && diff !== 0 && (
<p className="text-xs text-attn tabular-nums">{t('diff', { amount: formatCurrency(diff) })}</p>
)}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
{t('cancel')}
</Button>
<Button onClick={handleConfirm} disabled={!canConfirm}>
{submitting && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
{t('confirm')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}