feat(payments): betalfil UI (betalfil 3/3) (#1505)
* feat(payments): betalfil UI (betalfil 3/3) Bulk-select + Skapa betalfil bulkbar on the supplier-invoices list, preview dialog with per-line editable amount/date and exclusion reasons, payment-files history page with re-download, cancel and a sequential bulk mark-paid (duplicate guard respected, never forced), I betalfil chip on rows in active batches, clearing/kontonummer fields on the supplier form, and the supplier_payment_files namespace in sv+en. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sandbox): make the demo AP data betalfil-ready Demo supplier bankgiro numbers were not Luhn-valid, the unpaid demo invoice had remaining_amount 0 (no trigger derives it, so the list said 0 kr kvar att betala), and the company had no IBAN/BIC, all of which excluded the seeded data from the betalfil flow. Numbers swapped for Luhn-valid ones (991-2346 is Bankgirot's test number), a valid OCR added, and both bulk-insert rows set remaining_amount explicitly per the PostgREST normalization rule already documented inline. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
576a34750a
commit
f0bedc14af
@@ -7,6 +7,7 @@ import { useTranslations } from 'next-intl'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { DataListEmpty } from '@/components/ui/data-list'
|
||||
import { TH_CLASS, TD_CLASS, QUIET_LINK_CLASS } from '@/components/ui/dry-table'
|
||||
@@ -29,6 +30,24 @@ const NewSupplierInvoiceDialog = dynamic(
|
||||
{ loading: DialogLoadingSkeleton },
|
||||
)
|
||||
|
||||
const PaymentFileDialog = dynamic(
|
||||
() => import('@/components/supplier-invoices/PaymentFileDialog'),
|
||||
{ loading: DialogLoadingSkeleton },
|
||||
)
|
||||
|
||||
// Rough client-side gate for the payment-file bulk selection: the statuses
|
||||
// mark-paid accepts, SEK only, something left to pay, not a credit note. The
|
||||
// preview re-evaluates server-side (payee, OCR, active batches), so this only
|
||||
// decides which rows get a checkbox.
|
||||
function isBatchSelectable(inv: SupplierInvoice): boolean {
|
||||
return (
|
||||
['registered', 'approved', 'partially_paid', 'overdue'].includes(inv.status) &&
|
||||
!inv.is_credit_note &&
|
||||
inv.currency === 'SEK' &&
|
||||
inv.remaining_amount > 0.005
|
||||
)
|
||||
}
|
||||
|
||||
// One derivable chip per row (concept scene 21): Registrerad is the "waiting
|
||||
// for attest" state (outline), Godkänd the beige ready-to-pay state; paid is
|
||||
// the sage exception-free end state.
|
||||
@@ -79,6 +98,10 @@ export default function SupplierInvoicesPage() {
|
||||
const [fyPeriodId, setFyPeriodId] = useState<string | null>(null)
|
||||
const [fyPeriod, setFyPeriod] = useState<FiscalPeriod | null>(null)
|
||||
const [approvingId, setApprovingId] = useState<string | null>(null)
|
||||
// Payment-file bulk selection + the "already in an active betalfil" chip map.
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [activeBatchInvoiceIds, setActiveBatchInvoiceIds] = useState<Set<string>>(new Set())
|
||||
const [showPaymentDialog, setShowPaymentDialog] = useState(false)
|
||||
|
||||
// The "Registrera leverantörsfaktura" modal is driven by the URL (?new=1,
|
||||
// optionally with inbox_item_id for the invoice-inbox conversion flow) so
|
||||
@@ -98,8 +121,26 @@ export default function SupplierInvoicesPage() {
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
// Which invoices already sit in an active (not cancelled) betalfil: feeds
|
||||
// the "I betalfil" chip. Non-blocking; the list renders without it.
|
||||
async function fetchActiveBatchMembership() {
|
||||
try {
|
||||
const res = await fetch('/api/supplier-invoices/payment-batches?status=created')
|
||||
if (!res.ok) return
|
||||
const { data } = await res.json()
|
||||
const ids = new Set<string>()
|
||||
for (const batch of (data ?? []) as Array<{ supplier_invoice_ids?: string[] }>) {
|
||||
for (const id of batch.supplier_invoice_ids ?? []) ids.add(id)
|
||||
}
|
||||
setActiveBatchInvoiceIds(ids)
|
||||
} catch {
|
||||
// Chip data only; the list stays functional without it.
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchInvoices()
|
||||
fetchActiveBatchMembership()
|
||||
}, [])
|
||||
|
||||
// Mirrors the old standalone page's post-create navigation: inbox
|
||||
@@ -149,6 +190,34 @@ export default function SupplierInvoicesPage() {
|
||||
(inv) => inv.status === 'registered' || inv.status === 'approved' || inv.status === 'overdue',
|
||||
).length
|
||||
|
||||
const selectableInvoices = filteredInvoices.filter(isBatchSelectable)
|
||||
const allSelectableSelected =
|
||||
selectableInvoices.length > 0 && selectableInvoices.every((inv) => selectedIds.has(inv.id))
|
||||
|
||||
function toggleSelect(id: string) {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
// Labels the excluded rows in the payment dialog ("Derome CD3014794407"),
|
||||
// so a server-side exclusion never reads as a bare UUID.
|
||||
const invoiceLabelById = new Map(
|
||||
invoices.map((inv) => [
|
||||
inv.id,
|
||||
`${inv.supplier?.name ?? ''} ${inv.supplier_invoice_number}`.trim(),
|
||||
]),
|
||||
)
|
||||
|
||||
const handleBatchCreated = () => {
|
||||
setSelectedIds(new Set())
|
||||
fetchInvoices()
|
||||
fetchActiveBatchMembership()
|
||||
}
|
||||
|
||||
async function handleApprove(id: string) {
|
||||
setApprovingId(id)
|
||||
try {
|
||||
@@ -245,7 +314,10 @@ export default function SupplierInvoicesPage() {
|
||||
className="h-9 pl-10"
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-auto">
|
||||
<div className="ml-auto flex items-center gap-4">
|
||||
<Link href="/supplier-invoices/payment-files" className={QUIET_LINK_CLASS}>
|
||||
{t('payment_files_link')}
|
||||
</Link>
|
||||
<FyPicker
|
||||
value={fyPeriodId}
|
||||
onChange={(periodId, period) => {
|
||||
@@ -257,6 +329,37 @@ export default function SupplierInvoicesPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bulkbar: appears once anything is selected (transactions-page shape). */}
|
||||
{selectedIds.size > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-x-5 gap-y-2 border-b border-border px-1 py-2.5 text-[12.5px] animate-fade-in">
|
||||
<span className="whitespace-nowrap">
|
||||
<strong className="font-semibold tabular-nums">{selectedIds.size}</strong>{' '}
|
||||
{t('bulkbar_selected', { count: selectedIds.size })}
|
||||
</span>
|
||||
<Button size="sm" onClick={() => setShowPaymentDialog(true)}>
|
||||
{t('bulk_create_file')}
|
||||
</Button>
|
||||
{!allSelectableSelected && (
|
||||
<button
|
||||
type="button"
|
||||
className={QUIET_LINK_CLASS}
|
||||
onClick={() =>
|
||||
setSelectedIds(new Set(selectableInvoices.map((inv) => inv.id)))
|
||||
}
|
||||
>
|
||||
{t('bulk_select_all', { count: selectableInvoices.length })}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={QUIET_LINK_CLASS}
|
||||
onClick={() => setSelectedIds(new Set())}
|
||||
>
|
||||
{t('bulk_clear')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
@@ -288,6 +391,7 @@ export default function SupplierInvoicesPage() {
|
||||
<table className="w-full border-collapse text-[13px]">
|
||||
<thead>
|
||||
<tr>
|
||||
{canWrite && <th className={cn(TH_CLASS, 'w-[26px] !pl-1')} aria-hidden="true"></th>}
|
||||
<th className={cn(TH_CLASS, 'w-full')}>{t('th_supplier')}</th>
|
||||
<th className={TH_CLASS}>{t('th_invoice_number')}</th>
|
||||
<th className={cn(TH_CLASS, 'hidden text-right md:table-cell')}>{t('th_invoice_date')}</th>
|
||||
@@ -311,12 +415,37 @@ export default function SupplierInvoicesPage() {
|
||||
// them there), so attest keys off approved_at, not the status.
|
||||
const canApprove =
|
||||
canApproveSupplierInvoice(inv) && !inv.is_credit_note && canWrite
|
||||
const selectable = canWrite && isBatchSelectable(inv)
|
||||
return (
|
||||
<tr
|
||||
key={inv.id}
|
||||
className="group cursor-pointer transition-colors duration-150 hover:bg-secondary/35"
|
||||
className={cn(
|
||||
'group cursor-pointer transition-colors duration-150 hover:bg-secondary/35',
|
||||
selectedIds.has(inv.id) && 'bg-secondary/40',
|
||||
)}
|
||||
onClick={() => router.push(`/supplier-invoices/${inv.id}`)}
|
||||
>
|
||||
{/* Hover-revealed selection checkbox (JournalEntryList shape). */}
|
||||
{canWrite && (
|
||||
<td
|
||||
className={cn(TD_CLASS, 'w-[26px] !pl-1 py-[9px]')}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{selectable && (
|
||||
<Checkbox
|
||||
checked={selectedIds.has(inv.id)}
|
||||
onCheckedChange={() => toggleSelect(inv.id)}
|
||||
aria-label={t('bulk_select_row')}
|
||||
className={cn(
|
||||
'transition-opacity duration-150',
|
||||
selectedIds.has(inv.id) || selectedIds.size > 0
|
||||
? 'opacity-100'
|
||||
: 'opacity-0 group-hover:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
<td className={cn(TD_CLASS, 'max-w-0 w-full')}>
|
||||
<span className="block truncate">{inv.supplier?.name || '-'}</span>
|
||||
</td>
|
||||
@@ -348,9 +477,16 @@ export default function SupplierInvoicesPage() {
|
||||
{formatCurrency(inv.remaining_amount, inv.currency)}
|
||||
</td>
|
||||
<td className={cn(TD_CLASS, 'whitespace-nowrap')}>
|
||||
<Badge variant={chipVariant} className="font-normal">
|
||||
{chipLabel}
|
||||
</Badge>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Badge variant={chipVariant} className="font-normal">
|
||||
{chipLabel}
|
||||
</Badge>
|
||||
{activeBatchInvoiceIds.has(inv.id) && inv.status !== 'paid' && (
|
||||
<Badge variant="outline" className="font-normal">
|
||||
{t('in_batch_chip')}
|
||||
</Badge>
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
{/* Attest as a hover action on registered rows (concept):
|
||||
approval gates payment, so it lives right on the row. */}
|
||||
@@ -390,6 +526,18 @@ export default function SupplierInvoicesPage() {
|
||||
onCreated={handleCreated}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showPaymentDialog && (
|
||||
<PaymentFileDialog
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setShowPaymentDialog(false)
|
||||
}}
|
||||
invoiceIds={Array.from(selectedIds)}
|
||||
invoiceLabelById={invoiceLabelById}
|
||||
onCreated={handleBatchCreated}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useLocale, useTranslations } from 'next-intl'
|
||||
import { PageHeader } from '@/components/ui/page-header'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { TH_CLASS, TD_CLASS, QUIET_LINK_CLASS } from '@/components/ui/dry-table'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
SlideOver,
|
||||
SlideOverBody,
|
||||
SlideOverContent,
|
||||
SlideOverFooter,
|
||||
SlideOverHeader,
|
||||
} from '@/components/ui/slide-over'
|
||||
import { FileText, Loader2 } from 'lucide-react'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
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 { cn, formatCurrency, formatDate } from '@/lib/utils'
|
||||
import type { SupplierPaymentBatch, SupplierPaymentBatchItem } from '@/types'
|
||||
|
||||
type BatchListRow = SupplierPaymentBatch & { settled_count: number }
|
||||
|
||||
type BatchItemWithInvoice = SupplierPaymentBatchItem & {
|
||||
invoice: {
|
||||
id: string
|
||||
status: string
|
||||
remaining_amount: number
|
||||
supplier_invoice_number: string
|
||||
arrival_number: number
|
||||
} | null
|
||||
}
|
||||
|
||||
type BatchDetail = SupplierPaymentBatch & { items: BatchItemWithInvoice[] }
|
||||
|
||||
/** Mirrors the öre epsilon the server derives settled_count with. */
|
||||
const SETTLED_EPSILON = 0.005
|
||||
|
||||
function batchFilename(batch: Pick<SupplierPaymentBatch, 'id' | 'created_at'>): string {
|
||||
const datePart = batch.created_at.slice(0, 10).replace(/-/g, '')
|
||||
return `betalfil_${datePart}_${batch.id.replace(/-/g, '').slice(0, 8)}.xml`
|
||||
}
|
||||
|
||||
export default function PaymentFilesPage() {
|
||||
const t = useTranslations('supplier_payment_files')
|
||||
const locale = useLocale() as ErrorLocale
|
||||
const { toast } = useToast()
|
||||
const { canWrite } = useCanWrite()
|
||||
|
||||
const [batches, setBatches] = useState<BatchListRow[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [detail, setDetail] = useState<BatchDetail | null>(null)
|
||||
const [detailId, setDetailId] = useState<string | null>(null)
|
||||
const [downloadingId, setDownloadingId] = useState<string | null>(null)
|
||||
const [confirmCancelId, setConfirmCancelId] = useState<string | null>(null)
|
||||
const [cancelling, setCancelling] = useState(false)
|
||||
const [markingAll, setMarkingAll] = useState(false)
|
||||
|
||||
const fetchBatches = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const res = await fetch('/api/supplier-invoices/payment-batches?status=all')
|
||||
const body = await res.json()
|
||||
setBatches((body.data as BatchListRow[]) ?? [])
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchBatches()
|
||||
}, [fetchBatches])
|
||||
|
||||
const openDetail = useCallback(async (id: string) => {
|
||||
setDetailId(id)
|
||||
setDetail(null)
|
||||
const res = await fetch(`/api/supplier-invoices/payment-batches/${id}`)
|
||||
if (!res.ok) {
|
||||
setDetailId(null)
|
||||
return
|
||||
}
|
||||
const body = await res.json()
|
||||
setDetail(body.data as BatchDetail)
|
||||
}, [])
|
||||
|
||||
async function handleDownload(batch: Pick<SupplierPaymentBatch, 'id' | 'created_at'>) {
|
||||
if (downloadingId) return
|
||||
setDownloadingId(batch.id)
|
||||
try {
|
||||
const result = await downloadFile({
|
||||
url: `/api/supplier-invoices/payment-batches/${batch.id}/file`,
|
||||
filename: batchFilename(batch),
|
||||
locale,
|
||||
})
|
||||
if (!result.ok) {
|
||||
toast({
|
||||
title: t('download_failed_title'),
|
||||
description: failureDescription(result, {
|
||||
timeout: t('download_timeout'),
|
||||
network: t('download_network'),
|
||||
}),
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
setDownloadingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancel() {
|
||||
if (!confirmCancelId || cancelling) return
|
||||
setCancelling(true)
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/supplier-invoices/payment-batches/${confirmCancelId}/cancel`,
|
||||
{ method: 'POST' },
|
||||
)
|
||||
const body = await res.json()
|
||||
if (!res.ok) {
|
||||
toast({
|
||||
title: t('cancel_failed_title'),
|
||||
description: getErrorMessage(body, { locale }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
} else {
|
||||
toast({ title: t('cancelled_toast') })
|
||||
}
|
||||
setConfirmCancelId(null)
|
||||
setDetailId(null)
|
||||
fetchBatches()
|
||||
} finally {
|
||||
setCancelling(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Sequential mark-paid per item, reusing the existing per-invoice route with
|
||||
// its duplicate-payment guard intact. Never force: a 409 duplicate means a
|
||||
// matching bank transaction is already in the feed and bank matching is the
|
||||
// right way to settle that invoice.
|
||||
async function handleMarkAllPaid() {
|
||||
if (!detail || markingAll) return
|
||||
setMarkingAll(true)
|
||||
let booked = 0
|
||||
let skippedSettled = 0
|
||||
let skippedDuplicate = 0
|
||||
let failed = 0
|
||||
try {
|
||||
for (const item of detail.items) {
|
||||
const invoice = item.invoice
|
||||
if (!invoice || invoice.remaining_amount <= SETTLED_EPSILON) {
|
||||
skippedSettled += 1
|
||||
continue
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`/api/supplier-invoices/${item.supplier_invoice_id}/mark-paid`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
amount: Math.min(item.amount, invoice.remaining_amount),
|
||||
payment_date: item.payment_date,
|
||||
}),
|
||||
})
|
||||
if (res.ok) {
|
||||
booked += 1
|
||||
continue
|
||||
}
|
||||
const body = await res.json()
|
||||
if (body?.error?.code === 'SI_PAID_LIKELY_DUPLICATE') skippedDuplicate += 1
|
||||
else failed += 1
|
||||
} catch {
|
||||
failed += 1
|
||||
}
|
||||
}
|
||||
|
||||
toast({
|
||||
title: t('mark_all_result_title', { booked }),
|
||||
description:
|
||||
skippedDuplicate > 0
|
||||
? t('mark_all_result_duplicates', { count: skippedDuplicate })
|
||||
: failed > 0
|
||||
? t('mark_all_result_failed', { count: failed })
|
||||
: skippedSettled > 0
|
||||
? t('mark_all_result_settled', { count: skippedSettled })
|
||||
: undefined,
|
||||
variant: failed > 0 ? 'destructive' : undefined,
|
||||
})
|
||||
await fetchBatches()
|
||||
if (detailId) await openDetail(detailId)
|
||||
} finally {
|
||||
setMarkingAll(false)
|
||||
}
|
||||
}
|
||||
|
||||
const detailUnsettled =
|
||||
detail?.items.filter(
|
||||
(item) => item.invoice && item.invoice.remaining_amount > SETTLED_EPSILON,
|
||||
).length ?? 0
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<PageHeader title={t('history_title')} />
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="flex items-center gap-4 px-4 py-3">
|
||||
<Skeleton className="h-4 w-28" />
|
||||
<Skeleton className="h-4 w-20 flex-1" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : batches.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={FileText}
|
||||
title={t('empty_title')}
|
||||
description={t('empty_description')}
|
||||
actionLabel={t('empty_action')}
|
||||
actionHref="/supplier-invoices"
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse text-[13px]">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={TH_CLASS}>{t('th_created')}</th>
|
||||
<th className={cn(TH_CLASS, 'text-right')}>{t('th_count')}</th>
|
||||
<th className={cn(TH_CLASS, 'text-right')}>{t('th_total')}</th>
|
||||
<th className={cn(TH_CLASS, 'w-full')}>{t('th_status')}</th>
|
||||
<th className={cn(TH_CLASS, 'w-[180px]')} aria-hidden="true"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="stagger-enter">
|
||||
{batches.map((batch) => (
|
||||
<tr
|
||||
key={batch.id}
|
||||
className="group cursor-pointer transition-colors duration-150 hover:bg-secondary/35"
|
||||
onClick={() => openDetail(batch.id)}
|
||||
>
|
||||
<td className={cn(TD_CLASS, 'whitespace-nowrap tabular-nums')}>
|
||||
{formatDate(batch.created_at)}
|
||||
</td>
|
||||
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right tabular-nums')}>
|
||||
{batch.item_count}
|
||||
</td>
|
||||
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right tabular-nums rr-mask')}>
|
||||
{formatCurrency(batch.total_amount)}
|
||||
</td>
|
||||
<td className={cn(TD_CLASS, 'whitespace-nowrap')}>
|
||||
{batch.status === 'cancelled' ? (
|
||||
<Badge variant="outline" className="font-normal">
|
||||
{t('status_cancelled')}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground">
|
||||
{t('settled_count', {
|
||||
settled: batch.settled_count,
|
||||
total: batch.item_count,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td
|
||||
className={cn(TD_CLASS, 'whitespace-nowrap text-right')}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span className="flex items-center justify-end gap-4 opacity-0 transition-opacity duration-150 focus-within:opacity-100 group-hover:opacity-100 pointer-coarse:opacity-100">
|
||||
{batch.status === 'created' && (
|
||||
<button
|
||||
type="button"
|
||||
className={QUIET_LINK_CLASS}
|
||||
onClick={() => handleDownload(batch)}
|
||||
disabled={downloadingId !== null}
|
||||
>
|
||||
{t('download_again')}
|
||||
</button>
|
||||
)}
|
||||
{batch.status === 'created' && canWrite && (
|
||||
<button
|
||||
type="button"
|
||||
className={QUIET_LINK_CLASS}
|
||||
onClick={() => setConfirmCancelId(batch.id)}
|
||||
>
|
||||
{t('cancel_batch')}
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Batch detail: right slide-over (convention 13). */}
|
||||
<SlideOver
|
||||
open={detailId != null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setDetailId(null)
|
||||
setDetail(null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SlideOverContent aria-describedby={undefined}>
|
||||
{detail ? (
|
||||
<>
|
||||
<SlideOverHeader
|
||||
kicker={formatDate(detail.created_at)}
|
||||
title={t('detail_title', { count: detail.item_count })}
|
||||
/>
|
||||
<SlideOverBody className="space-y-4">
|
||||
{detail.status === 'cancelled' && (
|
||||
<Badge variant="outline" className="font-normal">
|
||||
{t('status_cancelled')}
|
||||
</Badge>
|
||||
)}
|
||||
<div className="space-y-0">
|
||||
{detail.items.map((item) => {
|
||||
const settled =
|
||||
!item.invoice || item.invoice.remaining_amount <= SETTLED_EPSILON
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center gap-3 border-b border-border/60 py-2.5 text-[13px]"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<Link
|
||||
href={`/supplier-invoices/${item.supplier_invoice_id}`}
|
||||
className="block truncate hover:underline"
|
||||
>
|
||||
{item.payee_name}
|
||||
</Link>
|
||||
<span className="block text-[11px] text-muted-foreground tabular-nums">
|
||||
{item.invoice?.supplier_invoice_number ?? item.reference}
|
||||
{' · '}
|
||||
{formatDate(item.payment_date)}
|
||||
</span>
|
||||
</div>
|
||||
{settled ? (
|
||||
<span className="whitespace-nowrap text-[11px] text-muted-foreground">
|
||||
{t('item_settled')}
|
||||
</span>
|
||||
) : (
|
||||
<Badge variant="outline" className="font-normal">
|
||||
{t('item_unsettled')}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="whitespace-nowrap text-right tabular-nums rr-mask">
|
||||
{formatCurrency(item.amount)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-[13px]">
|
||||
<span className="text-muted-foreground">{t('total_label')}</span>
|
||||
<span className="font-medium tabular-nums rr-mask">
|
||||
{formatCurrency(detail.total_amount)}
|
||||
</span>
|
||||
</div>
|
||||
{detail.status === 'created' && detailUnsettled > 0 && (
|
||||
<p className="text-xs text-muted-foreground">{t('mark_all_hint')}</p>
|
||||
)}
|
||||
</SlideOverBody>
|
||||
<SlideOverFooter>
|
||||
<div className="flex w-full flex-wrap items-center justify-end gap-3">
|
||||
{detail.status === 'created' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleDownload(detail)}
|
||||
disabled={downloadingId !== null}
|
||||
>
|
||||
{t('download_again')}
|
||||
</Button>
|
||||
)}
|
||||
{detail.status === 'created' && canWrite && detailUnsettled > 0 && (
|
||||
<Button onClick={handleMarkAllPaid} disabled={markingAll}>
|
||||
{markingAll && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t('mark_all_paid', { count: detailUnsettled })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</SlideOverFooter>
|
||||
</>
|
||||
) : (
|
||||
<SlideOverBody className="space-y-3 pt-6">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-5 w-full" />
|
||||
))}
|
||||
</SlideOverBody>
|
||||
)}
|
||||
</SlideOverContent>
|
||||
</SlideOver>
|
||||
|
||||
{/* Cancel confirm (convention 10): describe the outcome up front. */}
|
||||
<Dialog
|
||||
open={confirmCancelId != null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !cancelling) setConfirmCancelId(null)
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('cancel_confirm_title')}</DialogTitle>
|
||||
<DialogDescription>{t('cancel_confirm_body')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setConfirmCancelId(null)}
|
||||
disabled={cancelling}
|
||||
>
|
||||
{t('cancel_confirm_abort')}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleCancel} disabled={cancelling}>
|
||||
{cancelling && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t('cancel_confirm_action')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -167,6 +167,12 @@ export async function POST(request: Request) {
|
||||
next_invoice_number: 5,
|
||||
next_delivery_note_number: 1,
|
||||
invoice_default_days: 30,
|
||||
// Sender bank details: the pain.001 debtor for the betalfil demo.
|
||||
// Example IBAN from the Swedish IBAN documentation range; BIC derives
|
||||
// from it being an SEB-style example. Demo-only values.
|
||||
iban: 'SE3550000000054910000003',
|
||||
bic: 'ESSESESS',
|
||||
bankgiro: '991-2346',
|
||||
onboarding_step: 6,
|
||||
onboarding_complete: true,
|
||||
initial_setup_path: 'fresh',
|
||||
@@ -867,7 +873,9 @@ export async function POST(request: Request) {
|
||||
org_number: '5559000001',
|
||||
vat_number: 'SE555900000101',
|
||||
email: 'demo+telekom@example.com',
|
||||
bankgiro: '5559-0001',
|
||||
// Luhn-valid (Bankgirot check digit): the betalfil flow validates
|
||||
// payee numbers, so demo suppliers must carry numbers that pass.
|
||||
bankgiro: '5559-0004',
|
||||
address_line1: 'Demovägen 10',
|
||||
postal_code: '111 22',
|
||||
city: 'Stockholm',
|
||||
@@ -881,7 +889,7 @@ export async function POST(request: Request) {
|
||||
supplier_type: 'swedish_business',
|
||||
org_number: '5559000002',
|
||||
vat_number: 'SE555900000201',
|
||||
bankgiro: '5559-0002',
|
||||
bankgiro: '5559-0012',
|
||||
address_line1: 'Demovägen 11',
|
||||
postal_code: '111 22',
|
||||
city: 'Stockholm',
|
||||
@@ -923,6 +931,9 @@ export async function POST(request: Request) {
|
||||
payment_reference: '47112026031',
|
||||
paid_at: toDateStr(fifteenDaysAgo),
|
||||
paid_amount: 600,
|
||||
// Same normalization rule as paid_amount below: the other row in
|
||||
// this bulk insert sets remaining_amount, so this one must too.
|
||||
remaining_amount: 0,
|
||||
},
|
||||
{
|
||||
user_id: userId,
|
||||
@@ -938,11 +949,17 @@ export async function POST(request: Request) {
|
||||
subtotal: 240,
|
||||
vat_amount: 28.80,
|
||||
total: 268.80,
|
||||
// Luhn-valid OCR so the betalfil preview demos the structured
|
||||
// reference path instead of the invoice-number fallback.
|
||||
payment_reference: '882456',
|
||||
// Must be set explicitly: PostgREST normalizes columns across
|
||||
// rows in a bulk insert, so omitting paid_amount here while the
|
||||
// first row sets it sends null instead of falling through to the
|
||||
// schema default (0), violating the NOT NULL constraint.
|
||||
paid_amount: 0,
|
||||
// No trigger derives this; without it the unpaid demo invoice
|
||||
// shows "0 kr kvar att betala" and cannot join a betalfil.
|
||||
remaining_amount: 268.80,
|
||||
},
|
||||
])
|
||||
.select('id, supplier_invoice_number')
|
||||
|
||||
@@ -94,6 +94,8 @@ export const PUT = withRouteContext(
|
||||
bank_account: body.bank_account,
|
||||
iban: body.iban,
|
||||
bic: body.bic,
|
||||
clearing_number: body.clearing_number,
|
||||
account_number: body.account_number,
|
||||
default_expense_account: body.default_expense_account,
|
||||
default_payment_terms: body.default_payment_terms,
|
||||
default_currency: body.default_currency,
|
||||
|
||||
@@ -63,6 +63,8 @@ export const POST = withRouteContext(
|
||||
bank_account: body.bank_account,
|
||||
iban: body.iban,
|
||||
bic: body.bic,
|
||||
clearing_number: body.clearing_number,
|
||||
account_number: body.account_number,
|
||||
default_expense_account: body.default_expense_account,
|
||||
default_payment_terms: body.default_payment_terms || 30,
|
||||
default_currency: body.default_currency || 'SEK',
|
||||
|
||||
Reference in New Issue
Block a user