'use client' import { useCallback, useEffect, useMemo, useState } from 'react' import { useLocale, useTranslations } from 'next-intl' import { Dialog, DialogContent, DialogHeader, DialogTitle, } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { Input } from '@/components/ui/input' import { Checkbox } from '@/components/ui/checkbox' import { Skeleton } from '@/components/ui/skeleton' import { AttnLine } from '@/components/ui/attn-line' import { AlertTriangle, Download, Loader2 } from 'lucide-react' import { useToast } from '@/components/ui/use-toast' import { downloadFile } from '@/lib/browser/download-file' import { failureDescription } from '@/lib/browser/action-failure' import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message' import { formatCurrency } from '@/lib/utils' interface PreviewLine { id: string supplier_name: string invoice_number: string amount: number payment_date: string payee: { type: string; label: string } reference: { type: 'ocr' | 'invoice_number'; value: string } warnings: Array<'unattested' | 'already_batched' | 'ocr_invalid' | 'payee_city_missing'> active_batch_id: string | null } interface Preview { eligible: PreviewLine[] excluded: Array<{ id: string; reason: string }> total: number debtor_ok: boolean debtor_missing?: 'iban' | 'bic' | 'org_number' } interface PaymentFileDialogProps { open: boolean onOpenChange: (open: boolean) => void /** Selected supplier-invoice ids from the list page. */ invoiceIds: string[] /** Called after a batch was created and its file downloaded. */ onCreated: () => void /** Lets the dialog name the excluded invoices, not just their ids. */ invoiceLabelById?: ReadonlyMap } export default function PaymentFileDialog({ open, onOpenChange, invoiceIds, onCreated, invoiceLabelById, }: PaymentFileDialogProps) { const t = useTranslations('supplier_payment_files') const locale = useLocale() as ErrorLocale const { toast } = useToast() const [preview, setPreview] = useState(null) const [loading, setLoading] = useState(false) const [creating, setCreating] = useState(false) const [confirmAlreadyBatched, setConfirmAlreadyBatched] = useState(false) // Per-line editable overrides, keyed by invoice id. Values stay as input // strings so partially-typed numbers do not fight the user. const [amounts, setAmounts] = useState>({}) const [dates, setDates] = useState>({}) const loadPreview = useCallback(async () => { setLoading(true) setPreview(null) setAmounts({}) setDates({}) setConfirmAlreadyBatched(false) try { const res = await fetch('/api/supplier-invoices/payment-batches/preview', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ format: 'pain001', ids: invoiceIds }), }) const body = await res.json() if (!res.ok) { toast({ title: t('preview_failed_title'), description: getErrorMessage(body, { locale }), variant: 'destructive', }) onOpenChange(false) return } setPreview(body.data as Preview) } catch { toast({ title: t('preview_failed_title'), description: getErrorMessage(null, { locale }), variant: 'destructive', }) onOpenChange(false) } finally { setLoading(false) } }, [invoiceIds, locale, onOpenChange, t, toast]) useEffect(() => { if (open) loadPreview() }, [open, loadPreview]) const lineAmount = useCallback( (line: PreviewLine): number => { const raw = amounts[line.id] if (raw === undefined) return line.amount const parsed = Number.parseFloat(raw.replace(',', '.')) return Number.isFinite(parsed) ? parsed : 0 }, [amounts], ) const total = useMemo( () => (preview ? preview.eligible.reduce((sum, line) => sum + lineAmount(line), 0) : 0), [preview, lineAmount], ) const hasAlreadyBatched = preview?.eligible.some((line) => line.warnings.includes('already_batched')) ?? false const hasInvalidAmount = preview?.eligible.some((line) => lineAmount(line) <= 0 || lineAmount(line) > line.amount) ?? false const canCreate = !!preview && preview.eligible.length > 0 && preview.debtor_ok && !hasInvalidAmount && (!hasAlreadyBatched || confirmAlreadyBatched) && !creating async function handleCreate() { if (!preview || creating) return setCreating(true) try { const res = await fetch('/api/supplier-invoices/payment-batches', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ format: 'pain001', items: preview.eligible.map((line) => ({ supplier_invoice_id: line.id, amount: lineAmount(line), payment_date: dates[line.id] ?? line.payment_date, })), ...(confirmAlreadyBatched ? { confirm_already_batched: true } : {}), }), }) const body = await res.json() if (!res.ok) { toast({ title: t('create_failed_title'), description: getErrorMessage(body, { locale }), variant: 'destructive', }) // The server re-evaluated and something changed since the preview: // reload so the dialog shows the state the refusal was based on. loadPreview() return } const batch = body.data as { id: string; created_at: string } const datePart = batch.created_at.slice(0, 10).replace(/-/g, '') const shortId = batch.id.replace(/-/g, '').slice(0, 8) const result = await downloadFile({ url: `/api/supplier-invoices/payment-batches/${batch.id}/file`, filename: `betalfil_${datePart}_${shortId}.xml`, locale, }) if (!result.ok) { // The batch exists even though the download failed; the history page // can re-serve the identical file, so point there instead of implying // the whole operation failed. toast({ title: t('download_failed_title'), description: failureDescription(result, { timeout: t('download_timeout'), network: t('download_network'), }), variant: 'destructive', }) } else { toast({ title: t('created_toast') }) } onCreated() onOpenChange(false) } finally { setCreating(false) } } return ( !creating && onOpenChange(next)}> {t('dialog_title')} {loading || !preview ? (
{[1, 2, 3].map((i) => (
))}
) : (
{!preview.debtor_ok && ( {preview.debtor_missing === 'bic' ? t('debtor_missing_bic') : preview.debtor_missing === 'org_number' ? t('debtor_missing_org') : t('debtor_missing_iban')} )} {preview.eligible.length > 0 && (
{preview.eligible.map((line) => ( ))}
{t('th_supplier')} {t('th_payee')} {t('th_reference')} {t('th_payment_date')} {t('th_amount')}
{line.supplier_name} {line.invoice_number} {line.warnings.map((warning) => ( {t(`warning_${warning}`)} ))} {line.payee.label} {line.reference.value} setDates((prev) => ({ ...prev, [line.id]: e.target.value })) } className="h-8 w-[140px] text-xs tabular-nums" /> setAmounts((prev) => ({ ...prev, [line.id]: e.target.value })) } aria-invalid={lineAmount(line) <= 0 || lineAmount(line) > line.amount} className="h-8 w-[110px] text-right text-xs tabular-nums" />
{t('total_label')} {formatCurrency(total)}
)} {preview.eligible.length === 0 && (

{t('none_eligible')}

)} {preview.excluded.length > 0 && (

{t('excluded_title')}

{preview.excluded.map((row) => (

{invoiceLabelById?.get(row.id) ?? row.id}:{' '} {t(`excluded_reason_${row.reason}`)}

))}
)} {/* The file downloads fine and only fails at the bank if the upload agreement is missing: say the precondition up front. */}
{t('pain001_agreement_warning')}
{hasAlreadyBatched && ( )}
)}
) }