'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 { 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([]) const [loading, setLoading] = useState(false) const [personKey, setPersonKey] = useState('') const [selected, setSelected] = useState>(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) => (c.employee_id ?? `owner:${c.claimant_name}`) === 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) } finally { setSubmitting(false) } } return ( !submitting && onOpenChange(next)}> {t('title')} {t('description')} {loading ? (
) : people.length === 0 ? (

{t('no_claims')}

) : (
{people.length > 1 && (
)}
{personClaims.map((c) => ( ))}
{t('selected_sum', { amount: formatCurrency(selectedOre / 100) })} {t('transfer_sum', { amount: formatCurrency(transferOre / 100) })}
{selected.size > 0 && diff !== 0 && (

{t('diff', { amount: formatCurrency(diff) })}

)}
)}
) }