'use client' import Link from 'next/link' import { useState } from 'react' import { useLocale, useTranslations } from 'next-intl' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { AlertTriangle, Download, Loader2, CheckCircle2, ChevronDown, Info } 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 type { ErrorLocale } from '@/lib/errors/get-error-message' type PaymentFormat = 'bg_lb' | 'pain001' interface PaymentFilePanelProps { salaryRunId: string periodLabel: string paymentFileFormat: string | null paymentFileGeneratedAt: string | null defaultFormat: PaymentFormat /** company_settings.salary_default_bank: sorts and auto-expands the matching bank's instructions. */ defaultBank?: string | null readOnly?: boolean onDownloaded?: () => void } type BankKey = 'swedbank' | 'seb' | 'handelsbanken' | 'nordea' const BANK_NAME: Record = { swedbank: 'Swedbank', seb: 'SEB', handelsbanken: 'Handelsbanken', nordea: 'Nordea', } // Instruction copy lives in messages/{sv,en}.json under // salary_payments.steps__; this is the ordered key list. const BANKS_BY_FORMAT: Record = { bg_lb: ['swedbank', 'seb', 'handelsbanken', 'nordea'], pain001: ['swedbank', 'seb', 'handelsbanken', 'nordea'], } export function PaymentFilePanel({ salaryRunId, periodLabel, paymentFileFormat, paymentFileGeneratedAt, defaultFormat, defaultBank, readOnly, onDownloaded, }: PaymentFilePanelProps) { const t = useTranslations('salary_payments') const locale = useLocale() as ErrorLocale const { toast } = useToast() const [format, setFormat] = useState(defaultFormat) const [downloading, setDownloading] = useState(false) const banks = BANKS_BY_FORMAT[format] const matchedBank = banks.find((b) => b === defaultBank) ?? null const sortedBanks = matchedBank ? [matchedBank, ...banks.filter((b) => b !== matchedBank)] : banks const [showInstructions, setShowInstructions] = useState(Boolean(matchedBank)) const FORMAT_LABEL: Record = { bg_lb: t('format_bg_lb'), pain001: t('format_pain001'), } const endpoint = format === 'bg_lb' ? `/api/salary/runs/${salaryRunId}/payment/bg-lb` : `/api/salary/runs/${salaryRunId}/payment/pain001` async function handleDownload() { // The button is disabled while a file is in flight; this guard closes the // double-click / Enter-repeat race before React has re-rendered it. Two // payment files for one salary run is not a cosmetic problem: each one is // payable on its own, so a user who uploads both to the bank pays the // month's wages twice. if (downloading) return setDownloading(true) try { const ext = format === 'bg_lb' ? 'txt' : 'xml' // Bounded, and no file is written unless the server answered 2xx with a // complete body: an error envelope saved as lon_2026-04.xml is a file the // user would carry to the bank before discovering it pays nobody. const result = await downloadFile({ url: endpoint, filename: `lon_${periodLabel}.${ext}`, locale, }) // Exactly one toast per outcome. TOAST_LIMIT is 1, so a failure toast // followed by a success toast in the same tick would render only the last. if (!result.ok) { toast({ title: t('download_failed_title'), description: failureDescription(result, { timeout: t('download_timeout'), network: t('download_network'), }), variant: 'destructive', }) return } toast({ title: t('downloaded') }) onDownloaded?.() } finally { setDownloading(false) } } return ( {t('title')} {paymentFileFormat && paymentFileGeneratedAt && (
{t('last_generated')}{' '} {FORMAT_LABEL[paymentFileFormat as PaymentFormat] ?? paymentFileFormat} {' '} ({new Date(paymentFileGeneratedAt).toLocaleString('sv-SE')})
)} {!readOnly && ( <>

{format === 'bg_lb' ? t('format_description_bg_lb') : t('format_description_pain001')}

{/* The flow looked clean all the way until the bank said no: the file downloads fine and only fails at upload, on the pay date. Say the delivery precondition up front instead. */} {format === 'pain001' && (
{t('pain001_agreement_warning')}
)} {format === 'bg_lb' && (
{t('sunset_warning')}{' '} {t('sunset_link')}
)}
{showInstructions && (
{sortedBanks.map((bank) => (
{BANK_NAME[bank]} {bank === matchedBank ? ` (${t('your_bank')})` : ''}. {' '} {t(`steps_${format}_${bank}`)}
))}

{t('instructions_footer')}

)}
{t('open_payments_note')}
)}
) }