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>
369 lines
14 KiB
TypeScript
369 lines
14 KiB
TypeScript
'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<string, string>
|
|
}
|
|
|
|
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<Preview | null>(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<Record<string, string>>({})
|
|
const [dates, setDates] = useState<Record<string, string>>({})
|
|
|
|
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 (
|
|
<Dialog open={open} onOpenChange={(next) => !creating && onOpenChange(next)}>
|
|
<DialogContent className="max-w-2xl">
|
|
<DialogHeader>
|
|
<DialogTitle>{t('dialog_title')}</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
{loading || !preview ? (
|
|
<div className="space-y-3 py-2">
|
|
{[1, 2, 3].map((i) => (
|
|
<div key={i} className="flex items-center gap-4">
|
|
<Skeleton className="h-4 w-40" />
|
|
<Skeleton className="h-4 w-24 flex-1" />
|
|
<Skeleton className="h-4 w-20" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
{!preview.debtor_ok && (
|
|
<AttnLine
|
|
action={
|
|
preview.debtor_missing === 'org_number'
|
|
? { label: t('debtor_missing_org_link'), href: '/settings/company' }
|
|
: { label: t('debtor_missing_link'), href: '/settings/invoicing' }
|
|
}
|
|
>
|
|
{preview.debtor_missing === 'bic'
|
|
? t('debtor_missing_bic')
|
|
: preview.debtor_missing === 'org_number'
|
|
? t('debtor_missing_org')
|
|
: t('debtor_missing_iban')}
|
|
</AttnLine>
|
|
)}
|
|
|
|
{preview.eligible.length > 0 && (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full border-collapse text-[13px]">
|
|
<thead>
|
|
<tr className="border-b border-border text-left text-[11px] uppercase tracking-wider text-muted-foreground">
|
|
<th className="py-2 pr-3 font-medium">{t('th_supplier')}</th>
|
|
<th className="py-2 pr-3 font-medium">{t('th_payee')}</th>
|
|
<th className="py-2 pr-3 font-medium">{t('th_reference')}</th>
|
|
<th className="py-2 pr-3 font-medium">{t('th_payment_date')}</th>
|
|
<th className="py-2 text-right font-medium">{t('th_amount')}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{preview.eligible.map((line) => (
|
|
<tr key={line.id} className="border-b border-border/60 align-middle">
|
|
<td className="max-w-0 w-1/3 py-2 pr-3">
|
|
<span className="block truncate">{line.supplier_name}</span>
|
|
<span className="flex flex-wrap items-center gap-1 text-[11px] text-muted-foreground">
|
|
<span className="tabular-nums">{line.invoice_number}</span>
|
|
{line.warnings.map((warning) => (
|
|
<Badge
|
|
key={warning}
|
|
variant={warning === 'already_batched' ? 'warning' : 'outline'}
|
|
className="px-1.5 py-0 text-[10px] font-normal"
|
|
>
|
|
{t(`warning_${warning}`)}
|
|
</Badge>
|
|
))}
|
|
</span>
|
|
</td>
|
|
<td className="whitespace-nowrap py-2 pr-3 tabular-nums">
|
|
{line.payee.label}
|
|
</td>
|
|
<td className="max-w-[120px] truncate whitespace-nowrap py-2 pr-3 tabular-nums text-muted-foreground">
|
|
{line.reference.value}
|
|
</td>
|
|
<td className="whitespace-nowrap py-2 pr-3">
|
|
<Input
|
|
type="date"
|
|
value={dates[line.id] ?? line.payment_date}
|
|
onChange={(e) =>
|
|
setDates((prev) => ({ ...prev, [line.id]: e.target.value }))
|
|
}
|
|
className="h-8 w-[140px] text-xs tabular-nums"
|
|
/>
|
|
</td>
|
|
<td className="whitespace-nowrap py-2 text-right">
|
|
<Input
|
|
inputMode="decimal"
|
|
value={amounts[line.id] ?? String(line.amount)}
|
|
onChange={(e) =>
|
|
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"
|
|
/>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
<tfoot>
|
|
<tr>
|
|
<td colSpan={4} className="py-2 pr-3 text-right text-muted-foreground">
|
|
{t('total_label')}
|
|
</td>
|
|
<td className="whitespace-nowrap py-2 text-right font-medium tabular-nums">
|
|
{formatCurrency(total)}
|
|
</td>
|
|
</tr>
|
|
</tfoot>
|
|
</table>
|
|
</div>
|
|
)}
|
|
|
|
{preview.eligible.length === 0 && (
|
|
<p className="py-2 text-sm text-muted-foreground">{t('none_eligible')}</p>
|
|
)}
|
|
|
|
{preview.excluded.length > 0 && (
|
|
<div className="space-y-1 text-xs text-muted-foreground">
|
|
<p className="font-medium text-foreground">{t('excluded_title')}</p>
|
|
{preview.excluded.map((row) => (
|
|
<p key={row.id}>
|
|
{invoiceLabelById?.get(row.id) ?? row.id}:{' '}
|
|
{t(`excluded_reason_${row.reason}`)}
|
|
</p>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* The file downloads fine and only fails at the bank if the
|
|
upload agreement is missing: say the precondition up front. */}
|
|
<div className="flex items-start gap-2 rounded-lg border border-border p-3 text-xs">
|
|
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
|
<span className="text-muted-foreground">{t('pain001_agreement_warning')}</span>
|
|
</div>
|
|
|
|
{hasAlreadyBatched && (
|
|
<label className="flex items-start gap-2 text-xs">
|
|
<Checkbox
|
|
checked={confirmAlreadyBatched}
|
|
onCheckedChange={(checked) => setConfirmAlreadyBatched(checked === true)}
|
|
className="mt-0.5"
|
|
/>
|
|
<span className="text-muted-foreground">{t('confirm_already_batched')}</span>
|
|
</label>
|
|
)}
|
|
|
|
<div className="flex items-center justify-end gap-3">
|
|
<Button
|
|
variant="ghost"
|
|
onClick={() => onOpenChange(false)}
|
|
disabled={creating}
|
|
>
|
|
{t('cancel')}
|
|
</Button>
|
|
<Button onClick={handleCreate} disabled={!canCreate}>
|
|
{creating ? (
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
) : (
|
|
<Download className="mr-2 h-4 w-4" />
|
|
)}
|
|
{t('create_and_download')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|