Files
accounted/components/salary/PaymentFilePanel.tsx
T
Jakob Wennberg 9686b54b41 refactor(design): lock the border-radius ladder, one radius per role (#1607)
Seven radii were in circulation (4/5/6/8/12/16px + pill) with no rule for
which went where; one toolbar row on /transactions mixed four shape
languages. This locks a 4-tier ladder (design.md convention 16):

- pill: interactive toolbar controls (buttons, chips, pickers, segmented
  controls, toolbar search, count nubs)
- rounded-xl (12px): overlay tier: page panel, dialogs, slide-overs
- rounded-lg (8px): cards, form fields, popover/menu content, boxes
- rounded-sm (4px): nested leaves (menu items, checkboxes, kbd/code nubs)

Changes:
- New SegmentedControl primitive (pill-in-pill tablist, h-8) replaces the
  hand-rolled bg-muted/70 tablist copied across 11 files
- New ToolbarSearch primitive (pill, h-8) adopted on 9 page toolbars;
  dialog/picker searches keep the rounded-lg Input
- dialog.tsx 8px -> 12px, matching SettingsModal/slide-over/CommandPalette
- ContextPicker chips at the shared h-8 toolbar height
- ~300 rounded-md / bare rounded call sites remapped by role; auth icon
  tiles and the mobile nav sheet come down from 16px to 12px
- rounded-md, bare rounded, rounded-2xl and rounded-[Npx] are dead
  vocabulary, enforced by a new off-ladder-radius check in check:guards

Verified: lint 0 errors, 14422 unit tests pass, check:guards green, tsc
clean on all changed files, sandbox screenshots of transactions/
bookkeeping/granskning toolbars and the Ny verifikation dialog.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 08:55:37 +02:00

228 lines
8.6 KiB
TypeScript

'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<BankKey, string> = {
swedbank: 'Swedbank',
seb: 'SEB',
handelsbanken: 'Handelsbanken',
nordea: 'Nordea',
}
// Instruction copy lives in messages/{sv,en}.json under
// salary_payments.steps_<format>_<bank>; this is the ordered key list.
const BANKS_BY_FORMAT: Record<PaymentFormat, BankKey[]> = {
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<PaymentFormat>(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<PaymentFormat, string> = {
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 (
<Card>
<CardHeader>
<CardTitle className="text-base">{t('title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{paymentFileFormat && paymentFileGeneratedAt && (
<div className="flex items-start gap-2 text-sm text-muted-foreground">
<CheckCircle2 className="h-4 w-4 mt-0.5 text-success" />
<div>
{t('last_generated')}{' '}
<span className="text-foreground">
{FORMAT_LABEL[paymentFileFormat as PaymentFormat] ?? paymentFileFormat}
</span>{' '}
({new Date(paymentFileGeneratedAt).toLocaleString('sv-SE')})
</div>
</div>
)}
{!readOnly && (
<>
<div className="space-y-1">
<label className="text-sm font-medium">{t('format_label')}</label>
<Select value={format} onValueChange={(v) => setFormat(v as PaymentFormat)}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="pain001">{FORMAT_LABEL.pain001}</SelectItem>
<SelectItem value="bg_lb">{FORMAT_LABEL.bg_lb}</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{format === 'bg_lb' ? t('format_description_bg_lb') : t('format_description_pain001')}
</p>
</div>
{/* 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' && (
<div className="flex items-start gap-2 rounded-lg border border-border p-3 text-xs">
<AlertTriangle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
<span className="text-muted-foreground">{t('pain001_agreement_warning')}</span>
</div>
)}
{format === 'bg_lb' && (
<div className="flex items-start gap-2 rounded-lg border border-border p-3 text-xs">
<AlertTriangle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
<span className="text-muted-foreground">
{t('sunset_warning')}{' '}
<Link
href="/settings/salary"
className="underline underline-offset-2 hover:text-foreground"
>
{t('sunset_link')}
</Link>
</span>
</div>
)}
<div className="flex justify-end">
<Button onClick={handleDownload} disabled={downloading}>
{downloading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Download className="mr-2 h-4 w-4" />
)}
{t('download')}
</Button>
</div>
<button
type="button"
onClick={() => setShowInstructions(s => !s)}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground"
aria-expanded={showInstructions}
>
<ChevronDown className={`h-3 w-3 transition-transform ${showInstructions ? 'rotate-180' : ''}`} />
{t('instructions_toggle')}
</button>
{showInstructions && (
<div className="space-y-2 rounded-lg border bg-muted/30 p-3 text-xs">
{sortedBanks.map((bank) => (
<div key={bank}>
<strong className="text-foreground">
{BANK_NAME[bank]}
{bank === matchedBank ? ` (${t('your_bank')})` : ''}.
</strong>{' '}
<span className="text-muted-foreground">
{t(`steps_${format}_${bank}`)}
</span>
</div>
))}
<p className="pt-1 text-muted-foreground border-t mt-2">
{t('instructions_footer')}
</p>
</div>
)}
<div className="flex items-start gap-2 rounded-lg border border-dashed bg-muted/20 p-3 text-xs text-muted-foreground">
<Info className="h-3.5 w-3.5 mt-0.5 shrink-0" />
<span>{t('open_payments_note')}</span>
</div>
</>
)}
</CardContent>
</Card>
)
}