'use client' import Link from 'next/link' import { useState } from 'react' import { useLocale, useTranslations } from 'next-intl' import { Button } from '@/components/ui/button' import { DetailSection, DefRow } from '@/components/ui/detail-section' import { HelpPopover } from '@/components/ui/help-popover' import { AttnLine } from '@/components/ui/attn-line' import { SettingsSelect } from '@/components/settings/SettingsRows' import { Download, Loader2, ChevronDown } 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 { cn, formatDateTime } from '@/lib/utils' 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 /** * company_settings.bankgiro / iban: the sender account each format requires. * null means missing as of the latest settings fetch (warn up front, the * download would 400); undefined means unknown (settings not loaded), so no * warning is shown. The caller must refetch after detours that can fix the * setting (the warning links into the settings modal over this page). */ senderBankgiro?: string | null senderIban?: 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, senderBankgiro, senderIban, 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` // The sender account lives in company_settings, not in the Bolagsverket // snapshot shown on the settings overview: users see a bankgiro there and // reasonably believe it is configured. Say the precondition here, before // the download 400s on it. const senderMissing = (format === 'bg_lb' && senderBankgiro === null) || (format === 'pain001' && senderIban === null) 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) } } // 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 of the chosen format up front. It is one sentence per // format: the pain.001 agreement caveat, or the LB sunset with its link. const formatCaveat = format === 'pain001' ? ( t('pain001_agreement_warning') ) : ( <> {t('sunset_warning')}{' '} {t('sunset_link')} ) return (

{FORMAT_LABEL.pain001}: {t('format_description_pain001')}

{FORMAT_LABEL.bg_lb}: {t('format_description_bg_lb')}

{t('open_payments_note')}

} >
{paymentFileFormat && paymentFileGeneratedAt && ( {FORMAT_LABEL[paymentFileFormat as PaymentFormat] ?? paymentFileFormat}{' '} ({formatDateTime(paymentFileGeneratedAt)}) )} {!readOnly && ( setFormat(e.target.value as PaymentFormat)} wrapperClassName="-my-1" > )}
{!readOnly && (
{/* One attention sentence per section (convention 6). A missing sender account outranks the format caveat: without it the download fails outright. The caveat then drops to a muted line so the delivery precondition is still said. */} {senderMissing ? ( <> {format === 'bg_lb' ? t('missing_bankgiro_warning') : t('missing_iban_warning')}

{formatCaveat}

) : format === 'pain001' ? ( {t('pain001_agreement_warning')} ) : ( {t('sunset_warning')} )} {/* The one action row: the bank instructions are an appendix that folds out under it, so the button stays where the section ends. */}
{showInstructions && (
{sortedBanks.map((bank) => (

{BANK_NAME[bank]} {bank === matchedBank ? ` (${t('your_bank')})` : ''}. {' '} {t(`steps_${format}_${bank}`)}

))}

{t('instructions_footer')}

)}
)}
) }